diff --git a/.opencode/agents/architect.md b/.opencode/agents/architect.md index 0a0edb5..1d39735 100644 --- a/.opencode/agents/architect.md +++ b/.opencode/agents/architect.md @@ -37,10 +37,10 @@ docs/architecture/ The README is the entry point. It contains: -1. **Current State** — what phase the project is in, what's implemented -2. **Architecture Documents** — table linking to each spec doc with status -3. **ADR Table** — every decision with number, title, and status -4. **Open Questions** — link to `open-questions.md` +1. **Current State**: what phase the project is in, what's implemented +2. **Architecture Documents**: a table linking to each spec doc with status +3. **ADR Table**: every decision with number, title, and status +4. **Open Questions**: a link to `open-questions.md` ### Spec Documents @@ -83,7 +83,7 @@ Accepted | Proposed | Deprecated | Superseded (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 mark the old one Superseded. @@ -97,12 +97,12 @@ don't need ADRs. `open-questions.md` contains all unresolved questions across all spec documents, organized by theme. Each question has: -- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference -- **Origin** — which spec doc(s) the question appeared in -- **Status** — open, resolved, partially resolved -- **Priority** — high, medium, low -- **Resolution** — when resolved, what was decided and which ADR addresses it -- **Cross-references** — related OQs and ADRs +- **OQ-ID** (OQ-01, OQ-02, ...), the stable reference +- **Origin**: which spec doc(s) the question appeared in +- **Status**: open, resolved, partially resolved +- **Priority**: high, medium, low +- **Resolution**: when resolved, what was decided and which ADR addresses it +- **Cross-references**: related OQs and ADRs 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 @@ -136,7 +136,7 @@ Before writing architecture: - Understand the problem domain - Identify constraints and quality attributes - 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 ### 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 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 open questions tracked centrally. @@ -180,7 +180,7 @@ Before requesting external review: blocking condition (for `deferred(scope)`) or investigation target (for `deferred(unclear)`) isn't a *prerequisite* of the thing you're 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 make it clear). @@ -191,7 +191,7 @@ When you encounter a decision that genuinely can't be made: 1. Mark the OQ as `deferred(scope)` with a concrete blocking condition 2. Create a blocker task in `tasks/architecture/` naming the dependency (frontmatter schema: §"Task files you write" under Safe Exit below) -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 @@ -217,12 +217,12 @@ task( 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 terms, circular deferrals -- **Warning**: Should fix — missing cross-references, documents approaching +- **Warning**: Should fix (missing cross-references, documents approaching split threshold -- **Suggestion**: Consider — minor clarity improvements +- **Suggestion**: Consider (minor clarity improvements Iterate until zero critical issues. @@ -265,7 +265,7 @@ last_updated: 2026-05-29 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 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 and to clearly identify which decisions *can't* be made yet and why. @@ -273,7 +273,7 @@ last_updated: 2026-05-29 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 -later — not a decision to defer. Using "it's a two-way door" as a +later, not a decision to defer. Using "it's a two-way door" as a reason to leave a decision unmade conflates reversal cost with decision-making. See ADR-009 §"What this framework is NOT" for the full rationale. @@ -282,17 +282,17 @@ Architecture decisions are the architect's, regardless of door type. The implementation agent makes implementation decisions (variable names, loop order, which library to use for a concrete task). If a 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 -1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs — extract to ADRs -2. **Inline open questions**: Scattered per-doc "Open Questions" sections — +1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs; extract to ADRs +2. **Inline open questions**: Scattered per-doc "Open Questions" sections; 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 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 6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?", write an ADR @@ -311,7 +311,7 @@ reasoning to see its own circular hedges; a fresh context catches them. ## Safe Exit: Deferred Decisions 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 decisions *can't* be made yet and why. @@ -319,10 +319,10 @@ There are two kinds of deferral. The distinction matters because they have different resolution paths, and confusing them is a source of 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 -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. A decision should be `deferred(scope)` when: @@ -337,11 +337,11 @@ A decision should be `deferred(scope)` when: current scope is core + call crates; this question is about the agent 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 -types, existing patterns) but the composition — how they fit together -into a coherent shape — isn't clear yet. Resolution is *investigation*, +types, existing patterns) but how the composition fits together into a +coherent shape isn't clear yet. Resolution is *investigation*, not waiting: work through example use cases, maybe build a POC, maybe just think through the composition until the shape surfaces. @@ -353,7 +353,7 @@ pieces can be decided (verifier selection, crypto provider, fingerprint normalization) while the shape they compose into (the client config type) is still unclear. Forcing a decision in that state produces a 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 yet, and I need to work through examples to see it." @@ -364,28 +364,28 @@ A decision should be `deferred(unclear)` when: - Resolution requires *work* (thinking through examples, building a POC), not *waiting* (for a spec or use case to arrive) - 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 ### 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` (implies it's decided). 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: the alkfs sftp-mapping spec exists" or "investigation: work through 2+ example git-over-ssh use cases (clone and push) to see how identity + auth + the exec/subsystem channel mapping compose." -3. **State the impacts** — what does this block downstream? Be +3. **State the impacts**: what does this block downstream? Be specific: "blocks the http endpoint because receive-pack needs unbuffered POST ingestion" not "blocks the http crate." This is the triage signal that makes the deferral's urgency visible. If the impact is significant, the deferral needs to be addressed soon; if it's a future feature, 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 next architecture revision. @@ -398,10 +398,10 @@ A decision should be `deferred(unclear)` when: - **Status**: deferred(scope) | deferred(unclear) - **Door type**: - **Priority**: -- **Impacts**: +- **Impacts**: - **Blocked on**: (for deferred(scope)) **Investigation**: (for deferred(unclear)) -- **Resolution**: Not yet decidable. - **Cross-references**: OQ-NN, ADR-NNN @@ -410,14 +410,15 @@ A decision should be `deferred(unclear)` when: ### Task files you write (blocker/OQ trackers in `tasks/architecture/`) When you file or edit a task under `tasks/`, you follow the taskgraph -format — the same closed schema the decomposer uses. Frontmatter +format, the same closed schema the decomposer uses. Frontmatter (taskgraph 0.1.3; invalid variants fail `taskgraph validate`): ```yaml --- id: architecture/ # required name: # required -status: pending # pending | in-progress | completed | failed | blocked — never `done` +status: pending # pending | in-progress | completed | failed; + # never `done` depends_on: [] # task ids; the graph is the dependency record scope: narrow # single | narrow | moderate | broad | system risk: low # trivial | low | medium | high | critical @@ -427,7 +428,7 @@ tags: [deferred-oq, ...] # free-form --- ``` -- **Every file under `tasks/` carries a complete frontmatter block** — +- **Every file under `tasks/` carries a complete frontmatter block**; a file without frontmatter is *silently skipped* by `taskgraph validate`/`list` and every graph command: no error, the task simply doesn't exist as far as the graph is concerned. Prose-only trackers @@ -439,7 +440,7 @@ tags: [deferred-oq, ...] # free-form §"Architecture work and the `level` field" (ADR/spec = `implementation`, POC/validation = `research`, decomposition pass = `decomposition`, review = `review`). Deferred-OQ trackers are usually `research`. -- **`taskgraph validate` is the gate** — run it after writing or +- **`taskgraph validate` is the gate**; run it after writing or editing anything under `tasks/`. Semantics of the categorical fields live in `docs/sdd_process.md` §"Categorical Estimates". @@ -448,12 +449,12 @@ tags: [deferred-oq, ...] # free-form - Do not mark a deferred decision as `resolved` with caveats. "Resolved with an escape hatch" is hedging. - 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)`. - 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 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. ## When to Redirect diff --git a/.opencode/agents/architecture-reviewer.md b/.opencode/agents/architecture-reviewer.md index 093104a..c249239 100644 --- a/.opencode/agents/architecture-reviewer.md +++ b/.opencode/agents/architecture-reviewer.md @@ -84,7 +84,7 @@ Check coverage of: #### 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 fresh context can see the whole picture and spot reasoning that folds 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 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 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. -- "additive, not blocking" — if it's not decided, don't claim it is. -- "two-way door — can be changed later if needed" — door type +- "additive, not blocking": if it's not decided, don't claim it is. +- "two-way door, can be changed later if needed": door type 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. -- "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. - 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, so drop the undo instructions. If it's "X for now, Y later," the decision is 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 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 blocker needs it. @@ -136,17 +136,17 @@ Check each deferred OQ: or do the pieces exist but the shape wasn't synthesized (`deferred(unclear)` is correct), or do the pieces *and* the shape 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 -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)` only if the information genuinely is missing. **3. Legitimate deferral.** A deferred OQ where the information genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist 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 thing being deferred. @@ -156,13 +156,13 @@ Every unresolved OQ (`open`, `deferred(scope)`, `deferred(unclear)`, `partially resolved`) should have an **impacts** field stating what it 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. - Is it specific? "Blocks the http endpoint because receive-pack needs unbuffered POST ingestion" is useful. "Blocks the http crate" is boilerplate. - 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. Flag missing impacts fields as **warning**, vague ones as @@ -260,5 +260,5 @@ section 2" - Critical issues must block stabilization - The Decision Quality (F) category is often the highest-value check on 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. diff --git a/.opencode/agents/code-reviewer.md b/.opencode/agents/code-reviewer.md index 435b753..e7cb603 100644 --- a/.opencode/agents/code-reviewer.md +++ b/.opencode/agents/code-reviewer.md @@ -21,7 +21,7 @@ You are a subagent - you are invoked by the Coordinator or as a review task. ## Working in Worktrees 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. ```text @@ -111,9 +111,9 @@ cargo fmt --check # Format check For this project, also verify: - No comments in code (per project convention) -- Error handling uses `thiserror` for library error types — no panics in +- Error handling uses `thiserror` for library error types; no panics in library code, no `unwrap()`/`expect()` outside tests -- Feature flags are used correctly (`sha256`, `acme`) — base crates compile +- Feature flags are used correctly (`sha256`, `acme`); base crates compile lean; default features stay minimal - Public API is well-documented with `///` doc comments where appropriate - Module structure follows Rust conventions (`mod.rs`, `lib.rs`) diff --git a/.opencode/agents/coordinator.md b/.opencode/agents/coordinator.md index a6e4f2f..7727d79 100644 --- a/.opencode/agents/coordinator.md +++ b/.opencode/agents/coordinator.md @@ -23,7 +23,7 @@ You manage the execution of decomposed task graphs: ## The `worktree` Tool (via @alkimiadev/open-coordinator) 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. ### Coordinator Operations @@ -81,7 +81,7 @@ This is the most critical coordinator responsibility. Follow it exactly: If merge conflicts occur: - **Source code conflicts between parallel tasks** that modify the same file: 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. - **Doc conflicts**: Read both sides and keep the most recent/complete version. Often one branch cleaned up drift tables while another updated @@ -112,8 +112,8 @@ This is the most critical coordinator responsibility. Follow it exactly: ```text worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/", remote: true}}) ``` - 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 + The `remote: true` flag tells the plugin to also delete the remote branch, + so no separate `git push origin --delete` is needed. If you need to force-remove a dirty worktree, add `force: true`. **Bulk cleanup of merged branches** (useful after completing a generation): @@ -132,12 +132,12 @@ in this order: latest main) 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 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. 1. **Read the blocking message carefully.** The agent should have included what @@ -162,7 +162,7 @@ ignore it. ``` 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 - Ambiguous architecture? Ask the user to clarify - Scope too large? Decompose into smaller tasks @@ -179,15 +179,15 @@ ignore it. The `prompt` parameter supports `{{task}}` template substitution. Use it, but also include: -1. **Task identification** — How to find their task file in `tasks/` -2. **Merge from main** — Tell them to +1. **Task identification**: how to find their task file in `tasks/` +2. **Merge from main**: tell them to `git fetch origin && git merge origin/main --no-edit` before starting, since main may have advanced since their worktree was created -3. **Key references** — Which source files and architecture docs to read -4. **Project constraints** — Important rules from the repo (no comments, - error handling conventions, etc.). Point at `AGENTS.md` at the repo root — - it is the authoritative list; don't duplicate the whole list here. -5. **Done signal** — Use `worktree({action: "notify", ...})` when complete +3. **Key references**: which source files and architecture docs to read +4. **Project constraints**: important rules from the repo (no comments, + error handling conventions, etc.). Point at `AGENTS.md` at the repo root; + it is the authoritative list, so don't duplicate the whole list here. +5. **Done signal**: use `worktree({action: "notify", ...})` when complete Example prompt template: @@ -201,13 +201,13 @@ Your task: {{task}} 3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit 4. Implement the changes, following all acceptance criteria. 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) 8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. ", level: "info"}}) Key project constraints (@alkdev/alkssh): - russh-wrapped SSH adapter over alkcall channels (family ADR-003); - no in-family SSH framing — russh owns the wire + no in-family SSH framing, russh owns the wire - Every channel-type mapping is a cargo feature; unfeatured channel opens decline fail-closed (a git-only build serves no shell) - Keys flow through alkvault-backed sources; never env vars @@ -234,7 +234,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. 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. If you want to avoid conflicts, make overlapping tasks sequential. But parallel @@ -259,7 +259,7 @@ worktree({action: "spawn", args: { 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 # directly instead of spawning a new agent ``` @@ -270,7 +270,7 @@ worktree({action: "spawn", args: { 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. -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. Check `worktree({action: "sessions"})` when: @@ -316,7 +316,7 @@ Use these when: 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 -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. Only spawn a review task as an agent if the review requires extensive manual @@ -325,7 +325,7 @@ inspection of many files. ## Task File Handling 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. ### Why Agents Don't Commit Task Files @@ -404,12 +404,12 @@ When an agent reports blocked or an anomaly fires: what happened 2. Send guidance via `worktree({action: "message", ...})` if you can help 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 ### 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 of changes, validate, and commit. Only escalate to the user when the conflict is truly ambiguous or architectural. @@ -422,8 +422,8 @@ After merging and pushing: ```text worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/", remote: true}}) ``` - The `remote: true` flag handles remote branch deletion automatically — no - separate `git push origin --delete` needed. + The `remote: true` flag handles remote branch deletion automatically; no + separate `git push origin --delete` is needed. Don't let stale branches accumulate. diff --git a/.opencode/agents/implementation-specialist.md b/.opencode/agents/implementation-specialist.md index ad1831b..2fc4607 100644 --- a/.opencode/agents/implementation-specialist.md +++ b/.opencode/agents/implementation-specialist.md @@ -10,7 +10,7 @@ graph. ## Your Environment **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):** @@ -60,14 +60,14 @@ auto-injects `workdir` for bash commands when the session is mapped to a worktree. This means: ```bash -# ✅ CORRECT — workdir is auto-injected +# ✅ CORRECT: workdir is auto-injected cargo test -# ✅ ALSO CORRECT — explicit workdir still works +# ✅ ALSO CORRECT: explicit workdir still works 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. ## Workflow @@ -130,7 +130,7 @@ Check each acceptance criterion in the task file. ### 5. Commit and Notify ```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 commit -m "feat(): " git push origin $(git branch --show-current) @@ -168,7 +168,7 @@ When task becomes untendable: ### Process -1. **Stop** - don't force through +1. **Stop**, don't force through 2. **Notify coordinator** with a detailed blocking message. Include: - What you were trying to do - What went wrong (specific error, missing dep, ambiguous spec, etc.) @@ -178,29 +178,29 @@ When task becomes untendable: worktree({action: "notify", args: {message: "Blocked on : ", level: "blocking"}}) ``` 3. **Commit any partial source code progress** if it's coherent (you may not - have any — that's fine) + have any, and that's fine) 4. **Push your branch** so the coordinator can inspect your work if needed -5. **Exit** - coordinator handles escalation +5. **Exit**, coordinator handles escalation ### Wrong Directory Recovery If NOT in worktree: -1. **STOP** - no more file changes +1. **STOP**, no more file changes 2. **Safe Exit** via notify with blocking level -3. **Do NOT manually copy files** - causes conflicts +3. **Do NOT manually copy files**, it causes conflicts ## Context & Memory (via @alkdev/open-memory) 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 -- `memory({tool: "messages", args: {sessionId: "..."}})` — review previous +- `memory({tool: "messages", args: {sessionId: "..."}})`: review previous 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 -- `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% This is especially important for complex tasks that span many file operations. @@ -208,50 +208,50 @@ This is especially important for complex tasks that span many file operations. ## Project Conventions Read `AGENTS.md` at project root for the full authoritative list. Key -rules (summary — AGENTS.md wins on any conflict): +rules (summary; AGENTS.md wins on any conflict): -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. -2. **Error handling** — `thiserror` for library error types. No panics in +2. **Error handling**: `thiserror` for library error types. No panics in library code. No `unwrap()` or `expect()` outside tests. For poisoned `RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())` so a panic in one operation does not cascade to other operations. -3. **`tokio` is the async runtime** — all I/O is async. Do not introduce +3. **`tokio` is the async runtime**: all I/O is async. Do not introduce blocking I/O on the async path. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for correlation and subscription channels; `parking_lot` for short-held internal locks. -4. **No secret material on the wire or in plaintext at rest** — outbound +4. **No secret material on the wire or in plaintext at rest**: outbound credentials flow through `Capabilities` injected at the assembly layer; stored credentials live in alkvault, metadata stores hold references. -5. **No-env-vars invariant** — no handler reads outbound credentials from +5. **No-env-vars invariant**: no handler reads outbound credentials from any source other than `OperationContext.capabilities`. This is a spec-level invariant, not a runtime convention. -6. **Wire formats are stable one-way doors** — once published, an existing +6. **Wire formats are stable one-way doors**: once published, an existing wire format shape must not change (see AGENTS.md and the architecture ADRs for the current list per crate). -7. **Visible-surface = authorized-surface** — use alkcall's model: +7. **Visible-surface = authorized-surface**: use alkcall's model: `AccessControl` on every op; `Visibility::Internal` ops are never wire-callable. No unauthenticated endpoints; every capability the wire can reach must be explicitly granted. -8. **russh owns the SSH wire** — no in-family SSH framing (family +8. **russh owns the SSH wire**: no in-family SSH framing (family ADR-003); protocol asks to russh upstream, never silently absorbed. Keys flow through alkvault-backed sources, never env vars. -9. **Feature flags** — every channel-type mapping is a cargo feature +9. **Feature flags**: every channel-type mapping is a cargo feature and declines unfeatured channel opens fail-closed; base crate compiles lean. Verify both `cargo test` (default) and `cargo test --all-features` pass if features are added. -10. **Naming conventions** — Rust standard: `snake_case` for +10. **Naming conventions**: Rust standard: `snake_case` for functions/variables/modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants. -11. **Module structure** — one module per file under `src/`, re-exported +11. **Module structure**: one module per file under `src/`, re-exported from `src/lib.rs`. Public API surface is `lib.rs` re-exports. ## Key Principles -1. **Read first** - understand before implementing -2. **Verify before completing** - all criteria met -3. **Safe exit is okay** - better to block than force failures -4. **Minimal changes** - implement exactly what's needed -5. **Worktree isolation** - never touch files outside your worktree -6. **Communicate** - use `worktree({action: "notify", ...})` to keep coordinator +1. **Read first**: understand before implementing +2. **Verify before completing**: all criteria met +3. **Safe exit is okay**: better to block than force failures +4. **Minimal changes**: implement exactly what's needed +5. **Worktree isolation**: never touch files outside your worktree +6. **Communicate**: use `worktree({action: "notify", ...})` to keep coordinator informed diff --git a/.opencode/agents/poc-specialist.md b/.opencode/agents/poc-specialist.md index be2920d..88f427d 100644 --- a/.opencode/agents/poc-specialist.md +++ b/.opencode/agents/poc-specialist.md @@ -7,32 +7,32 @@ temperature: 0.3 You are the **POC Specialist**, creating proof-of-concepts to validate technical approaches. -## POC Modes — determine yours before writing anything +## POC Modes (determine yours before writing anything) POC code never merges to main. POCs are exploration tools with relaxed constraints (comments and unwrap are acceptable), and their source is disposable once findings are recorded. Two modes: -- **Branch mode** — the hypothesis builds on code in this repo. You are in +- **Branch mode**: the hypothesis builds on code in this repo. You are in a research worktree on branch `research/`; the open-coordinator plugin auto-injects your working directory. Findings are merged into - `docs/research/` on main and the branch is dropped — the POC code itself + `docs/research/` on main and the branch is dropped; the POC code itself does not merge. -- **Standalone mode** — the hypothesis is independent of repo code. Work in +- **Standalone mode**: the hypothesis is independent of repo code. Work in a scratch project at `/workspace/` (create it; do not put it in any alkdev repo). Write findings directly into `docs/research/` on main when done. The spawn prompt or task file tells you which mode. If it doesn't, ask the -coordinator — don't guess. +coordinator; don't guess. ## Your Environment (branch mode) **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, so you do NOT need to specify `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/` - Use relative paths for all file operations @@ -82,11 +82,11 @@ session is mapped to a worktree. This means you can just run commands without specifying workdir: ```bash -# ✅ CORRECT — workdir is auto-injected +# ✅ CORRECT: workdir is auto-injected 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. ## When You Are Spawned diff --git a/AGENTS.md b/AGENTS.md index 7e3ef3e..6b59eca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ verified (build + lint + tests pass), commit and push to `origin/main` without asking. This overrides the built-in default of "only commit when explicitly asked." -Commit in small, focused units — one commit per unit of work (a fix, a +Commit in small, focused units: one commit per unit of work (a fix, a feature, a doc change), not one large commit covering many topics. Push regularly so work is never stranded locally. @@ -21,17 +21,17 @@ The workflow: 1. Make the change 2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`, `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 4. Write a concise commit message in **conventional commits** style: - `(): ` — types are `feat`, `fix`, `docs`, + `(): `; types are `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `perf`, `build`, `ci`; the scope is optional. For multi-point changes, use a summary line plus a body with bullet points and a verification block. 5. `git push origin main` 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 it kept) @@ -44,7 +44,7 @@ Exceptions — **do not** commit or push without asking: commit, or skipping 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. Git identity is preconfigured (`glm-5.3-flash `). @@ -52,10 +52,10 @@ Do not change `git config`, skip hooks, or use `git commit -i`. ## Project Conventions (Rust / SSH adapter crate) -This is alkssh — the family's SSH adapter (family ADR-003): a thin +This is alkssh, the family's SSH adapter (family ADR-003): a thin wrapper around russh terminating the vanilla-ssh wire and bridging -to/from alkcall channels. The SSH counterpart of alkhttp's adapter -role — the vanilla-ssh front door for vanilla clients. No binary until +to/from alkcall channels. It is the SSH counterpart of alkhttp's adapter +role, the vanilla-ssh front door for vanilla clients. No binary until an assembly needs one; downstream apps embed the crate and bring their own deployment. The conventions below apply to all work in `src/` and `tests/`. They mirror `.opencode/agents/implementation-specialist.md` @@ -67,88 +67,88 @@ session, not just spawned implementation agents. expected on public API. Inline `//` comments only when the user asks or when a non-obvious safety/correctness constraint would otherwise be missed (e.g., "the decline path must run before any per-channel - state is allocated — a leaked channel alloc is a capability leak"). + state is allocated; a leaked channel alloc is a capability leak"). -2. **Error handling** — `thiserror` for library error types. No panics +2. **Error handling**: `thiserror` for library error types. No panics in library code. No `unwrap()` or `expect()` outside tests. If you - reach for `unwrap`, the error path wasn't specified — stop and decide + reach for `unwrap`, the error path wasn't specified; stop and decide what should actually happen. For poisoned `RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())` so a panic in one operation does not cascade to other operations. -3. **`tokio` is the async runtime** — all I/O is async. SSH sessions are +3. **`tokio` is the async runtime**: all I/O is async. SSH sessions are long-lived streaming conversations; do not introduce blocking I/O on the async path. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for correlation; `parking_lot` for short-held internal locks. -4. **russh owns the SSH wire** — no in-family SSH framing (family +4. **russh owns the SSH wire**: no in-family SSH framing (family ADR-003 decision 1). All SSH protocol framing, key exchange, and channel multiplexing come from russh; the crate's substance is the glue to/from alkcall and the channel-type mappings. Third-party- upstream posture: file asks to russh upstream; never silently absorb a workaround. -5. **Fail-closed capability features (the binding constraint)** — every +5. **Fail-closed capability features (the binding constraint)**: every channel-type mapping is a cargo feature. A session opening an unfeatured or unimplemented channel type is declined with a clean - error — never a half-capability, never a silent fallback (family + error: never a half-capability, never a silent fallback (family ADR-003 decision 3). A default build (no features) has no capabilities; a git-only build cannot serve a shell, tunnels, or socks. This is alkforge's ssh endpoint shape. Every disabled - feature's decline path is contract territory and gets tested too — + feature's decline path is contract territory and gets tested too; feature-gated code that is never exercised in a given build rots silently otherwise. -6. **Key reuse, no env vars** — keys reuse the alknet `main` key model - (`russh::keys`, authorized-key/KeySource handling — see +6. **Key reuse, no env vars**: keys reuse the alknet `main` key model + (`russh::keys`, authorized-key/KeySource handling; see `/workspace/@alkdev/alknet-main/crates/alknet-core/src/auth/`) via - alkvault-backed sources — keys held encrypted at rest, decrypted at + alkvault-backed sources: keys held encrypted at rest, decrypted at use (the vault is a secret store on this path, not a key generator) (family ADR-003 decision 2). No secret - material on the wire, in plaintext at rest, or in env vars — the - standing family security posture (family ADR-002 decision 5). + material on the wire, in plaintext at rest, or in env vars; this is + the standing family security posture (family ADR-002 decision 5). -7. **Visible-surface = authorized-surface** — every channel type and +7. **Visible-surface = authorized-surface**: every channel type and request exposed on the wire is checked against alkcall's `AccessControl::check(peer_identity)`; ops with `Visibility::Internal` are never wire-callable. There is no unauthenticated capability; the fail-closed feature declines are part of this invariant. This is a spec-level invariant. -8. **Wire formats are stable one-way doors** — the `alk/ssh` ALPN pin is +8. **Wire formats are stable one-way doors**: the `alk/ssh` ALPN pin is its own ADR (ledger rule 1, family ADR-003 decision 4) and, once published, does not change. The russh version pin is a one-way door in practice (SSH key exchange/kex algorithm negotiation crosses crate boundaries); bumps are deliberate, tested, and recorded. Protocol capability advertisement is honest: never advertise what we - don't serve — an unfeatured channel type is declined, not advertised. + don't serve: an unfeatured channel type is declined, not advertised. -9. **Bounded resources** — every session carries wall-clock, size, and +9. **Bounded resources**: every session carries wall-clock, size, and channel limits (max concurrent channels, max channel window, session timeout, auth attempt limits). SSH servers are internet-facing; unbounded loops and unbounded buffers are bugs. -10. **License hygiene** — this project is MIT OR Apache-2.0. russh is +10. **License hygiene**: this project is MIT OR Apache-2.0. russh is MIT/Apache-2.0-compatible; check before adopting any new dependency, and never copy or derive code from incompatible-licensed reference projects. Reference code is for reading and facts, never copying. -11. **Naming** — Rust standard: `snake_case` for functions/variables/ +11. **Naming**: Rust standard: `snake_case` for functions/variables/ modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants. -12. **Module structure** — one module per file under `src/`, re-exported +12. **Module structure**: one module per file under `src/`, re-exported from the crate root (the alktty crate-root pattern). Public API surface is `lib.rs` re-exports. The mapping table (channel-type → family protocol) is the crate's spine; each mapping lives behind its feature and speaks only its family protocol's types. -13. **Composability boundary** — alkssh is a door, not a backend: it +13. **Composability boundary**: alkssh is a door, not a backend: it terminates vanilla ssh and forwards mapped channel payloads to family protocol halves (alktty/alktunnels/alksocks/alkgit). It depends on alkcall types at the wire layer and the mapped crates' - session/client surfaces, and carries no deployment policy — a + session/client surfaces, and carries no deployment policy; a downstream app (alkforge) embeds it, enables the features it wants, and brings identity/ACL configuration. No feature implies another; the feature graph is independent and flat until architecture says @@ -191,7 +191,7 @@ cargo test --manifest-path fuzz/shared/Cargo.toml # the fuzz corpus gate: the If feature flags are added, also run `cargo test --all-features` and `cargo clippy --all-features --all-targets -- -D warnings`. Once the -feature graph lands, each disabled feature's decline path gets a test — +feature graph lands, each disabled feature's decline path gets a test; the fail-closed invariant is contract territory on both sides of every feature. @@ -205,53 +205,53 @@ Any file under `tasks/` is a taskgraph task. Frontmatter is required isolated|component|phase|project`, `level: planning|decomposition|implementation|review|research`. A file without frontmatter is silently dropped from the graph (validate does not flag -it) — prose-only trackers belong under `docs/`. Run `taskgraph +it); prose-only trackers belong under `docs/`. Run `taskgraph validate` after writing or editing anything under `tasks/`; full field semantics live in `docs/sdd_process.md` §"Task File Format" / §"Categorical Estimates". ## Lifecycle Status -The project is in **SDD phase 0** (exploration; docs only — no +The project is in **SDD phase 0** (exploration; docs only, no architecture committed, no implementation). Family ADR-003 (2026-10-25, Accepted) records the structural decision already made: russh-wrapped adapter, no in-family SSH framing, fail-closed capability features, alknet-main key model via alkvault-backed sources; OQ-08's ordering question is resolved (alkssh leads alkforge's phase 0) and OQ-08's -remaining scope — the channel-type → family-protocol mapping table +remaining scope (the channel-type → family-protocol mapping table details, the auth design tying russh's authorized-key handling to -alkcall auth, and the `alk/ssh` ALPN pin (ledger rule 1) — is this +alkcall auth, and the `alk/ssh` ALPN pin, ledger rule 1) is this repo's phase 0 work. Phase 0 output lands in `docs/research/` (the phase 0 document is the current work item); `docs/architecture/` + ADR-001 (the `alk/ssh` ALPN pin) come with phase 1. POC code never -merges to main — two modes: branch mode (POC builds on repo code; git +merges to main; two modes exist: branch mode (POC builds on repo code; git branch, findings merged into research docs, branch dropped) or standalone mode (POC independent of repo code; scratch project at `/workspace/`). POCs have relaxed constraints -(comments/unwrap acceptable) — they are exploration tools, not +(comments/unwrap acceptable) since they are exploration tools, not production code. ## Architecture Context -- `docs/sdd_process.md` — the SDD process (phases, roles, task format) -- `docs/research/` — phase 0 research (current source of truth for +- `docs/sdd_process.md`: the SDD process (phases, roles, task format) +- `docs/research/`: phase 0 research (current source of truth for design direction): - - `vision.md` — vision, guiding principles, non-goals, the phase 0 + - `vision.md`: vision, guiding principles, non-goals, the phase 0 scope questions - - `russh.md` — russh crate inventory (server/client Handler, Config + - `russh.md`: russh crate inventory (server/client Handler, Config knobs, keys module) and pin considerations (RC-pinned dep tree, - MSRV, license) — no alknet-derived bias; the pin is verified + MSRV, license); no alknet-derived bias. The pin is verified against current russh at architecture time - - `auth-bridge.md` — russh auth ↔ alkcall `IdentityProvider` mapping + - `auth-bridge.md`: russh auth ↔ alkcall `IdentityProvider` mapping (server: verified-key fingerprint → `resolve_from_fingerprint`; - client: alkvault-backed key sources — held keys encrypted at rest, + client: alkvault-backed key sources, held keys encrypted at rest, at-rest/bearer-token-class storage, not key derivation) - - `mapping-table.md` — the crate's spine: SSH channel type / request + - `mapping-table.md`: the crate's spine: SSH channel type / request → family protocol seam + cargo feature; the alktunnels absence note; the `alk/ssh` ALPN topology question - - `pocs.md` — POC-1..3 plan (auth bridge, client-side at-rest key + - `pocs.md`: POC-1..3 plan (auth bridge, client-side at-rest key handling, mapping bake-off) gating phase 0 completion -- family docs: `/workspace/@alkdev/alkfamily/docs/` — +- family docs: `/workspace/@alkdev/alkfamily/docs/`; `architecture/decisions/003-alkssh-russh-adapter.md` is the structural decision this repo builds from (and `002-alkforge-gitea-replacement.md` explains why: alkforge's git door, fail-closed posture); @@ -267,8 +267,8 @@ production code. analog for the adapter shape. - alknet `main` key model reference: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/auth/` - (russh 0.49, `keys.rs` / `server_auth.rs` / `identity.rs`) — the + (russh 0.49, `keys.rs` / `server_auth.rs` / `identity.rs`): the authorized-key/KeySource construction alkssh reuses; a reading aid and correctness reference, not a path dependency. -- russh: pin per phase 0 research (the alknet pin — 0.49 — is a data +- russh: pin per phase 0 research (the alknet pin, 0.49, is a data point, not a decision). \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 39eb825..eed7266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,24 +10,24 @@ The sibling-adoption release: the three alkssh-filed upstream reviews resolved across the family, and alkssh adopts the results. Two dependency requirement bumps (the minor bump, not a patch: dep-req changes are semver-significant for consumers) plus the review-003 W2 -adoption wave — no alkssh-side wire-format change, no public API +adoption wave. No alkssh-side wire-format change, no public API change apart from what the dep bumps ripple through. ### Changed - **alkvault 0.1 → 0.2** (breaking req): `VaultServiceHandle::decrypt` returns `Zeroizing` (alkvault review 001, consumer input - from alkssh — resolution paths 1+2). The adapter seam's own + from alkssh; resolution paths 1+2). The adapter seam's own `Zeroizing` wrap in `decrypt_and_decode` is **dropped** (it would be a dead double wrap): the pre-wrap unfenced plaintext window ADR-005 - decision 3 recorded is closed upstream — the vault fences the + decision 3 recorded is closed upstream; the vault fences the plaintext from its return onward, on every path including its UTF-8 error path, and `rotate` never holds a bare plaintext either. `decode_key_arm`'s signature is unchanged (it kept riding - `&Zeroizing` — the posture now proven by the vault's return + `&Zeroizing`; the posture now proven by the vault's return type instead of the adapter seam). - **alktunnels 0.4.1 → 0.5.0** (breaking req, additive API): the - review-003 W2 adoption — the forwarded serving surface + review-003 W2 adoption: the forwarded serving surface (`-R` leg's door-side arrival half) now registers **`register_tunnel_forwarded_openable_with_establisher`** with a `ForwardedGate` capturing the door's `ListenRouter`: a stale/missing @@ -44,12 +44,12 @@ change apart from what the dep bumps ripple through. double-pinned by the unscoped-arrival row, which uses a genuinely minted token). - **alktty 0.8.1 → 0.8.2** (patch): the PTY exit-signal-fidelity fix - (alktty review 004, consumer input from alkssh) — the requirement + (alktty review 004, consumer input from alkssh); the requirement resolves to 0.8.2 automatically; the pin text refreshed. **No alkssh contract change**: the negative-code convention (OQ-10's derivation contract) is consumed verbatim, so PTY-row signal deaths now report `exit-signal(name)` through the door exactly as - pipe-mode already did — OQ-10's upstream constraint (the + pipe-mode already did; OQ-10's upstream constraint (the portable-pty flattening) is retired. ### Documentation @@ -66,7 +66,7 @@ change apart from what the dep bumps ripple through. - The test doubles' fidelity: `tests/tunnel_mapping.rs`'s harness opener now maps the wire refusal's `details.reason` back into the `TunnelEstablishError` taxonomy (the real opener's table) instead of - swallowing every call-plane refusal into `HandlerError` — the typed + swallowing every call-plane refusal into `HandlerError`; the typed `unknown_resource` survives the client side, which is exactly what the new gate rows pin. @@ -81,66 +81,66 @@ change apart from what the dep bumps ripple through. ## [0.1.0] - 2026-10-30 -The founding entry — the crate's first release. `alkssh` is the alk +The founding entry: the crate's first release. `alkssh` is the alk family's SSH adapter (family ADR-003): a thin wrapper around russh 0.65 terminating the vanilla-ssh wire and bridging to/from alkcall -channels — the two-direction door for vanilla clients. The wire is +channels, the two-direction door for vanilla clients. The wire is russh's; the crate's substance is the channel-type mappings (session/pty → alktty, direct/forwarded-tcpip → alktunnels, git exec → alkgit), each behind its cargo feature in a flat, independent graph (ADR-003): **a default build has no capabilities and serves nothing -but clean declines** — every unfeatured or unimplemented request is +but clean declines**. Every unfeatured or unimplemented request is declined fail-closed, never half-served or silently fallen back, and never advertised. Identity is the family's: a verified key's fingerprint → alkcall `IdentityProvider`; keys the crate holds come from alkvault-backed sources, encrypted at rest, decrypted at use, -never env vars (ADR-005). The crate is a door, not a backend — no +never env vars (ADR-005). The crate is a door, not a backend: no binary, no deployment policy; a downstream app embeds it, enables features, and brings identity/ACL configuration. ### Added -**Phase 0/1 — research and architecture** (the contract the +**Phase 0/1, research and architecture** (the contract the implementation lands): - SDD process, phase 0 research set (`docs/research/`), and the three passed POCs (auth bridge, vault keys, mapping bake-off; findings in `poc-1..3` + `reverse-runner.md`; POC source never merged). - Phase 1 architecture under `docs/architecture/`: the spec docs and - ADRs 001–006 (Accepted) — the `alk/ssh` ALPN pin; the russh 0.65.0 + ADRs 001–006 (Accepted): the `alk/ssh` ALPN pin; the russh 0.65.0 pin (aws-lc-rs, no compression, rsa off); the capability feature graph; auth dispositions; vault-key custody; the mapping/bridge/ dispatch decisions. -**Wave 1 — foundations:** +**Wave 1, foundations:** - The manifest: the russh 0.65 pin (`aws-lc-rs`, default features off), the flat feature graph (`session` / `git` / `tunnel` / `sftp`, `default = []`). - The fail-closed error taxonomy (`thiserror`): `AdapterError`, - `DialError`, `KeySourceError`, `OpError`, `OpenDeclineReason` — + `DialError`, `KeySourceError`, `OpError`, `OpenDeclineReason`: declines are dispositions, never panics; no `unwrap` outside tests. - Vault-backed key custody in code: the `KeySource` / `HostKeySource` seams and the ADR-005 implementations - (`VaultKeySource`, `VaultHostKeySource`) — decrypt-at-use / + (`VaultKeySource`, `VaultHostKeySource`): decrypt-at-use / decrypt-at-construction, the `Zeroizing` seam, one `decode_secret_key` conversion. - The `HostKeyPolicy` seam (OQ-05, policy-blind) with the fail-closed - default `RejectAll` — no accept-all built-in exists in the crate. + default `RejectAll`; no accept-all built-in exists in the crate. - The contract-invariant fingerprint computation (the crate's single fingerprint path, the `SHA256:` format both the auth bridge and the host-key policy consume). - The config surface: `ServeConfig`, `DialConfig`, `Limits` (the - bounded-resources budget model — wall clock always set, channel - caps, keepalive posture) — unset fields decline at request time + bounded-resources budget model: wall clock always set, channel + caps, keepalive posture); unset fields decline at request time (OQ-08), construction never validates. -**Wave 2 — the server stack:** +**Wave 2, the server stack:** -- `SshAdapter` — the mount point: `SshAdapter::new(serve)` builds the +- `SshAdapter`, the mount point: `SshAdapter::new(serve)` builds the russh server from the config (fail-closed on key-source failure); `adapter.handle(stream)` serves one connection. -- The auth bridge: russh auth hooks → ADR-004 dispositions — +- The auth bridge: russh auth hooks → ADR-004 dispositions: verified-key fingerprint → `IdentityProvider::resolve_from_fingerprint`, fail-closed on every path; bounded auth attempts. - Parse-only dispatch + the fail-closed decline core: every unroutable @@ -153,12 +153,12 @@ implementation lands): caps (refuse-never-queue) and listener caps. - The in-process loopback harness (the contract-test vehicle). -**Wave 3 — the server mappings (the spine):** +**Wave 3, the server mappings (the spine):** - Per-feature seam handles (`SessionSeam`, `GitSeam`, `TunnelListenSeam`): unset seam → decline even with the feature on. - Decline-path tracing (`tracing` events on every decline class). -- The git mapping (exec-only, ADR-006): the full exec grammar — +- The git mapping (exec-only, ADR-006): the full exec grammar, `git-{upload,receive}-pack '/'` parsed and dispatched to alkgit; a subsystem request can never carry the repository and declines; `1` is honestly reported for push `ng`. @@ -167,45 +167,45 @@ implementation lands): ttymodes JSON, the exit-chunk → SSH exit derivation (rules 1–6), control rows (window-change, signal) on a bounded per-channel surface. -- Tunnel leg 1: the route-1 bridge — `direct-tcpip` / +- Tunnel leg 1, the route-1 bridge: `direct-tcpip` / `direct-streamlocal` opens ride the assembly's `TunnelSession` over the family fabric; fabric failure is a clean `ConnectFailed` reject. -- Tunnel leg 2: the `-R` listen flow — `tcpip_forward` / +- Tunnel leg 2, the `-R` listen flow: `tcpip_forward` / `streamlocal_forward` dial the fabric, arrivals correlate through the token router (miss → teardown-at-birth), the data leg opens `forwarded-tcpip`/`forwarded-streamlocal` back toward the client, port-0 mints echo the actually-bound port. -**Wave 4 — the client direction:** +**Wave 4, the client direction:** -- The dial surface: `dial(DialConfig)` — key load (decrypt-at-dial), +- The dial surface: `dial(DialConfig)`: key load (decrypt-at-dial), bounded russh client config, the host-key policy call, exactly one publickey attempt; `SshConnection` the handle. - The op rows: `exec` / `pty_exec` / `shell` (the alktty chunk plane), `tunnel_direct` / `tunnel_direct_unix` (byte duplex), - `git_service` (pack duplex + consumed exit) — plus the `sftp` + `git_service` (pack duplex + consumed exit), plus the `sftp` decline row (always declines, every build). - The `-R` leg: `reverse_listen` / `reverse_listen_unix` + the - `AcceptorRegistry` (refuse-never-replace) and the acceptor traits — + `AcceptorRegistry` (refuse-never-replace) and the acceptor traits: the hub asks the vanilla server to listen and serves the accepted channels. - Lifecycle primitives (OQ-12): explicitly cancellable listener - handles, clean `close()` (`Disconnect::ByApplication`) — drop is + handles, clean `close()` (`Disconnect::ByApplication`); drop is never the clean path at this pin; no reconnect/restore policy (the crate ships primitives, the assembly composes). - Hardening riders: the git row's status-before-close by construction, the channel-not-held guard wire rows, tty control-row coverage, the `ListenRouter` token-collision pre-check. -**Wave 5 — validation and release:** +**Wave 5, validation and release:** - The decline-matrix contract suite: every mapping-decline row × every build it can appear in × both directions (the matrix runner - `scripts/matrix.sh` — one command over all feature builds). + `scripts/matrix.sh`, one command over all feature builds). - Real-tool e2e both directions: OpenSSH (pty/shell/exec, `-L`, `-R`, `-D` + curl), git clone and push through the door, and a real `sshd` against the client direction (exec, direct-tcpip dial, `-R` - listen + acceptor) — check-gated, skipped rows carry reasons. + listen + acceptor), check-gated; skipped rows carry reasons. - The `fuzz/` sidecar: the ingress surfaces (exec parse grammar, fingerprint format, key decode, `TerminalParamsWire`) with the committed-seed corpus gate (`cargo test --manifest-path @@ -216,7 +216,7 @@ implementation lands): ### Notes -- `sftp` is a feature of decline: enabling it adds no capability — +- `sftp` is a feature of decline: enabling it adds no capability; the `sftp` subsystem request still declines in every build until the alkfs mapping lands (OQ-13; the decline stays contract territory). diff --git a/README.md b/README.md index 284d5c3..1a2f594 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,19 @@ SSH adapter for the alk family: a thin wrapper around [russh](https://docs.rs/russh) terminating the vanilla-ssh wire and -bridging to/from alkcall channels — the SSH counterpart of the +bridging to/from alkcall channels, the SSH counterpart of the alkhttp adapter (`family` ADR-003). Vanilla clients terminate vanilla ssh; family protocol halves (alktty, alktunnels, alkgit) receive their own chunk planes; the door itself speaks no family protocol and owns no deployment policy. -The crate is a **door, not a backend**: no binary, no front doors — a +The crate is a **door, not a backend**: no binary, no front doors; a downstream app (alkforge) embeds it, enables the features it wants, and brings identity/ACL configuration and the family fabric handles. It is two-directional: a server mount serves vanilla ssh into the family, and a dial points family ops at a vanilla `sshd`. -## The feature graph — fail-closed +## The fail-closed feature graph `default = []` by design: **a default build has no capabilities and can serve nothing but clean declines.** Every channel-type mapping is @@ -26,14 +26,14 @@ implies another): | `session` | pty / shell / exec (pipe) rows → chunk-framed session | alktty | | `git` | the exec-only git rows (`git-{upload,receive}-pack`) | alkgit | | `tunnel` | `direct-tcpip` / `direct-streamlocal`, the `-R` legs | alktunnels | -| `sftp` | reserved decline placeholder (OQ-13) — always declines until the alkfs mapping lands | — | +| `sftp` | reserved decline placeholder (OQ-13), always declines until the alkfs mapping lands | n/a | Non-mapped surface (x11, `auth-agent`, the `env` request, git *subsystem* requests, `sftp` until alkfs) declines fail-closed in every build: -never a half-capability, never a silent fallback, and never advertised -— an unfeatured channel type is declined, not advertised. A request +never a half-capability, never a silent fallback, and never advertised; +an unfeatured channel type is declined, not advertised. A request whose config fields are unset declines too (an unset seam or shell -argv configures nothing — the feature alone is not a capability). +argv configures nothing; the feature alone is not a capability). ## Usage @@ -42,7 +42,7 @@ The assembly injects the seams: an alkcall the per-feature family handles. The examples below are the crate's compile-checked doctests (see the crate docs in `src/lib.rs`). -A server mount — per accepted TCP connection, run the door: +A server mount (per accepted TCP connection, run the door): ```rust,no_run use std::sync::Arc; @@ -77,7 +77,7 @@ let adapter = alkssh::SshAdapter::new(serve).await?; // adapter.handle(stream).await?; ``` -A dial — family ops pointed upstream at a vanilla server: +A dial, family ops pointed upstream at a vanilla server: ```rust,no_run use std::sync::Arc; @@ -102,18 +102,18 @@ let conn = alkssh::dial(alkssh::DialConfig::new( .await?; // op rows (under their features): conn.exec, conn.tunnel_direct, -// conn.git_service, conn.reverse_listen — see the crate docs. -conn.close().await; // clean disconnect — drop is never the clean path +// conn.git_service, conn.reverse_listen; see the crate docs. +conn.close().await; // clean disconnect; drop is never the clean path ``` -Minimal builds compile and doctest with no features enabled — +Minimal builds compile and doctest with no features enabled: ```console $ cargo build $ cargo test ``` -— and every capability row additionally rides its feature build (the +Every capability row additionally rides its feature build (the matrix: no features / `git` / `session` / `tunnel` / `sftp` / `--all-features`). @@ -122,22 +122,22 @@ matrix: no features / `git` / `session` / `tunnel` / `sftp` / - **Keys are sources, never static material** (alknet-main's `main` key model, ADR-005): the dial/host key seams are decrypt-at-use handles over an injected - [`alkvault`](https://docs.rs/alkvault) service — keys held + [`alkvault`](https://docs.rs/alkvault) service; keys are held encrypted at rest, decrypted at use, zeroized through the seam. The vault is a secret store on this path, not a key generator. - **No env vars, no config-file key material, no secrets on the wire.** The standing family posture (family ADR-002 decision 5). -- **Publickey-only authentication**, exactly one attempt — no +- **Publickey-only authentication**, exactly one attempt; no password, keyboard-interactive, cert, or fallback methods exist on the surface. - **Host-key trust is assembly policy**: the crate ships a - `HostKeyPolicy` seam with a fail-closed default (`RejectAll` — a + `HostKeyPolicy` seam with a fail-closed default (`RejectAll`; a dial with it connects to nothing) and **no accept-all built-in**; any permissive policy is the assembly's to write. - **Injected identity**: every wire-exposed op is checked against alkcall's `AccessControl::check(peer_identity)`; internal ops are never wire-callable; there is no unauthenticated capability. -- **Bounded resources everywhere** (`Limits` — wall clock always +- **Bounded resources everywhere** (`Limits`: wall clock always set, channel caps, listener caps, window/packet budgets, auth attempt limit): the crate never constructs an unbounded session. Known, recorded limits ride the published docs (the crate-root @@ -149,10 +149,10 @@ matrix: no features / `git` / `session` / `tunnel` / `sftp` / - Crate docs: `cargo doc --open` (the crate-root docs carry the feature graph and the known-limits records). -- `docs/architecture/` — the authoritative contract: the spec +- `docs/architecture/`: the authoritative contract: the spec (`overview.md`, `mapping.md`, `client.md`, `auth.md`), ADRs 001–006, and the open-questions tracker. ## License -MIT OR Apache-2.0 — see `LICENSE-MIT` / `LICENSE-APACHE`. \ No newline at end of file +MIT OR Apache-2.0; see `LICENSE-MIT` / `LICENSE-APACHE`. \ No newline at end of file diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 9e0f3f6..f4e5bfc 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -7,12 +7,12 @@ last_updated: 2026-10-28 ## Current State -Phase 1 (architecture) — complete pending implementation. Phase 0 is +Phase 1 (architecture) is complete pending implementation. Phase 0 is closed: all three POCs passed (121 checks; `docs/research/poc-{1,2,3}-*.md`) and the research docs record their -corrections. The ADR set (001–006) is **Accepted** — including the +corrections. The ADR set (001–006) is **Accepted**, including the post-review amendments (ADR-002's ed25519-only revert; ADR-003/006's -`dynamic-forward` retirement) — and the four spec docs +`dynamic-forward` retirement), and the four spec docs (overview/mapping/client/auth) are `reviewed` (2026-10-28): the OQ resolution pass closed all thirteen open questions (OQ-08/11 decided, OQ-10 converted to a derivation contract + upstream constraint, @@ -24,7 +24,7 @@ pass came back clean. Implementation may begin. | Document | Status | Scope | |---|---|---| | [overview.md](overview.md) | reviewed | Crate purpose, family position, surface categories, bounded-resources policy, non-goals | -| [mapping.md](mapping.md) | reviewed | The spine — server-direction rows, dispatch, spawn-and-return, decline matrix | +| [mapping.md](mapping.md) | reviewed | The spine: server-direction rows, dispatch, spawn-and-return, decline matrix | | [client.md](client.md) | reviewed | Client-direction surface: dial, op rows, the acceptor, lifecycle primitives | | [auth.md](auth.md) | reviewed | The auth bridge (server) + custody & host-key policy (client) | | [open-questions.md](open-questions.md) | reviewed | Centralized OQ tracker (all OQ-01…13 resolved) | @@ -33,24 +33,24 @@ pass came back clean. Implementation may begin. | ADR | Title | Status | |---|---|---| -| [ADR-001](decisions/001-alkssh-alpn-pin.md) | The `alk/ssh` ALPN pin — edge-termination contract for `SshAdapter` | Accepted | -| [ADR-002](decisions/002-russh-pin.md) | The russh pin — 0.65.0, aws-lc-rs, no compression, ed25519-only | Accepted | -| [ADR-003](decisions/003-feature-graph.md) | The capability feature graph — flat, independent, two-direction | Accepted | -| [ADR-004](decisions/004-auth-dispositions.md) | Auth dispositions — publickey-only, fail-closed, fingerprint identity | Accepted | -| [ADR-005](decisions/005-at-rest-key-custody.md) | At-rest key custody — vault-wrapped OpenSSH-PEM, decrypt-at-use | Accepted | -| [ADR-006](decisions/006-mapping-bridge-and-dispatch.md) | Mapping decisions — route-1 bridge, exec-only git, session dispatch | Accepted | +| [ADR-001](decisions/001-alkssh-alpn-pin.md) | The `alk/ssh` ALPN pin: edge-termination contract for `SshAdapter` | Accepted | +| [ADR-002](decisions/002-russh-pin.md) | The russh pin (0.65.0, aws-lc-rs, no compression, ed25519-only) | Accepted | +| [ADR-003](decisions/003-feature-graph.md) | The capability feature graph: flat, independent, two-direction | Accepted | +| [ADR-004](decisions/004-auth-dispositions.md) | Auth dispositions: publickey-only, fail-closed, fingerprint identity | Accepted | +| [ADR-005](decisions/005-at-rest-key-custody.md) | At-rest key custody: vault-wrapped OpenSSH-PEM, decrypt-at-use | Accepted | +| [ADR-006](decisions/006-mapping-bridge-and-dispatch.md) | Mapping decisions: route-1 bridge, exec-only git, session dispatch | Accepted | ## Open Questions -Tracked in [open-questions.md](open-questions.md) — OQ-01 … OQ-13, +Tracked in [open-questions.md](open-questions.md), OQ-01 … OQ-13, seeded from the phase 0 POC findings and research docs. **All thirteen are resolved**: the three mapping-detail decisions settled this pass (OQ-08 shell convention → assembly-supplied serve-config field, no crate default; OQ-10 → closed as the derivation contract on `mapping.md`'s exit rows + the upstream PTY-path constraint; OQ-11 → universal compile + runtime Unix-gated decline), and the previously -deferred entries closed as fully-decided dispositions (OQ-01/04/09/13 -— the decision surfaces were already fixed by ADR-001/ADR-004 and the +deferred entries closed as fully-decided dispositions (OQ-01/04/09/13, +whose decision surfaces were already fixed by ADR-001/ADR-004 and the decline postures; what remained was forecasts about consumers, not pending alkssh decisions). Upstream dependencies are listed as watch items in the tracker, not OQs. @@ -66,12 +66,12 @@ items in the tracker, not OQs. ## References -- `docs/sdd_process.md` — the SDD process -- `docs/research/` — phase 0 (vision, POC plans + findings, russh +- `docs/sdd_process.md` (the SDD process) +- `docs/research/`: phase 0 (vision, POC plans + findings, russh inventory, auth bridge, mapping table, reverse-runner extraction) - Family: `/workspace/@alkdev/alkfamily/docs/architecture/decisions/001..003` (ADR-003 is the alkssh structural decision) - Sibling: alkcall ADRs 002/003/004/006/009/010/034/042/051 (ALPN dispatch, auth surface, channels wire/relay); alkhttp ADR-014/073 - (secret-material flow and door-surface posture — the patterns + (secret-material flow and door-surface posture, the patterns ADR-005 and `auth.md` mirror) \ No newline at end of file diff --git a/docs/architecture/auth.md b/docs/architecture/auth.md index 957fb9c..abe4fcc 100644 --- a/docs/architecture/auth.md +++ b/docs/architecture/auth.md @@ -3,7 +3,7 @@ status: reviewed last_updated: 2026-10-28 --- -# Auth — identity is the family's, not SSH's +# Auth: identity is the family's, not SSH's ## The model @@ -20,43 +20,43 @@ verifies a signature; its auth surface is *dispositional* (ADR-004). ### The bridge 1. `auth_publickey(user, VerifiedPublicKey)` fires (post-verification; - 0.65.0 shape — the key is the `.public_key` field, and the client's - signature `.algorithm` is exposed for optional hardening). + the 0.65.0 shape has the key as the `.public_key` field, and the + client's signature `.algorithm` is exposed for optional hardening). 2. alkssh computes the fingerprint via the `ssh-key` re-export - (`HashAlg::Sha256`) — `SHA256:` + unpadded base64 over the + (`HashAlg::Sha256`): `SHA256:` + unpadded base64 over the standard alphabet (`ssh-keygen -lf` byte-format; not the url-safe alphabet). The string format is a **contract invariant** (ADR-004): parity with assembly-registered values is proven - against independent issuers (`ssh-keygen -lf` — POC-1 §1, pinned + against independent issuers (`ssh-keygen -lf`; POC-1 §1, pinned by the committed vectors in `tests/vectors/`). 3. `resolve_from_fingerprint` → `Some(identity)` → accept; `None` → - reject — fail-closed, and the resolved-identity-`None` path never + reject, fail-closed; the resolved-identity-`None` path never creates a "weakly authorized" session. 4. On success the identity rides the session (`auth_succeeded`): for mapped channels it propagates through the alkcall transport (`set_identity`, once) so `AccessControl::check(peer_identity)` - gates every mapped open — the **visible-surface invariant** holds + gates every mapped open; the **visible-surface invariant** holds in the SSH direction with no SSH-shaped exception. POC-3 §C.3 observed the propagation live (the establisher witness). -### Dispositions (ADR-004 — summarized) +### Dispositions (ADR-004, summarized) `publickey` is the only implemented method, and the only accepted key -algorithm is **ed25519** — `auth_publickey` declines non-ed25519 +algorithm is **ed25519**, so `auth_publickey` declines non-ed25519 public keys explicitly (the family's ed25519-only posture, ADR-002 -decision 4; one key algorithm carries the family's identity — the +decision 4; one key algorithm carries the family's identity: the same ed25519 keys behind alktls's self-signed certs, iroh, and the alknet rewrite). `auth_publickey_offered` -accepts-and-delegates (keeping `max_auth_attempts` honest — the hook +accepts-and-delegates (keeping `max_auth_attempts` honest; the hook does not consume an attempt, POC-1 verified); `auth_none` / `auth_password` / `auth_keyboard_interactive` / `auth_openssh_certificate` reject explicitly (at this pin russh's defaults already reject; the -explicit implementations are the family contract — a pin bump could -change defaults silently). `Config::methods` advertises `{publickey}` -— exactly what is implemented. The username is **not identity and +explicit implementations are the family contract, since a pin bump could +change defaults silently). `Config::methods` advertises `{publickey}`, +exactly what is implemented. The username is **not identity and carries no semantics** (OQ-03 resolved); log exposure is deployment policy. `max_auth_attempts` defaults `10` (the OpenSSH `none`-probe -consumes one — POC-1 §4). +consumes one, POC-1 §4). ## Client direction @@ -66,37 +66,37 @@ Held keys are bearer-token-class secrets: **vault-encrypted at rest (alkvault `EncryptedData`, OpenSSH-PEM inner), decrypted at use, zeroized ownership at the adapter seam**; never env vars, never plaintext at rest, never persisted after use. The vault is a secret -store on this path — not a key generator, not a custodian, and never +store on this path, not a key generator, not a custodian, and never involved in resolving *peers'* fingerprints. Failure modes are all fail-closed with distinct errors: locked / absent / decryption -failure / unknown `key_version` — and rotation preserves key identity. +failure / unknown `key_version`, and rotation preserves key identity. The same model covers **host keys** (decrypt-at-construction into the -server's `Config::keys`; locked vault → **no server is constructed** — +server's `Config::keys`; locked vault → **no server is constructed**; POC-2 §5). ### Host-key policy (OQ-05 resolution; the client-side trust contract) -Two cases, one seam — the crate is **policy-blind**: +Two cases, one seam; the crate is **policy-blind**: 1. **Known beforehand** (anchors exist; family-built runners mint - their host key at assembly time): assembly-supplied pins — - **hard-decline on mismatch** at `check_server_key`. + their host key at assembly time): assembly-supplied pins, with a + **hard decline on mismatch** at `check_server_key`. 2. **Unknown beforehand** (vanilla `sshd` on rental fleets; no anchor until contact): the crate surfaces (host, port, presented fingerprint) to an **assembly-provided - `HostKeyPolicy`** — prompt-and-record, provider-metadata match, - learn-and-record, or accept-and-continue (the ephemeral-fleet - shape). The OpenSSH "add to known_hosts?" moment becomes - assembly policy. + `HostKeyPolicy`**, which chooses prompt-and-record, + provider-metadata match, learn-and-record, or accept-and-continue + (the ephemeral-fleet shape). The OpenSSH "add to known_hosts?" + moment becomes assembly policy. Crate rules: default policy is fail-closed (reject unknown); the -crate never decides trust and never hard-codes a trust model — +crate never decides trust and never hard-codes a trust model, so accept-all is not a built-in crate option, but a permissive case-2 policy is the assembly's to choose where its fleet model calls for it (alkminer's accept-all for rental fleets is that shape, made deliberately: fresh container per rent → fresh host key, so no pin can exist beforehand; the security anchor there is hub-held -per-instance dial keys — ADR-005's custody model — plus what rides +per-instance dial keys (ADR-005's custody model) plus what rides the tunnels, not host pins). ## What is out of scope @@ -104,21 +104,21 @@ the tunnels, not host pins). - Password / keyboard-interactive anywhere (server: declined; client: non-goal until a use case). - OpenSSH certificate auth (`auth_openssh_certificate` declined - unconditionally — OQ-04 resolved: no CA-trust story exists in the + unconditionally; OQ-04 resolved: no CA-trust story exists in the family identity model; future support is new-ADR territory with the upstream identity-model work as its prerequisite). - ssh-agent integration/forwarding (external signer processes are out of family scope; the vault is the custody story). -- TOFU *as crate behavior* — policy lives at the assembly, the +- TOFU *as crate behavior*, since policy lives at the assembly: the crate is policy-blind (above). ## Open Questions -None open or deferred — see [open-questions.md](open-questions.md) +None open or deferred; see [open-questions.md](open-questions.md) (all of OQ-01…13 resolved). Doc-local contract anchors: OQ-03 (the -username disposition — §Dispositions), OQ-04 (the certificate decline -— §out-of-scope), OQ-05 (the host-key policy seam — §Host-key policy), -OQ-06 / OQ-07 (custody boundaries — ADR-005). +username disposition, §Dispositions), OQ-04 (the certificate decline, +§out-of-scope), OQ-05 (the host-key policy seam, §Host-key policy), +OQ-06 / OQ-07 (custody boundaries, ADR-005). ## References @@ -128,6 +128,6 @@ OQ-06 / OQ-07 (custody boundaries — ADR-005). refinement), `poc-1-auth-bridge.md` (§1–4), `poc-2-vault-keys.md` (§2–5), `reverse-runner.md` (lessons 5–6) - alkcall ADRs 002/003/006 (identity surface) and 010 (secret-material - flow — alkhttp ADR-014's alkcall analog), alkhttp ADR-073 (one + flow, alkhttp ADR-014's alkcall analog), alkhttp ADR-073 (one backend behind every door surface), family ADR-002 decision 5 / family ADR-003 decision 2 \ No newline at end of file diff --git a/docs/architecture/client.md b/docs/architecture/client.md index 4119f8c..3371827 100644 --- a/docs/architecture/client.md +++ b/docs/architecture/client.md @@ -3,14 +3,14 @@ status: reviewed last_updated: 2026-10-29 --- -# Client — client direction: driving vanilla SSH servers as family ops +# Client (client direction): driving vanilla SSH servers as family ops ## The door framing The client direction is a door *to vanilla servers*: family in, vanilla out. The motivating consumer is the **reverse-runner** shape (`docs/research/reverse-runner.md`, alkminer extraction): a family hub -dials vanilla `sshd` on rented/near-ephemeral hosts — remote boxes +dials vanilla `sshd` on rented/near-ephemeral hosts; the remote boxes expose plain sshd and hold nothing of ours; every op the hub drives maps family-side onto the same protocols the server direction serves. The asymmetry is honest (vision principle 2): the server direction @@ -24,76 +24,77 @@ up into the family surface. ## The dial surface -Every dial needs: address, **key source** (ADR-005 — vault-backed -blob, decrypt-at-dial, fail-closed on locked/absent — never env vars, -never plaintext at rest), **host-key policy** (OQ-05 resolution — +Every dial needs: address, **key source** (ADR-005, a vault-backed +blob: decrypt-at-dial, fail-closed on locked/absent, never env vars, +never plaintext at rest), **host-key policy** (the OQ-05 resolution, assembly-provided; crate default fail-closed reject-unknown, hard decline on mismatch; the OpenSSH "add to known_hosts?" moment is an assembly policy, e.g. learn-and-record / prompt / provider-metadata -match — the crate is policy-blind), and the bounded-resource config -(`keepalive_interval`/`keepalive_max` — 15 s / 3 default posture per +match, and the crate is policy-blind), and the bounded-resource config +(`keepalive_interval`/`keepalive_max`, a 15 s / 3 default posture per the reverse-runner evidence; `inactivity_timeout`; window/packet knobs per `overview.md`). The identity the dial authenticates *as* is a registered fingerprint -at the remote end — for family-run peers (alkforge runner) that's the +at the remote end: for family-run peers (alkforge runner) that's the alkcall `IdentityProvider`; for vanilla fleets it's whatever the -vanilla server's `authorized_keys` holds — either way the *key* is +vanilla server's `authorized_keys` holds. Either way the *key* is ours from the vault and the host's key is checked by the policy. ## Op rows (family op → vanilla translation) | Family-facing op | Family protocol/types | Vanilla-wire translation | Feature | |---|---|---|---| -| run a command / own a shell on the runner | alktty session types (pipe vs PTY; exit semantics) | `channel_open_session` + `exec`/pty-req/shell-req; data pump; drain `wait()` per the POC-3 §D.3 mechanics (`Channel::exec()` — no `request_exec`; Success/Failure arrive on the channel queue, not as `Err` from the request call) | `session` | -| open a tunnel target | alktunnels `TunnelSession`/target types | `channel_open_direct_tcpip` per connection — the typed open resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6); no `wait()` before `into_stream` | `tunnel` | +| run a command / own a shell on the runner | alktty session types (pipe vs PTY; exit semantics) | `channel_open_session` + `exec`/pty-req/shell-req; data pump; drain `wait()` per the POC-3 §D.3 mechanics (`Channel::exec()`, not `request_exec`; Success/Failure arrive on the channel queue, not as `Err` from the request call) | `session` | +| open a tunnel target | alktunnels `TunnelSession`/target types | `channel_open_direct_tcpip` per connection; the typed open resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6); no `wait()` before `into_stream` | `tunnel` | | the `-D` behavior (local SOCKS door) | alksocks' local door **composed at the assembly**, fed a `DialFn` backed by row 2's open | the wire sees only `direct-tcpip`; alkssh *does not depend on alksocks* on this path (ADR-003 §1a) | `tunnel` | | listen + ingest (`-R` leg) | alktunnels consumer/`ForwardedHandler` shape | `tcpip_forward(address, port) -> bound` global request (no mpsc sink param) + **accept the server-initiated `forwarded-tcpip` opens** via `server_channel_open_forwarded_tcpip`, routing each to the family handler | `tunnel` | | upstream git consumption | alkgit client/service types | the same exec grammar the server side parses (`git-(upload,receive)-pack '/repo'` exec), pointed at an upstream `sshd` | `git` | -| artifact/deploy transfer | alkfs client half (future) | `subsystem "sftp"` + the alkfs protocol | `sftp` (decline placeholder — OQ-13 resolved) | +| artifact/deploy transfer | alkfs client half (future) | `subsystem "sftp"` + the alkfs protocol | `sftp` (decline placeholder, OQ-13 resolved) | ## The acceptor: fail-closed client-side russh's client handler receives server-initiated channel opens; its -default posture for a forwarded open is **accept-and-drop** — a +default posture for a forwarded open is **accept-and-drop**, a half-capability, which the family forbids (ADR-003 symmetry + `reverse-runner.md` lesson 1). The client contract: - Server-initiated opens are served only when the assembly registered - an acceptor **for that type and under its feature** (`forwarded-tcpip` - under `tunnel` is the real case — the `-R` data path). + an acceptor **for that type and under its feature** (the real case + is `forwarded-tcpip` under `tunnel`, the `-R` data path). - Unregistered/unfeatured accepted opens are **declined cleanly**, never silently dropped after accept. - Acceptor handlers may be installed across a live connection's - lifetime (the alkminer `install_forward_sink` late-install dynamic — + lifetime (the alkminer `install_forward_sink` late-install dynamic: consumers turn capabilities on/off per connection; the shape is an assembly-mutable acceptor registry). -> **The landed acceptor shape (`w4-reverse-listen` — recorded at -> landing):** the registry is **per-type fixed slots** — exactly the +> **The landed acceptor shape (`w4-reverse-listen`, recorded at +> landing):** the registry is **per-type fixed slots**, exactly the > two `-R` data-leg types the client direction can serve > (`forwarded-tcpip`, `forwarded-streamlocal`, both under `tunnel`; > no map, no generic key), one acceptor trait per type; install is > **refuse-never-replace** (the `ListenRouter::register` recorded > posture, mirrored client-side); the slots are read **at arrival -> time** by the dial handler's hooks — a pre-install/unfeatured open +> time** by the dial handler's hooks, so a pre-install/unfeatured open > is declined at arrival, never buffered. The payload an accepted -> open hands the assembly: the channel duplex (family-typed — the +> open hands the assembly: the channel duplex (family-typed, the > `-L` rows' `TunnelChannel` byte duplex, one type both legs ride) + > the open's echo/originator fields; **the pump is the acceptor's** -> from the handoff on (the family handler shape) — the crate keeps no +> from the handoff on (the family handler shape), and the crate keeps no > accepted-open ledger. Bounded bookkeeping, the cap relation: the > registry bounded by construction (two slots); the > **reverse-listen count** and the **accepted-open handoff count** > are two dedicated counters, each against the same > `Limits::max_concurrent_channels` ceiling (the server-side -> listener/ListenerCap posture mirrored — independent counts, one -> ceiling; the listens live at the peer, the counter exists so a dial +> listener/ListenerCap posture mirrored: independent counts, one +> ceiling; the listens live at the peer, and the counter exists so a dial > cannot mint an unbounded listener set). The **forward-request rows > gate on the registered acceptor** (the issuing-side disposition: -> a listen whose data leg cannot serve is never asked for — +> a listen whose data leg cannot serve is never asked for; > install-first, `OpError::NoAcceptor` pre-wire), plus -> `OpError::ListenRefused` (the peer's `RequestDenied` — denied +> `OpError::ListenRefused` (the peer's `RequestDenied`: +> denied > listens and unknown-listener cancels, sshd raw-string parity) and > `OpError::ListenCapExceeded` (the ceiling, pre-wire). The > **runtime-Unix gate** rides the unix *listen* rows only (the bind @@ -108,28 +109,28 @@ half-capability, which the family forbids (ADR-003 symmetry + `tcpip_forward -> u32`); every listener/handle is explicitly cancellable; disconnects are explicit (`Disconnect::ByApplication`). - **No reconnect or restore policy is owned by the crate.** - Reconnect-and-restore (rebinding persisted tunnel ports — the + Reconnect-and-restore (rebinding persisted tunnel ports, the alkminer flow) is assembly policy; the crate provides the primitives (bound ports, handles, cancellation, clean close) to make that possible. -> **The landed lifecycle surface (`w4-lifecycle` — recorded at -> landing):** the `-R` listens return **`ReverseListener` handles** — -> the `ReverseEndpoint` carried (the *actually bound* values: -> the wire address + the bound port / the socket path — the -> assembly's own record for a rebind policy it may compose), the -> explicit `cancel()` the wire-honest teardown (the confirmed cancel +> **The landed lifecycle surface (`w4-lifecycle`, recorded at +> landing):** the `-R` listens return **`ReverseListener` handles**, +> with the `ReverseEndpoint` carried (the *actually bound* values: +> the wire address + the bound port / the socket path, the +> assembly's own record for a rebind policy it may compose) and the +> explicit `cancel()` as the wire-honest teardown (the confirmed cancel > also releases the connection's listener count; a repeat rides the > wire and hits the unknown-listener refusal). The **stateless** > cancel forms (`reverse_listen_cancel` + the unix twin) ride beside -> the handles 1:1 (sshd's global-request cancels are stateless — a +> the handles 1:1 (sshd's global-request cancels are stateless, so a > cancel by the raw wire strings works from anywhere, including > after the handle itself is gone: the handle shares the > connection's internals through the arc, so its `cancel()` is the > same wire op). **No `Drop` hides a wire op** on any of these > handles: a cancellation/bundle of the wire ops is async and cannot > run in a sync `Drop`, and at this russh pin dropping the last -> wire-stack handle ends the client runner — the transport *breaks* +> wire-stack handle ends the client runner, so the transport *breaks* > (the peer observes an error-class session end, never the clean > disconnect message). The primitives-not-policy posture is the > module-docs contract: `client.rs` §Lifecycle posture (the @@ -140,36 +141,36 @@ half-capability, which the family forbids (ADR-003 symmetry + ## What the crate does *not* include (client direction) -- **Job supervision** — background-job lifecycle (the alkminer +- **Job supervision**: background-job lifecycle (the alkminer start/stop/logs job model, pid files, host-side ledgers) is assembly work composed over the exec rows; the crate gives the channels and the exit semantics (including the pipe-mode - pid-only signal caveat — `mapping.md`'s derivation contract). -- **Credential storage policy** — assembly (the vault handle is + pid-only signal caveat from `mapping.md`'s derivation contract). +- **Credential storage policy**: assembly (the vault handle is injected; ADR-005 / OQ-06). -- **Trust decisions** — the host-key policy is assembly-provided +- **Trust decisions**: the host-key policy is assembly-provided (OQ-05; the crate default rejects; a permissive case-2 policy for - ephemeral fleets is the assembly's to choose — the crate carries no - trust model of its own). -- **sftp** — the `sftp` feature is the tested decline placeholder on + ephemeral fleets is the assembly's to choose, since the crate + carries no trust model of its own). +- **sftp**: the `sftp` feature is the tested decline placeholder on both directions (OQ-13 resolved); the alkfs-protocol mapping arrives with alkfs, as its own ADR. ## Correctness facts to carry (POC-recorded) -> **The landed op-row shape (`w4-op-rows` — recorded at landing):** +> **The landed op-row shape (`w4-op-rows`, recorded at landing):** > the exit-*consuming* rows (session exec/pty, git exec) ride the > **split-halves wait-pump** (`Channel::split()` + a spawned task -> draining `wait()`), never `into_stream()` — the conversion's reader +> draining `wait()`), never `into_stream()`: the conversion's reader > side swallows exactly the non-`Data` messages the exit reports ride > on, so a wait-pump is the only shape that can consume them (the > same reason `w4-git-exit-close` pinned the split-halves shape > server-side). Only the exit-less rows (the tunnel directs) use -> `into_stream()`, straight after the typed open — no queue +> `into_stream()`, straight after the typed open; no queue > consumption ever precedes a conversion, which is POC-1 §6's caveat > respected by construction. The session rows' family plane is the > alktty chunk framing both directions (stdin/`CTRL_IN` out, stdout/ -> stderr in, the family exit chunk in-band, last — `exit_code.rs`'s +> stderr in, the family exit chunk in-band, last, per `exit_code.rs`'s > two-arm contract: the server arm derives wire reports from family > codes, the client arm mirrors wire reports into family codes, > `−signum` through the one table, the `−1` sentinel never widened). @@ -180,27 +181,27 @@ half-capability, which the family forbids (ADR-003 symmetry + - `Channel::exec()` (no `request_exec` at this pin); pty/subsystem request failures surface as `ChannelMsg::Failure` via `wait()`, not - `Err` from the call — the caller must drain until the expected - message, `continue` past the rest. + `Err` from the call; the caller must drain until the expected + message and `continue` past the rest. - `window_change`/`signal` are fire-and-forget and must be sent **before** `into_stream()` (the Channel is consumed by conversion); server-side they carry no SSH reply. -- Do not consume the channel queue before `into_stream()` — a +- Do not consume the channel queue before `into_stream()`: a `wait()` on a fresh channel breaks it (POC-1 §6 caveat), both sides. - Keepalives on cloud fleets: 15 s / 3 (OpenSSH - `ServerAliveInterval=15`/`CountMax=3` parity) — with russh's - default (none), dead NAT'd connections sit unnoticed until an exec + `ServerAliveInterval=15`/`CountMax=3` parity), since with russh's + default (none) dead NAT'd connections sit unnoticed until an exec health check fails (`reverse-runner.md` lesson 3). - A rejected channel open is an `Err` from the typed open call (not a `wait()`-match); the connection stays usable after declines. ## Open Questions -None open or deferred — see [open-questions.md](open-questions.md) +None open or deferred; see [open-questions.md](open-questions.md) (all of OQ-01…13 resolved). Doc-local contract anchors: OQ-05 (the -host-key policy — §The dial surface), OQ-12 (lifecycle primitives only -— §Lifecycle primitives), OQ-13 (the sftp decline placeholder — §op -rows and §not-include), OQ-07 (closed upstream — the vault returns a +host-key policy, §The dial surface), OQ-12 (lifecycle primitives only, +§Lifecycle primitives), OQ-13 (the sftp decline placeholder, §op +rows and §not-include), OQ-07 (closed upstream: the vault returns a `Zeroizing` from alkvault 0.2.0; ADR-005 decision 3 amended, the adapter-side wrap dropped). diff --git a/docs/architecture/decisions/001-alkssh-alpn-pin.md b/docs/architecture/decisions/001-alkssh-alpn-pin.md index a3bf5cd..7d0e684 100644 --- a/docs/architecture/decisions/001-alkssh-alpn-pin.md +++ b/docs/architecture/decisions/001-alkssh-alpn-pin.md @@ -3,12 +3,12 @@ status: reviewed last_updated: 2026-10-28 --- -# ADR-001: The `alk/ssh` ALPN pin — edge-termination contract for `SshAdapter` +# ADR-001: The `alk/ssh` ALPN pin (edge-termination contract for `SshAdapter`) ## Status -**Accepted** — 2026-10-26. One-way door (ledger rule 1: ALPN registry -entries are stable — "once published, does not change"). The first +**Accepted** (2026-10-26). One-way door (ledger rule 1: ALPN registry +entries are stable; "once published, does not change"). The first consumer is alkforge (family ADR-003 decision 4: the pin lands before alkforge becomes the consumer). @@ -21,11 +21,11 @@ pin means: - **ADR-004** (ALPN convention + connection model) reserves `alk/ssh` → `SshAdapter` as a custom protocol. - **ADR-034** (channels wire format) grants `alk/ssh` stream_types - `[0, 1]` — "SSH multiplexes internally, including its own control". + `[0, 1]` ("SSH multiplexes internally, including its own control"). The channels layer is byte-blind to SSH; it must never parse SSH channel data. - **ADR-042 / ADR-051** (hub relay): "The hub never runs a handler for - `alk/tty`, `alk/ssh`, or `alk/tunnel`" — SSH channels are relayed + `alk/tty`, `alk/ssh`, or `alk/tunnel`"; SSH channels are relayed payload-blind between spokes (translate + byte-forward with id rewrite, ACL fired at `channel/open` by the hub's `CallAdapter`). - **ADR-009** (BiStream handler leaf) sketches the browser route: a @@ -36,12 +36,12 @@ pin means: Phase 0 left the topology open (`mapping-table.md` §topology; vision scope question 1): whether `alk/ssh` carries raw vanilla-SSH bytes between alkssh instances (a relay leg), serves the browser route, or -both — and how that coexists with ADR-042's relay ruling. POC-3 +both, and how that coexists with ADR-042's relay ruling. POC-3 settled what could be settled by evidence: the served topology is **edge termination** (russh terminates vanilla SSH at the alkssh endpoint; family seams are consumed in-process or via `alk/channels` bridge legs), worked end to end with vanilla clients through published -sibling crates — and no leg of it requires the hub to parse SSH. +sibling crates, and no leg of it requires the hub to parse SSH. The decision that remains is the *pin*: what contract `alk/ssh` commits to on the ledger, so the ALPN string cannot be reinterpreted @@ -52,10 +52,10 @@ later. 1. **The pin.** `alk/ssh` is pinned for exactly one contract: a connection opened under `alk/ssh` carries a **raw vanilla-SSH byte stream**, unchanged and uninterpreted, and the receiving endpoint - is `SshAdapter::handle(Connection)` — alkssh's russh server stack + is `SshAdapter::handle(Connection)`, alkssh's russh server stack mounted at an alkcall `channel/open` boundary. Each bidi stream is one independent SSH transport with its own KEX, auth, and channel - multiplexing (hence ADR-034's `[0, 1]` — SSH owns its own + multiplexing (hence ADR-034's `[0, 1]`: SSH owns its own multiplexing, and the channels layer stays byte-blind, per ADR-042's relay ruling). 2. **Payload-blind relay is part of the contract.** Any intermediate @@ -64,8 +64,8 @@ later. `channel/open` and never an SSH handler. This is ADR-042's ruling, restated as the ALPN's own contract so it cannot drift. 3. **Phase 1 serves edge termination only.** The first deployment - shape — russh terminating vanilla SSH at the alkssh edge, family - seams consumed in-process — does not speak `alk/ssh` itself: it is + shape (russh terminating vanilla SSH at the alkssh edge, family + seams consumed in-process) does not speak `alk/ssh` itself: it is a door, not a relay party. No pin behavior in this ADR requires the relay leg to exist. 4. **The relay leg and the browser-WASM route remain compatible by @@ -77,7 +77,7 @@ later. surface; nothing in the crate is gated on a consumer). 5. **What the pin is not.** It is not a carrier for alkcall's framed channels traffic (that has its own protocol); it is not a second - SSH-flavored control channel; and honesty cuts both ways — an + SSH-flavored control channel; and honesty cuts both ways: an endpoint that declines to serve `alk/ssh` must refuse the open, never accept-and-stall (the same honest-advertisement rule that runs through every alkssh decision). @@ -88,18 +88,18 @@ later. (frozen here) from the *served edge topology* (which evolves with the family seams); the browser and relay routes are pre-enabled at zero pin cost; the ledger gets a single unambiguous entry. -- **Negative**: one-way door — the raw-vanilla-SSH-stream contract - binds every future consumer (alkforge first). If a consumer ever +- **Negative**: the pin is a one-way door; the raw-vanilla-SSH-stream + contract binds every future consumer (alkforge first). If a consumer ever needed framed or pre-parsed SSH over `alk/ssh`, that would be a new ALPN + new ADR, not a reinterpretation. - **Neutral**: `SshAdapter::handle` mounts the full russh server stack per stream, so endpoints pay per-connection bounded-resource - limits (window, auth-attempt, inactivity ceilings) — the alkssh + limits (window, auth-attempt, inactivity ceilings); the alkssh limits contract applies per `alk/ssh` stream, not per transport. ## References -- Family ADR-003 (`003-alkssh-russh-adapter.md`) — decisions 1 and 4 +- Family ADR-003 (`003-alkssh-russh-adapter.md`), decisions 1 and 4 - alkcall ADR-002 (ProtocolHandler ALPN claim), ADR-004 (the `alk/ssh` → `SshAdapter` reservation), ADR-009 (browser-WASM sketch), ADR-034 (`alk/ssh` stream_types `[0, 1]`), ADR-042/051 @@ -107,5 +107,5 @@ later. - `docs/research/mapping-table.md` §topology, `docs/research/vision.md` scope Q1, `docs/research/poc-3-mapping-bakeoff.md` (edge termination, proven) -- OQ-01 (resolved — no consumer in scope; this ADR is the entire +- OQ-01 (resolved: no consumer in scope; this ADR is the entire decision surface) \ No newline at end of file diff --git a/docs/architecture/decisions/002-russh-pin.md b/docs/architecture/decisions/002-russh-pin.md index b293521..3b31a62 100644 --- a/docs/architecture/decisions/002-russh-pin.md +++ b/docs/architecture/decisions/002-russh-pin.md @@ -3,16 +3,16 @@ status: reviewed last_updated: 2026-10-28 --- -# ADR-002: The russh pin — 0.65.0, aws-lc-rs, no compression, ed25519-only +# ADR-002: The russh pin (0.65.0, aws-lc-rs, no compression, ed25519-only) ## Status -**Accepted** — 2026-10-26. A one-way door in practice (SSH negotiation +**Accepted** (2026-10-26). A one-way door in practice (SSH negotiation behavior crosses crate boundaries; bumps are deliberate, tested, and recorded per the repo's wire-stability convention). Amended same day after review: the earlier enabling of the `rsa` feature is reverted (`rsa` off). Amended 2026-10-27 (review pass): the key-algorithm -position is **ed25519-only** — the family's posture is the binding +position is **ed25519-only**; the family's posture is the binding constraint, not fleet compatibility (decision 4). ## Context @@ -21,9 +21,9 @@ constraint, not fleet compatibility (decision 4). 2026-10-26 re-verification section carries the current facts. The POCs validated against 0.64.1 (then-newest) with the `ring` backend; since then **0.65.0** has been published (2026-10-25) with six security -fixes — three in classes an internet-facing SSH door cares about -(client EXT_INFO unbounded allocation, peer zero-max-packet-size -channel stall, pre-auth nested-certificate stack exhaustion) — and two +fixes, three of them in classes an internet-facing SSH door cares +about (client EXT_INFO unbounded allocation, peer zero-max-packet-size +channel stall, pre-auth nested-certificate stack exhaustion), and two breaking changes confined to handler-signature shapes (`PublicKeyOffer` / `VerifiedPublicKey`; `async-trait` feature removed). @@ -31,9 +31,9 @@ removed). The family's crypto-posture context: `alktls`, `alkhttp`, and `alknet` all standardize on `rustls` with the **`aws_lc_rs`** feature; no family crate links `ring`. The provider choice matters only as -binary-composition hygiene — when alkforge's binary links alkssh -*and* the family's rustls stack, one shared primitive library is the -goal — and has nothing to do with TLS on the SSH wire. +binary-composition hygiene (when alkforge's binary links alkssh +*and* the family's rustls stack, the goal is one shared primitive +library) and has nothing to do with TLS on the SSH wire. ## Decision @@ -51,22 +51,22 @@ goal — and has nothing to do with TLS on the SSH wire. builds clean in ~50 s with no external provisioning (prebuilt bindings), tree `aws-lc-rs 1.18.2` / `aws-lc-sys 0.46.0`. The POCs' `ring` build is recorded as the fallback if a target - platform ever fails to provision aws-lc — a deliberate, + platform ever fails to provision aws-lc, a deliberate, recorded deviation, never drift. -3. **`flate2` off — no compression.** The POCs already ran +3. **No compression (`flate2` off).** The POCs already ran compression-free and vanilla OpenSSH was untroubled; SSH compression is largely pointless at modern link layers and is the CRIME-adjacent attack surface OpenSSH itself defaults off. Bounded-resources and surface-reduction posture agree. -4. **ed25519-only — `rsa` off, and no other scheme is supported.** +4. **ed25519-only (`rsa` off, and no other scheme is supported).** Amended 2026-10-27 from the earlier "ed25519-first" wording after review: the position is ed25519-only. One key algorithm carries the family's identity: the same ed25519 keys serve alktls's self-signed TLS certificates and iroh, and the downstream alknet - rewrite rides ssh + ed25519, tcp + tls, quic + tls, and iroh — the + rewrite rides ssh + ed25519, tcp + tls, quic + tls, and iroh, the same ed25519 identity. The legacy/alternative SSH key schemes (rsa, dsa; ecdsa variants and hardware-token sk variants share the - defect) are issued, stored, or derived nowhere in the family — + defect) are issued, stored, or derived nowhere in the family, so supporting them here would be a wart backed by nothing else. Enabling `rsa` would additionally have pulled two RC-pinned deps and made alkssh the family's only RSA-holding crate. Concretely: @@ -75,15 +75,15 @@ goal — and has nothing to do with TLS on the SSH wire. RC-free except `ssh-key =0.7.0-rc.11`, which russh exact-pins on the `russh::keys` path). Wire behavior is honest and fail-closed: a client presenting a non-ed25519 key gets a clean - `USERAUTH_FAILURE` — never a half-open session. Operational + `USERAUTH_FAILURE`, never a half-open session. Operational shape: alkssh implements no other scheme; an assembly registers ed25519 fingerprints only; and `auth_publickey` **declines non-ed25519 keys explicitly** (ADR-004's explicit-over-inherited pattern applied to the algorithm position, so the posture survives a pin bump that might otherwise accept more schemes). If a scheme ever matters, the amendment is one feature flag plus this ADR. -5. **`des` / `dsa` never.** russh's own "Danger: insecure" features — - no alkssh configuration may enable them. +5. **`des` / `dsa` never.** russh's own "Danger: insecure" features, + which no alkssh configuration may enable. 6. **Manifest lands with the dependency**: `rust-version = "1.89"` (russh's MSRV; supersedes the current 1.88) and, with it, the edition-2024 bump the POCs compiled under. @@ -91,12 +91,12 @@ goal — and has nothing to do with TLS on the SSH wire. ## Consequences - **Positive**: the pin is current, carries the security fixes, and - matches the family crypto posture; the tree is fully inventoried — - with `rsa` off it is **RC-free except `ssh-key =0.7.0-rc.11`** + matches the family crypto posture; the tree is fully inventoried, + and with `rsa` off it is **RC-free except `ssh-key =0.7.0-rc.11`** (unavoidable: russh exact-pins it on the `russh::keys` path); MSRV/licensing are explicit. - **Negative**: clients presenting non-ed25519 keys (RSA among them) - decline outright (clean `USERAUTH_FAILURE`) — recorded as + decline outright (clean `USERAUTH_FAILURE`), recorded as intentional; `ssh-key`'s RC pin rides the russh keys path until russh moves off it; aws-lc-rs is the heavier build (proven acceptable here, with `ring` as the recorded fallback). diff --git a/docs/architecture/decisions/003-feature-graph.md b/docs/architecture/decisions/003-feature-graph.md index 5169767..64ebec4 100644 --- a/docs/architecture/decisions/003-feature-graph.md +++ b/docs/architecture/decisions/003-feature-graph.md @@ -3,11 +3,11 @@ status: reviewed last_updated: 2026-10-28 --- -# ADR-003: The capability feature graph — flat, independent, two-direction +# ADR-003: The capability feature graph (flat, independent, two-direction) ## Status -**Accepted** — 2026-10-26. Builds the fail-closed capability contract +**Accepted** (2026-10-26). Builds the fail-closed capability contract (family ADR-003 decision 3) into the crate's own feature names; the decline paths are contract territory on every side of every feature. @@ -17,15 +17,15 @@ decline paths are contract territory on every side of every feature. into capabilities by what rides it (shell/exec, git, pipes), and vanilla SSH multiplexes tunnel legs through `direct-tcpip`/`forwarded-tcpip` channels (a vanilla `ssh -D` is its own client-side SOCKS door plus -`direct-tcpip` — the wire never sees SOCKS, POC-3 correction 2). The feature +`direct-tcpip`; the wire never sees SOCKS, POC-3 correction 2). The feature graph is how alkforge gets an endpoint that is *structurally incapable* of serving what it didn't enable: a git-only build accepts no shell and -no tunnels — and, in the opposite direction, no capabilities a client +no tunnels; and, in the opposite direction, no capabilities a client build didn't opt into. Phase 0 validated the seams end to end (POC-3 stages A–D: git exec, direct-tcpip via the alktunnels bridge, session/pty, all against -published sibling crates — with stage B additionally establishing, +published sibling crates, with stage B additionally establishing, against real vanilla `ssh -D` + curl, that the wire-level SOCKS story is client-side; its SOCKS-in-channel serve path was subsequently retired, see §1a), and the user's direction confirmed the scope: full @@ -40,53 +40,52 @@ feature set except sftp (placeholder until alkfs lands). |---|---|---|---| | `session` | session channels: pty/shell, pipe-mode exec | client pty/shell/exec/subsystem opens | alktty | | `git` | git-parseable exec (dispatched out of session channels) | client git consumption of upstream servers | alkgit | - | `tunnel` | `direct-tcpip`, `forwarded-tcpip`, `direct-streamlocal`, the `tcpip_forward`/`streamlocal_forward` listen flow (the streamlocal rows — channel and listen — are runtime-Unix-gated, OQ-11) | client `direct-tcpip` opens (which is what `-D` rides — see 1a) | alktunnels | - | `sftp` | *placeholder — declines until alkfs lands (OQ-13 resolved)* | client sftp half, same placeholder | alkfs (future) | + | `tunnel` | `direct-tcpip`, `forwarded-tcpip`, `direct-streamlocal`, the `tcpip_forward`/`streamlocal_forward` listen flow (the streamlocal rows, channel and listen, are runtime-Unix-gated, OQ-11) | client `direct-tcpip` opens (which is what `-D` rides; see 1a) | alktunnels | + | `sftp` | *placeholder, declines until alkfs lands (OQ-13 resolved)* | client sftp half, same placeholder | alkfs (future) | **1a. No `dynamic-forward` feature.** Amended 2026-10-26 (review untangle): the earlier graph carried a `dynamic-forward` feature - with a server-side SOCKS5-in-channel row — a family-native client + with a server-side SOCKS5-in-channel row, a family-native client shape ("speak SOCKS into a session channel, server dials through alksocks"). No consumer of ours is that client: vanilla `ssh -D` does its SOCKS5 handshake client-side and opens plain `direct-tcpip` channels (POC-3 correction 2), and the family - behavior of `-D` is a **client-side local door** — alksocks' local + behavior of `-D` is a **client-side local door**: alksocks' local door given a `DialFn` backed by the alkssh client's `direct-tcpip` opens, composed at the assembly. The server needs only the `tunnel` row for the whole `-D` leg, and alkssh does not depend on alksocks on this path. The SSH-detect dispatch from ADR-006 point 3 retires with it (session dispatch is parse-only; a request-less session channel declines). If a consumer ever - genuinely wants a remote SOCKS door (the OQ-01 relay/browser leg — + genuinely wants a remote SOCKS door (the OQ-01 relay/browser leg, resolved out of scope: no such consumer exists today), adding it - back is an explicit ADR — - the two transports are "just byte streams", so nothing forbids - it; nothing needs it. + back is an explicit ADR. The two transports are "just byte + streams", so nothing forbids it; nothing needs it. -2. **Flat and independent.** No feature implies another. `default = []` - — a default build has no capabilities at all; every unfeatured +2. **Flat and independent.** No feature implies another. `default = []`: + a default build has no capabilities at all; every unfeatured channel type/open/request declines with a clean error. `git` does not imply `session` (a git-only build serves the git-parseable exec - path by declining to parse to anything else — the parse-then-fail + path by declining to parse to anything else, the parse-then-fail contract from POC-3 stage A). Note the `tunnel` client leg's SOCKS door is *local to the dialing host* (alksocks composed at the - assembly), not a wire capability — the wire sees `direct-tcpip` + assembly), not a wire capability; the wire sees `direct-tcpip` only. 3. **Two-direction symmetry.** Each feature name gates *both* the server-direction (what an embedded alkssh serves vanilla clients) and the client-direction (what a family app drives when dialing an upstream SSH server, including the forwarded-channel acceptor rows). - One name, one capability, both doors — this is the symmetry rule, + One name, one capability, both doors: this is the symmetry rule, now structural: a `git`-only build cannot dial a tunnel upstream any more than it can serve one. 4. **Client-side fail-closed at the acceptor.** Server-initiated - channel opens (`forwarded-tcpip` is the real one — the `-R` data + channel opens (the real one is `forwarded-tcpip`, the `-R` data path) are served only under a registered feature's acceptor handler; unhandled/unfeatured accepted opens are **declined**, not - accept-and-dropped (russh's client default is accept-then-drop — + accept-and-dropped (russh's client default is accept-then-drop, ours to fix; the reverse-runner extraction names it). 5. **Never features.** `x11` and `auth-agent` forwarding are not - feature-gated at all — always declined, at any configuration + feature-gated at all and are always declined, at any configuration (agent forwarding is the *anti-pattern* of the alkvault custody model; x11 has no family seam). An ADR amendment is required to ever feature-gate them. @@ -104,21 +103,21 @@ feature set except sftp (placeholder until alkfs lands). surfaces can't drift apart on capability naming. - **Negative**: the split of `session` makes feature interplay a real test matrix (per-feature decline paths on both directions are - contract tests — the AGENTS.md verification convention covers this + contract tests; the AGENTS.md verification convention covers this via `--all-features` runs plus the decline-matrix suite). - **Neutral**: feature *names* are now contractual (downstream builds - specify them); renames would be semver-visible — hence the ADR, not - incidental naming. + specify them); renames would be semver-visible, hence the ADR rather + than incidental naming. ## References - `mapping-table.md` (the validated rows under these names + the feature-split notes + the three POC-3 corrections) -- Family ADR-003 (decision 3 — the fail-closed capability features), +- Family ADR-003 (decision 3, the fail-closed capability features), family ADR-002 (why exclusion matters for alkforge) - `docs/research/reverse-runner.md` (client-direction surface the symmetry rule serves; the acceptor default fact) - `docs/research/poc-3-mapping-bakeoff.md` (stages A–D: the seams at these names work end to end) -- OQ-13 (resolved — the placeholder contract stands), OQ-02 (resolved +- OQ-13 (resolved; the placeholder contract stands), OQ-02 (resolved by ADR-006) \ No newline at end of file diff --git a/docs/architecture/decisions/004-auth-dispositions.md b/docs/architecture/decisions/004-auth-dispositions.md index 5f84518..0be9709 100644 --- a/docs/architecture/decisions/004-auth-dispositions.md +++ b/docs/architecture/decisions/004-auth-dispositions.md @@ -3,16 +3,16 @@ status: reviewed last_updated: 2026-10-28 --- -# ADR-004: Auth dispositions — publickey-only, fail-closed, fingerprint identity +# ADR-004: Auth dispositions (publickey-only, fail-closed, fingerprint identity) ## Status -**Accepted** — 2026-10-26. Evidence-backed (POC-1 end to end, +**Accepted** (2026-10-26). Evidence-backed (POC-1 end to end, POC-2's signature-pipeline facts); open to amendment only via a new ADR. Amended 2026-10-27 (review pass, per ADR-002 decision 4's ed25519-only amendment): `auth_publickey` additionally declines -non-ed25519 keys explicitly — the key-algorithm posture, not the -fingerprint disposition, changes; all other dispositions stand. Not a +non-ed25519 keys explicitly (the key-algorithm posture, not the +fingerprint disposition, changes); all other dispositions stand. Not a ledger item (no wire format changes), but the decline matrix is contract territory: every decline is tested on both sides of every feature. @@ -22,7 +22,7 @@ every feature. The family identity model has exactly one SSH-shaped resolution path: a verified key's fingerprint → alkcall `IdentityProvider:: resolve_from_fingerprint` → `Identity` (`auth-bridge.md`). russh 0.64.1 -(the POC pin) performs the cryptographic verification itself — +(the POC pin) performs the cryptographic verification itself, so `auth_publickey` fires *after* signature and ownership verification (POC-1 observed), and invalid/malformed signatures produce `USERAUTH_FAILURE` before any handler hook fires (POC-2); the facts @@ -35,25 +35,25 @@ twice: which methods exist, and what unauthenticated attempts receive. ## Decision -1. **`auth_publickey` is implemented — the only method.** Handler: +1. **`auth_publickey` is implemented, and it is the only method.** Handler: fingerprint (`ssh-key` re-export, `HashAlg::Sha256`, - `SHA256:` + unpadded base64 over the standard alphabet — + `SHA256:` + unpadded base64 over the standard alphabet, `+/`, not the url-safe alphabet; byte-identical to `ssh-keygen -lf`) → `resolve_from_fingerprint` → `Some` → accept with the resolved identity; `None` → reject. The fingerprint *string format* is a contract invariant: registration strings from any standard issuer - (ssh-keygen, alkcall-side computation) must compare equal — - POC-1 proved parity against `ssh-keygen -lf` output. + (ssh-keygen, alkcall-side computation) must compare equal; POC-1 + proved parity against `ssh-keygen -lf` output. 2. **`auth_publickey_offered` is accepted-and-delegated.** The probe hook (key seen, ownership unverified) accepts the offer and lets - `auth_publickey` decide — which keeps `max_auth_attempts` counting + `auth_publickey` decide, which keeps `max_auth_attempts` counting honest (confirmed: the hook does not consume an attempt). 3. **Every other method is explicitly declined, including when inert at the pin.** `auth_none`, `auth_password`, `auth_keyboard_interactive`, `auth_openssh_certificate` each get explicit reject implementations. At russh 0.64.1 the defaults - already reject (POC-1's correction of the 0.5x-era assumption) — + already reject (POC-1's correction of the 0.5x-era assumption); the explicit implementations are family contract, not redundancy: the pin is a one-way door and a future bump could silently change defaults (an auth-posture change would otherwise be invisible). @@ -86,7 +86,7 @@ twice: which methods exist, and what unauthenticated attempts receive. small and fully testable per build; auth posture survives a russh bump that changes defaults. - **Negative**: none material. The explicit-declines cost a few - handler lines per method — deliberately purchased immovability. + handler lines per method, deliberately purchased immovability. - **Neutral**: password/keyboard-interactive exist client-side only as non-goals (`vision.md`) until a consuming use case arrives; the server never gains them without amending this ADR (a wire-visible diff --git a/docs/architecture/decisions/005-at-rest-key-custody.md b/docs/architecture/decisions/005-at-rest-key-custody.md index 0a72d2a..c61ccea 100644 --- a/docs/architecture/decisions/005-at-rest-key-custody.md +++ b/docs/architecture/decisions/005-at-rest-key-custody.md @@ -3,11 +3,11 @@ status: reviewed last_updated: 2026-10-28 --- -# ADR-005: At-rest key custody — vault-wrapped OpenSSH-PEM, decrypt-at-use +# ADR-005: At-rest key custody (vault-wrapped OpenSSH-PEM, decrypt-at-use) ## Status -**Accepted** — 2026-10-26. Evidence-backed (POC-2, 39/39, including +**Accepted** (2026-10-26). Evidence-backed (POC-2, 39/39, including the 4-way inner-format bake and the failure matrix); amendable only via a new ADR. @@ -16,7 +16,7 @@ via a new ADR. On the client direction alkssh *holds* secret material (dial keys); on the server direction it holds the host key. Family rules in force: -- Secrets come from alkvault-backed sources — never env vars, never +- Secrets come from alkvault-backed sources, never env vars, never plaintext at rest (family ADR-002 decision 5; family-wide standing posture). - The vault is a **secret store on this path, not a key generator or @@ -24,13 +24,13 @@ the server direction it holds the host key. Family rules in force: a different feature and is not the SSH-keys path (`auth-bridge.md` §The gap). Held SSH keys are bearer-token-class secrets: opaque bytes, vault-encrypted at rest, decrypted at use. -- The vault is **never** involved in resolving *clients'* fingerprints - — that path is the assembly-layer `IdentityProvider` (scope note in +- The vault is **never** involved in resolving *clients'* fingerprints; + that path is the assembly-layer `IdentityProvider` (scope note in `pocs.md` §POC-2). alkvault's `encrypt`/`decrypt` surface is `&str` in, `Zeroizing` out since 0.2.0 (0.1.0 returned a plain `String`; the `EncryptedData` -wrapper unchanged) — a decisive constraint on any +wrapper unchanged), a decisive constraint on any binary inner format (POC-2 measured: it would force base64 layers on both ends). @@ -43,9 +43,9 @@ both ends). boundary (`ssh -i` accepted the decrypted bytes directly), and exactly one decode call (`decode_secret_key(plain, None)`) from russh. Rejected alternatives, recorded for the trail: ssh-key's - own encrypted-key format (double-wrapping antipattern — the + own encrypted-key format (double-wrapping antipattern: the passphrase is itself a stored secret, moving the problem one level - down), raw-seed base64 (80 B vs 540 B blobs — legitimate size win, + down), raw-seed base64 (80 B vs 540 B blobs, a legitimate size win, rejected because it couples storage to a fixed key shape; "the bytes of a PEM file" survives every algorithm ssh-key can decode), PKCS#8 PEM (parity, loses the native round-trip, adds nothing). @@ -56,28 +56,28 @@ both ends). only at dial time: locking it mid-session keeps live sessions streaming while new dials fail (POC-2 verified); unlock is side-effect-free on key identity. -3. **Zeroization ownership (the gap — closed upstream).** ssh-key +3. **Zeroization ownership (the gap, closed upstream).** ssh-key zeroizes ed25519 key bytes on `Drop` (decoded keys, and their clones, clean themselves). The *decrypted plaintext* was the gap: alkvault's `decrypt` once returned a plain `String`, leaving the plaintext in unfenced heap between the vault's return and the - adapter's wrap — the adapter owned the wrap then (proven in POC-2; + adapter's wrap; the adapter owned the wrap then (proven in POC-2; **that was the contract on this path**) and the vault-side improvement (a `Zeroizing`-out API) was an upstream ask filed in alkvault's own tracker. The ask shipped in **alkvault - 0.2.0** (2026-11-04, the tracker's review 001 — this ADR is the + 0.2.0** (2026-11-04, the tracker's review 001; this ADR is the origin it cites): `decrypt` returns `Zeroizing`, the buffer zeroizes on every path (including the UTF-8 error path), and `rotate` transits zeroized output too. The adapter seam wrap is thereby **dropped** (the alkssh 0.2.0 adoption): `decrypt_and_decode` - consumes the vault's zeroized return directly — the double wrap + consumes the vault's zeroized return directly, since the double wrap would be dead posture. Decision 3's ownership now sits entirely with alkvault; the adapter's obligation shrinks to consuming the zeroized value in one pass (the `decode_key_arm` signature keeps riding the `Zeroizing` type as the proof). 4. **Failure modes are all fail-closed, with distinct error shapes.** Locked vault → no dial (and no server: a host key that can't load - prevents server construction — verified at bootstrap). Absent + prevents server construction, verified at bootstrap). Absent blob → no dial. Wrong mnemonic / tampered ciphertext → decryption failure, no dial. Unknown `key_version` → vault's `InvalidPath` refuse-before-crypto. Rotation (at-rest version bump, key @@ -85,25 +85,25 @@ both ends). each to a distinct variant; the vault's error taxonomy is the adapter's to reshape, never to collapse. 5. **`EncryptedData` is opaque to alkssh.** The crate never - interprets `key_version` or any record internals — the vault owns + interprets `key_version` or any record internals; the vault owns version semantics. Rotation is a storage-side operation. 6. **Host keys ride the same model.** Server host keys are decrypt-at-construction into `Config::keys` through the same load path; the client's host-*pin* is public config, never vault material (OQ-05's two-case policy handles pinning). The two-case - host-key policy *seam* is the crate's trust-decision contract — - the crate is policy-blind (fail-closed default; a permissive - case-2 policy is an assembly choice) — the contract lives in + host-key policy *seam* is the crate's trust-decision contract: the + crate is policy-blind (fail-closed default; a permissive + case-2 policy is an assembly choice), and the contract lives in `auth.md`/`client.md`. 7. **Plaintext never persists.** No disk spill on this path, ever; the vanilla-tool PEM compatibility is for the assembly layer to - exploit if ever (e.g. handing a key to an external agent process — + exploit if ever (e.g. handing a key to an external agent process, itself out of family scope). 8. **The `Signer` seam is dismissed, with evidence.** POC-2's verdict: no `impl Signer` exists anywhere in the 0.64.1 tree (the doc comment claiming an SSH-agent implementation is stale); a hand-rolled signer must replicate the splice convention (`to_sign - || string{sig}` — initial omission was silently misparsed); an + || string{sig}`; an initial omission was silently misparsed); an in-process vault gains nothing over one-time decode (decrypted bytes exist client-side at *either* dial time or sign time; decrypt-per-sign keeps *more* plaintext copies alive while @@ -117,7 +117,7 @@ both ends). at-rest bytes are plain OpenSSH key files, so alkvault stores and rotates them with no SSH-specific logic. - **Negative**: ~~decrypted plaintext has one unfenced window (before - the adapter's wrap) until alkvault changes its API~~ — the gap is + the adapter's wrap) until alkvault changes its API~~; the gap is resolved (alkvault 0.2.0's `Zeroizing` return; the adapter wrap dropped, OQ-07 amended). No plaintext window remains on this ADR's path. @@ -132,6 +132,6 @@ both ends). lifecycle + zeroization bounds, §3 the failure matrix, §4 the Signer dismissal, §5 host keys end to end) - alkcall ADR-010 + alkhttp ADR-014 (the secret-material-flow family - pattern — ADR-010 is alkhttp ADR-014's alkcall analog) + pattern; ADR-010 is alkhttp ADR-014's alkcall analog) - Family ADR-002 decision 5, family ADR-003 decision 2 - OQ-05, OQ-06, OQ-07, OQ-13 (all resolved) \ No newline at end of file diff --git a/docs/architecture/decisions/006-mapping-bridge-and-dispatch.md b/docs/architecture/decisions/006-mapping-bridge-and-dispatch.md index 325dc58..17640ac 100644 --- a/docs/architecture/decisions/006-mapping-bridge-and-dispatch.md +++ b/docs/architecture/decisions/006-mapping-bridge-and-dispatch.md @@ -3,14 +3,14 @@ status: reviewed last_updated: 2026-10-28 --- -# ADR-006: Mapping decisions — route-1 bridge, exec-only git, session dispatch +# ADR-006: Mapping decisions (route-1 bridge, exec-only git, session dispatch) ## Status -**Accepted** — 2026-10-26. All three decisions are +**Accepted** (2026-10-26). All three decisions are POC-3-validated against published sibling crates; amendments are new ADRs. Amended same day (review untangle): point 3's **SOCKS-detect is -retired** — with the `dynamic-forward` feature removed (ADR-003 §1a), +retired**: with the `dynamic-forward` feature removed (ADR-003 §1a), session dispatch is parse-only and a request-less session channel simply declines. The parse-vs-anything split was only ever needed if a session channel could *be* a SOCKS stream; no consumer is that @@ -39,48 +39,48 @@ alkgit/alksocks/alktunnels/alktty). (`channel_open_direct_tcpip` → accept only after the fabric open succeeds → `copy bidirectional`). POC-3 stage C proved it end to end (8 MiB in ~333 ms; fabric-open failure maps to an explicit - SSH `ConnectFailed` reject — accept happens only post-success, + SSH `ConnectFailed` reject; accept happens only post-success, since `accept()` consumes the handle). Registry-side bridging (route 2: alkssh-shaped `ChannelOperations` in a serving endpoint's registry) is **rejected**: it inverts the door posture by putting adapter work upstream of the door. The alktunnels - *absence* note (no serve-over-stream entry) is thereby resolved — - route 1 never needed one. + *absence* note (no serve-over-stream entry) is thereby resolved, + as route 1 never needed one. 2. **Git over SSH is exec-only; every subsystem request declines.** The vanilla git connection model is: git spawns its ssh transport, which opens one session channel and sends - `exec_request "git-{upload,receive}-pack '/repo'"` — the repo path - rides inside the command string, so a subsystem request (name + `exec_request "git-{upload,receive}-pack '/repo'"`, so the repo path + rides inside the command string, and a subsystem request (name only, no repo id) can never dispatch. POC-3 proved the parse grammar (quoted/unquoted paths, service-only → decline, non-git → parse-then-fail with no resource alloc) and clone/push/second-push through the published seam with zero - fixups. Vanilla git behavior loses nothing — vanilla git never + fixups. Vanilla git behavior loses nothing: vanilla git never speaks subsystem for git; the decline only refuses a manual `ssh -s`-style invocation, with a reason. 3. **~~Session-channel dispatch: request-driven parse wins; detect - covers the request-less case~~ — amended: parse-only.** The + covers the request-less case~~ (amended: parse-only).** The original decision composed the POC-3 modes as: requests take the parse path (git under `git`, pipe/shell under `session`); request-less session channels ran a SOCKS-detect (`dynamic-forward`: spawn, first-byte `0x05` sniff, 500 ms bounded window). With `dynamic-forward` removed from the feature graph (ADR-003 §1a), the detect has no owner: **session dispatch - is parse-only** — requests parse to git (exec) or pipe/shell, + is parse-only**: requests parse to git (exec) or pipe/shell, and a request-less session channel declines as unfeatured. This still resolves OQ-02 (the composition was the unknown; the resolution is now the simpler of the two candidates). 4. **The spawn-and-return rule is a structural constraint, not an implementation detail.** russh 0.64.1-era servers run handler - callbacks inline inside the session's packet loop — a blocking + callbacks inline inside the session's packet loop, so a blocking handler deadlocks against the peer's follow-up bytes (POC-3 proved by construction: receive-pack pushes bytes immediately after the exec request). Every serving path is `tokio::spawn` + handler-returns-fast; exit reporting from the - spawned task rides the cloned `Session::handle()` — - `exit-status`(then `close`) before channel close, `exit-signal` + spawned task rides the cloned `Session::handle()`: + `exit-status` (then `close`) before channel close, `exit-signal` for signal termination (signal-name fidelity: the derivation - contract on `mapping.md`'s exit rows — OQ-10 resolved). + contract on `mapping.md`'s exit rows; OQ-10 resolved). ## Consequences @@ -89,11 +89,11 @@ alkgit/alksocks/alktunnels/alktty). alkforge assembly gets an exact wiring recipe (POC-3 §C.4's in-process fabric leg is the template, verbatim). - **Negative**: the SOCKS-detect retirement removes a tunable the - POC proved workable — if a family client ever wants a remote SOCKS + POC proved workable; if a family client ever wants a remote SOCKS door, the shape is re-addable via ADR (500 ms bounded window preserved in the reference here). - **Neutral**: route 2 remains documented in `mapping-table.md` as - the rejected alternative, with its rationale — useful if the + the rejected alternative, with its rationale, useful if the posture is ever revisited (that revisit is an ADR, not a code flip). ## References @@ -103,8 +103,8 @@ alkgit/alksocks/alktunnels/alktty). - `mapping-table.md` (the rows + the absence note this ADR resolves) - ADR-003 (the feature gates these dispatch decisions sit behind) - `docs/research/reverse-runner.md` (the client-direction mirror of - leg 1 — the `-L`/`-R` legs the same route-1 bridge serves) -- OQ-02 (resolved by this ADR), OQ-10 (resolved — the derivation - contract lives on `mapping.md`'s exit rows; the PTY-path - signal-identity limitation is recorded upstream, alktty/portable-pty - side) \ No newline at end of file + leg 1, the `-L`/`-R` legs the same route-1 bridge serves) +- OQ-02 (resolved by this ADR), OQ-10 (resolved: the derivation + contract lives on `mapping.md`'s exit rows, and the PTY-path + signal-identity limitation is recorded upstream on the + alktty/portable-pty side) \ No newline at end of file diff --git a/docs/architecture/mapping.md b/docs/architecture/mapping.md index 13119de..13ba165 100644 --- a/docs/architecture/mapping.md +++ b/docs/architecture/mapping.md @@ -3,7 +3,7 @@ status: reviewed last_updated: 2026-10-29 --- -# Mapping — server direction: SSH channels × family seams (the spine) +# Mapping (server direction): SSH channels × family seams, the spine ## The door framing @@ -12,7 +12,7 @@ family out. Vanilla `ssh`/`git` connect as they would to any sshd; every accepted channel/request maps to **exactly one family protocol seam** and **exactly one cargo feature** (ADR-003). Rows below are POC-3-validated against published sibling crates with real vanilla -tooling (OpenSSH 9.6, git 2.43, curl 8.5 — `poc-3-mapping-bakeoff.md`). +tooling (OpenSSH 9.6, git 2.43, curl 8.5; see `poc-3-mapping-bakeoff.md`). ## The seams @@ -20,25 +20,25 @@ tooling (OpenSSH 9.6, git 2.43, curl 8.5 — `poc-3-mapping-bakeoff.md`). |---|---|---| | alktty | `drive_session[_pre_negotiated]` | `(impl AsyncWrite, impl AsyncRead, backends, identity)` | | alkgit | `serve_duplex_resolved` | `(repo_id, identity, service, duplex, limits, backend traits)` | -| alktunnels | — no serve entry (**absence note resolved**: route 1 never needed one, ADR-006) | consumer/open-op registrations | +| alktunnels | no serve entry (**absence note resolved**: route 1 never needed one, ADR-006) | consumer/open-op registrations | -russh channels convert to duplex via `into_stream`/`split` — the +russh channels convert to duplex via `into_stream`/`split`, the bridge primitives every mapping uses (POC-1 §7). ## Channel-type rows | SSH channel type | Vanilla use | Feature | Family seam | Contract notes | |---|---|---|---|---| -| `session` → `pty_request` + `shell_request` | `ssh host` interactive shell | `session` | alktty: `NegotiateRequest` constructed locally → `drive_session_pre_negotiated` | `pty_request` args → `TerminalParamsWire` (term/cols/rows/pixels; SSH modes → the reserved modes JSON); shell argv = the assembly-supplied serve-config shell (no crate default; unset → `shell_request` declines — OQ-08 resolved); the data plane is a **translation bridge**, not a byte pump — alktty is chunk-framed both directions (stdin/stdout/stderr/CTRL chunks): SSH data→stdin chunks; stdout chunks→SSH data; stderr chunks→`extended_data(1)`; exit chunk→`exit-status`/`exit-signal` + close (POC-3 §D.1) | -| `session` → `exec_request` (non-git) | `ssh host "cmd"` | `session` | alktty pipe mode (`terminal: None`) — separate stderr | the wire's command string → the configured exec wrapper argv with the command appended as final element (`wrapper ++ [cmd]`, OQ-08 — never naively split); wrapper unset → clean decline; stderr rides `extended_data(1)` — only valid without PTY; PTY mode merges (kernel PTY, POC-3 §D.1 proven) | +| `session` → `pty_request` + `shell_request` | `ssh host` interactive shell | `session` | alktty: `NegotiateRequest` constructed locally → `drive_session_pre_negotiated` | `pty_request` args → `TerminalParamsWire` (term/cols/rows/pixels; SSH modes → the reserved modes JSON); shell argv = the assembly-supplied serve-config shell (no crate default; unset → `shell_request` declines, OQ-08 resolved); the data plane is a **translation bridge**, not a byte pump: alktty is chunk-framed both directions (stdin/stdout/stderr/CTRL chunks): SSH data→stdin chunks; stdout chunks→SSH data; stderr chunks→`extended_data(1)`; exit chunk→`exit-status`/`exit-signal` + close (POC-3 §D.1) | +| `session` → `exec_request` (non-git) | `ssh host "cmd"` | `session` | alktty pipe mode (`terminal: None`), which separates stderr | the wire's command string → the configured exec wrapper argv with the command appended as final element (`wrapper ++ [cmd]`, OQ-08; never naively split); wrapper unset → clean decline; stderr rides `extended_data(1)` (only valid without PTY); PTY mode merges (kernel PTY, POC-3 §D.1 proven) | | `session` → `exec_request` git | `git clone/push ssh://` | `git` | alkgit: parse command → `Service` + repo → `serve_duplex_resolved(repo_id, identity, service, duplex, …)` | **exec-only** (ADR-006 decision 2): the repo rides inside the exec command string (`git-upload-pack '/repo'`); vanilla git never speaks subsystem for git; process-mode dialect (server-speaks-first advertisement); exit-status proven both directions | -| `session` → `subsystem_request` | `sftp` clients; manual `ssh -s` | `sftp` (placeholder) / `git` rows | — | **`sftp` name**: declines until alkfs lands — the feature exists, the route doesn't (OQ-13 resolved: the tested decline placeholder is the shipped behavior until an alkfs-mapping ADR changes it). **git service names**: decline with reason — a subsystem request carries only a name, never a repo id (POC-3 §A.3); never half-serves | -| `direct-tcpip` | `ssh -L`; all `ssh -D` traffic | `tunnel` | alktunnels **route 1**: adapter-side bridge — `TunnelSession::open_direct` on a held `ChannelClient`, pump `take_halves()` vs the channel | target = `SubstrateAddr::HostPort`/`Path`; `Substrate` = Tcp/Unix; accept only **after** the fabric open succeeds — failure maps to an explicit `ConnectFailed` reject (POC-3 §C.2); vanilla `-D`'s dynamic targets dispatch here (POC-3 correction 2) | -| `forwarded-tcpip` | `ssh -R` data channels | `tunnel` | the `register_tunnel_forwarded_openable_with_establisher` gate shape (alktunnels 0.5.0, review-003 W2 adoption): the router's correlation lookup rides the establisher's `ForwardedGate`; the handler's `ForwardedHandler` shape hands the channel duplex to the pump | per fabric arrival the door opens a `forwarded-tcpip` channel **toward the vanilla client** (a vanilla client never initiates this type — sshd parity; an inbound open is the unvanilla class, declined `ConnectFailed`); echo args = the original `tcpip_forward` request's wire string verbatim + the actually-bound port (the sshd `c->path` + `get_local_port` parity — a `port = 0` mint echoes the allocated number); originator = `TunnelForwardedParams.peer`; a stale/missing correlation token fails the open **typed at the call plane** (`channel:open_failed`, `unknown_resource`) before any channel is born (the gate's miss class); the narrow gate-pass→handler-run residual race and the cap-admission declines still teardown-at-birth (never a half-open channel); the accept is the crate's per-session channel cap (the acceptor vocabulary) | -| `direct-streamlocal` | unix-socket forward | `tunnel` | `SubstrateAddr::Path` treatment | compiles wherever `tunnel` compiles; serving is **runtime-Unix-gated** — on Unix serves, on other platforms a clean open-decline (OQ-11 resolved: universal compile + runtime decline, no per-platform feature fork) | -| `x11` | X forwarding | — (never) | — | always declines; not feature-gated (ADR-003 §5) | -| `auth-agent` | agent forwarding | — (never) | — | always declines; the anti-pattern of the alkvault custody posture (ADR-003 §5) | -| `session` → SOCKS5-in-channel | (no vanilla use) | — (retired, ADR-003 §1a) | — | vanilla `-D` is client-side SOCKS + `direct-tcpip`; the family local door is alksocks composed at the assembly — see `client.md` | +| `session` → `subsystem_request` | `sftp` clients; manual `ssh -s` | `sftp` (placeholder) / `git` rows | none | **`sftp` name**: declines until alkfs lands, since the feature exists but the route doesn't (OQ-13 resolved: the tested decline placeholder is the shipped behavior until an alkfs-mapping ADR changes it). **git service names**: decline with reason, since a subsystem request carries only a name, never a repo id (POC-3 §A.3); never half-serves | +| `direct-tcpip` | `ssh -L`; all `ssh -D` traffic | `tunnel` | alktunnels **route 1**: adapter-side bridge, `TunnelSession::open_direct` on a held `ChannelClient`, pump `take_halves()` vs the channel | target = `SubstrateAddr::HostPort`/`Path`; `Substrate` = Tcp/Unix; accept only **after** the fabric open succeeds; failure maps to an explicit `ConnectFailed` reject (POC-3 §C.2); vanilla `-D`'s dynamic targets dispatch here (POC-3 correction 2) | +| `forwarded-tcpip` | `ssh -R` data channels | `tunnel` | the `register_tunnel_forwarded_openable_with_establisher` gate shape (alktunnels 0.5.0, review-003 W2 adoption): the router's correlation lookup rides the establisher's `ForwardedGate`; the handler's `ForwardedHandler` shape hands the channel duplex to the pump | per fabric arrival the door opens a `forwarded-tcpip` channel **toward the vanilla client** (a vanilla client never initiates this type, per sshd parity; an inbound open is the unvanilla class, declined `ConnectFailed`); echo args = the original `tcpip_forward` request's wire string verbatim + the actually-bound port (the sshd `c->path` + `get_local_port` parity; a `port = 0` mint echoes the allocated number); originator = `TunnelForwardedParams.peer`; a stale/missing correlation token fails the open **typed at the call plane** (`channel:open_failed`, `unknown_resource`) before any channel is born (the gate's miss class); the narrow gate-pass→handler-run residual race and the cap-admission declines still teardown-at-birth (never a half-open channel); the accept is the crate's per-session channel cap (the acceptor vocabulary) | +| `direct-streamlocal` | unix-socket forward | `tunnel` | `SubstrateAddr::Path` treatment | compiles wherever `tunnel` compiles; serving is **runtime-Unix-gated**: on Unix it serves, on other platforms it is a clean open-decline (OQ-11 resolved: universal compile + runtime decline, no per-platform feature fork) | +| `x11` | X forwarding | none (never) | none | always declines; not feature-gated (ADR-003 §5) | +| `auth-agent` | agent forwarding | none (never) | none | always declines; the anti-pattern of the alkvault custody posture (ADR-003 §5) | +| `session` → SOCKS5-in-channel | (no vanilla use) | none (retired, ADR-003 §1a) | none | vanilla `-D` is client-side SOCKS + `direct-tcpip`; the family local door is alksocks composed at the assembly (see `client.md`) | ## Session-channel dispatch (ADR-006, as amended) @@ -47,64 +47,64 @@ parse path: `exec_request` parses first (a git-parseable command routes to `git`; anything else to `session`'s pipe path), and `pty_request`/`shell_request` route to `session`. Request-less session channels decline as unfeatured. Parsing happens before dispatch and -before any resource allocation — a leaked channel alloc is a +before any resource allocation: a leaked channel alloc is a capability leak, so **decline first, alloc never** (POC-3 stage A's decline matrix is the contract: unknown repo → collapsed generic -denial *before any protocol byte* — no existence oracle; non-git +denial *before any protocol byte*, no existence oracle; non-git command → parse-then-fail). ## Global-request rows (the `-R` listen flow) `Handler::tcpip_forward(address, &mut port) -> bool` + -`Handler::streamlocal_forward` (+ both cancel forms) — gate on +`Handler::streamlocal_forward` (+ both cancel forms), gated on `tunnel` (unfeatured declines both the listen requests and the data channels). The served flow (`w3-tunnel-listen`; the route-1 classes decided against alktunnels 0.4.1's recorded surfaces, the -establisher shape against 0.5.0 — the review-003 W2 adoption — with +establisher shape against 0.5.0 (the review-003 W2 adoption), with POC-3 §C.4 as the template): -- **The listen dial** — family-ward through the consumer surface: +- **The listen dial**, family-ward through the consumer surface: `TunnelListener::open(channel_client, TunnelParams {resource: , substrate: Tcp/Unix})`. The resource is built from the wire bind endpoint: the address string rides verbatim (the OpenSSH client normalizes the no-address case to `"localhost"` before it sends (`channel_rfwd_bind_host` parity), so the door passes through), with - the tcp port appended (`host:port`; `0` stays `0` — the OS-picking + the tcp port appended (`host:port`; `0` stays `0`, because the OS-picking is the fabric's bind step, whose bound report feeds the out-param); the unix path rides alone; whose - bind policy governs (wildcard vs loopback — the sshd `GatewayPorts` + bind policy governs (wildcard vs loopback, the sshd `GatewayPorts` posture) is the fabric's establisher/bind closure, assembly policy at the family's provider. The dial is bounded (`LISTEN_DIAL_TIMEOUT` = 10 s, matching alkcall's own establisher deadline) and awaited **inline** in the hook by wire necessity (the global request's answer + the `&mut u32` out-param are the hook's own; the fabric leg's answer doesn't ride this session's routing, so the stall is - bounded, not deadlock-class) — recorded as the flow's one inline + bounded, not deadlock-class); this is recorded as the flow's one inline serving step. - **The reply contract**: the minted `listen` token (alkcall ADR-049 - amendment 3 establisher field) is REQUIRED — a producer that minted + amendment 3 establisher field) is REQUIRED: a producer that minted no token (or no `bound` report) gets a declined listen with its fabric channel reaped immediately (a listen the door cannot correlate arrivals onto never answers success). The bound port writes the `&mut u32` out-param (`port = 0` → the actually-bound - number — sshd's `allocated_listen_port` reply parity; the + number; sshd's `allocated_listen_port` reply parity; the `client.md` lifecycle primitive). -- **The door-side arrival surface** — the fabric's bind-first accept +- **The door-side arrival surface**: the fabric's bind-first accept dispatcher (`make_bindfirst_accept_handler`) dials the `tunnel/forwarded` op **toward the door** per arrival (the `ForwardedOpener` closure); the door serves it through the alkcall - openable registered into the shared per-connection registry — the + openable registered into the shared per-connection registry, the serve-config's `TunnelListenSeam` (the `w3-seam-config` second course-correction: the assembly's `Arc` alone carries no inbound-op serving loop; the seam fronts the `ServingConfig.registry` the assembly wired at the leg's `from_connection_with_serving`). The registration runs at - `SshAdapter::new` (a failure is a failed construction — + `SshAdapter::new` (a failure is a failed construction, `AdapterError::ListenRegistration`); alkcall's own gates carry the per-identity cap + the `tunnel:forwarded` scope per arrival, then the crate's arrival pump takes over. -- **The arrival pump** — per the `forwarded-tcpip` row above; unix +- **The arrival pump**: per the `forwarded-tcpip` row above; unix substrate arrivals (a minted `streamlocal_forward` route) open `forwarded-streamlocal@openssh.com` with the path echoed (russh's server `Handle` carries the channel-open at this pin; the OQ-11 @@ -113,13 +113,13 @@ POC-3 §C.4 as the template): - **The cancel forms + bounded bookkeeping**: the per-session listener map (packet-loop-only, like the `channels` store) matches cancels by (the wire endpoint as the request carried it, the bound - port — sshd's `c->path == host && listening_port == port` parity; + port; the sshd `c->path == host && listening_port == port` parity; the unix forms by path alone) 1:1 into the family listener's - teardown (`TunnelListener::close`/`Drop` — the route leaves the + teardown (`TunnelListener::close`/`Drop`; the route leaves the shared router *before* the teardown races), `false` for an unknown pair. The listen count is a bounded resource: a dedicated counter against the same `Limits::max_concurrent_channels` ceiling (the - listener/channels mix stays unconstrained; both stay bounded — no + listener/channels mix stays unconstrained; both stay bounded, so no unbounded listener map). A session's teardown removes its tokens from the shared router (a fabric arrival after the session is gone is the correlation-miss class). @@ -129,21 +129,21 @@ POC-3 §C.4 as the template): | SSH request | Family mapping | |---|---| | `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{…}` (POC-3 §D.1: live `stty size` honored); ttymodes → the reserved modes JSON (OQ-44); a stored pty is consumed by the later `shell_request`/`exec_request` on the channel (`ssh -t host "cmd"`). A pre-serve `window_change_request` resizes the stored params (the final size wins) | -| `shell_request` | `NegotiateRequest.cmd` = the **assembly-supplied serve-config shell argv** verbatim (no crate default; unset → the serving-path decline — failure then close; never a fallback — OQ-08 resolved; never wire-supplied). **A shell request without a preceding pty serves pipe-mode (`tty = None`)** — vanilla sshd's posture; `ssh -T` is legitimate vanilla | -| `exec_request` | parse → git dispatch (`git`) or alktty pipe `cmd` = exec wrapper argv with the command string appended (`wrapper ++ [cmd]`, OQ-08 — vanilla quoting semantics, never naively split; wrapper unset → the serving-path decline — failure then close; never a fallback); unknown → `channel_failure`, no resource alloc | +| `shell_request` | `NegotiateRequest.cmd` = the **assembly-supplied serve-config shell argv** verbatim (no crate default; unset → the serving-path decline, failure then close; never a fallback, OQ-08 resolved; never wire-supplied). **A shell request without a preceding pty serves pipe-mode (`tty = None`)**, vanilla sshd's posture; `ssh -T` is legitimate vanilla | +| `exec_request` | parse → git dispatch (`git`) or alktty pipe `cmd` = exec wrapper argv with the command string appended (`wrapper ++ [cmd]`, OQ-08; vanilla quoting semantics, never naively split; wrapper unset → the serving-path decline, failure then close; never a fallback); unknown → `channel_failure`, no resource alloc | | `subsystem_request` | decline (per the rows table; honest, with reason) | -| `env_request` | **declined unconditionally** (OQ-09 resolved; no allowlist ships) — wire env is not family config | -| `window_change_request` | resize control through the serving bridge (POC-3 §D.1: live resize proven); fire-and-forget — no SSH reply, so a full/closed control surface drops the event | -| `signal` | signal control (alktty names verbatim — no door-side filter; PTY mode reaches the process group, pipe mode is pid-only — backend semantics, fidelity note, OQ-10); fire-and-forget, no decline shape | +| `env_request` | **declined unconditionally** (OQ-09 resolved; no allowlist ships); wire env is not family config | +| `window_change_request` | resize control through the serving bridge (POC-3 §D.1: live resize proven); fire-and-forget, no SSH reply, so a full/closed control surface drops the event | +| `signal` | signal control (alktty names verbatim, no door-side filter; PTY mode reaches the process group, pipe mode is pid-only; backend semantics and a fidelity note, OQ-10); fire-and-forget, no decline shape | | data / `extended_data(1)` | the translation bridge (rows table) | -| `exit_status` | from the alktty exit chunk — sent **before** close, from the spawned task via `Session::handle()` (POC-3 §D: proven both legs). Derivation contract rules 1, 3–6 below | -| `exit_signal` | negative exit-chunk codes → `exit-signal(name)` — derivation contract rule 2; `−1` → `exit-status(255)`, never a signal claim (rule 3). Both the pipe **and** the PTY path report signal deaths as `−N` (alktty 0.8.2 reaps the PTY child directly — the former portable-pty flattening constraint retired; rule 5) | +| `exit_status` | from the alktty exit chunk, sent **before** close, from the spawned task via `Session::handle()` (POC-3 §D: proven both legs). Derivation contract rules 1, 3–6 below | +| `exit_signal` | negative exit-chunk codes → `exit-signal(name)` (derivation contract rule 2); `−1` → `exit-status(255)`, never a signal claim (rule 3). Both the pipe **and** the PTY path report signal deaths as `−N` (alktty 0.8.2 reaps the PTY child directly; the former portable-pty flattening constraint is retired; rule 5) | ## Exit chunk → SSH exit reporting (the derivation contract) -The alktty exit chunk (`{"type":"exit","code":N}`, exit-chunk-is-last — +The alktty exit chunk (`{"type":"exit","code":N}`, exit-chunk-is-last; alktty ADR-004, alknet ADR-055 lineage) is the sole fidelity input; the -chunk is **not widened** (any widening needs its own alktty ADR — the +chunk is **not widened** (any widening needs its own alktty ADR; the convention is consumed as-is; OQ-10 resolved). Rules, normative for the `exit_status`/`exit_signal` rows above: @@ -151,7 +151,7 @@ convention is consumed as-is; OQ-10 resolved). Rules, normative for the 2. **Negative `−N`, N ≥ 2 (the Unix pipe path's signal encoding)** → `exit-signal(name)` via a Unix signal-number→name table (server-side work; signal semantics belong to the serving platform, where the - backend reaps). `core_dumped` is **never asserted** — alktty discards + backend reaps). `core_dumped` is **never asserted**: alktty discards `ExitStatus::core_dumped()`, so nothing better is knowable from the chunk, and SSH's `exit-signal` bool stays false. (POC-3 §D.2 observed the full derivation live: signal TERM → `−15` → `exit-signal("TERM")`.) @@ -159,7 +159,7 @@ convention is consumed as-is; OQ-10 resolved). Rules, normative for the alktty's could-not-determine sentinel collides with the SIGHUP encoding on the Unix pipe path, and this rule accepts the fidelity cost symmetrically: neither a fabricated signal for a wait failure - (a signal that never happened) nor a real one — a *genuine* SIGHUP + (a signal that never happened) nor a real one: a *genuine* SIGHUP death on the pipe path also reports 255, not `exit-signal("HUP")`. If the wait-failure vs SIGHUP distinction ever becomes load-bearing, alktty's additive `exit_error` control type is the extension seam @@ -174,24 +174,24 @@ convention is consumed as-is; OQ-10 resolved). Rules, normative for the PTY waiter skips portable-pty's conversion and reaps the direct child via `waitpid`, decoding signal deaths as `−N`); the PTY path now reports signal termination exactly like the pipe path, and the - derivation here needed **no contract change** — the negative-code - convention is consumed verbatim (OQ-10's upstream constraint + derivation here needed **no contract change**: the negative-code + convention is consumed verbatim (OQ-10's upstream constraint closed). Pipe-mode pid-only vs PTY process-group *scope* (the `signal` row note) is backend semantics, not a mapping detail. 6. **Unmapped negatives → rule 3.** The convention's producers are `−1` and `−signum` only; a negative value with no entry in the - signal-name table (defensive case — the mapping never invents a + signal-name table (defensive case: the mapping never invents a name) reports `exit-status(255)`. ## The structural constraint: spawn-and-return russh (this pin: ADR-002) runs server handler callbacks **inline -inside the session's packet loop** — a blocking handler suspends all -packet routing for the connection (channel data is routed by that -same loop; an inline serve deadlocks against the peer's immediate -follow-up bytes — POC-3 proved by construction with receive-pack). -Therefore: **every serving path is spawned; handlers return fast**; +inside the session's packet loop**, which means a blocking handler +suspends all packet routing for the connection (channel data is routed +by that same loop; an inline serve deadlocks against the peer's +immediate follow-up bytes, as POC-3 proved by construction with +receive-pack). Therefore: **every serving path is spawned; handlers return fast**; exit/status reporting from spawned tasks rides the cloned `Session::handle()`. This is a load-bearing correctness constraint of the whole spine, not an implementation choice. @@ -201,7 +201,7 @@ the whole spine, not an implementation choice. - russh knobs (`window_size`, `maximum_packet_size`, `channel_buffer_size`, `Limits`, `inactivity_timeout`, `keepalive_*`) + in-crate max-concurrent-channels cap + per-length - exec-parse limits — per the bounded-resources policy in + exec-parse limits, per the bounded-resources policy in `overview.md`. - alkcall bridge leg (when the family half is remote): channels-layer reassembly cap 1 MiB/channel; ADR-034 grants `alk/ssh` stream_types @@ -215,26 +215,26 @@ the whole spine, not an implementation choice. | Build | Serves | Declines | |---|---|---| -| none (default) | — | everything | +| none (default) | nothing | everything | | `git` | git-parseable exec | pty/shell, plain exec, all other channel types, all `session` requests | -| `session` | pty/shell/pipe-exec (requires the configured shell/exec wrapper — OQ-08; an unset field declines its row though the feature is on) | git-parseable exec (the `git` parse is its gate), other channel types | -| `tunnel` | direct/forwarded-tcpip, streamlocal (unix), `-R` listens + arrivals (the listen forms additionally require the configured `TunnelListenSeam` — an egress-only door declines just the `-R` forms) | session channels (auto-reject), all others | +| `session` | pty/shell/pipe-exec (requires the configured shell/exec wrapper, OQ-08; an unset field declines its row though the feature is on) | git-parseable exec (the `git` parse is its gate), other channel types | +| `tunnel` | direct/forwarded-tcpip, streamlocal (unix), `-R` listens + arrivals (the listen forms additionally require the configured `TunnelListenSeam`; an egress-only door declines just the `-R` forms) | session channels (auto-reject), all others | | any build + `sftp` slot | unchanged from the underlying build | `sftp` subsystems decline-with-reason until alkfs | -Every row is tested in *every* build it can appear in — the decline +Every row is tested in *every* build it can appear in: the decline paths are contract territory (AGENTS.md conventions 5–6), including under `--all-features`. ## Open Questions -None open or deferred — see [open-questions.md](open-questions.md) +None open or deferred; see [open-questions.md](open-questions.md) (all of OQ-01…13 resolved). Doc-local contract anchors: OQ-02 (session -dispatch, ADR-006 as amended — §above), OQ-08 (the shell argv / exec -wrapper serve config — `shell_request`/`exec_request` rows and the +dispatch, ADR-006 as amended, §above), OQ-08 (the shell argv / exec +wrapper serve config, the `shell_request`/`exec_request` rows and the `session→pty+shell` channel row), OQ-09 (the `env_request` row), -OQ-10 (§the derivation contract — formalized; the former upstream PTY +OQ-10 (§the derivation contract, now formalized; the former upstream PTY constraint is retired as of alktty 0.8.2, rule 5), OQ-11 (`direct-streamlocal` row + the listen-flow gate), -OQ-13 (the `sftp` rows — the decline placeholder is the shipped +OQ-13 (the `sftp` rows; the decline placeholder is the shipped behavior). ## References diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index 4d85604..0a57e90 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -15,114 +15,114 @@ resolutions only; the genuinely-upstream items it surfaced (the PTY exit limit, the alkvault zeroization gap, the alkfs dependency) are noted at the end and carry their work where their owners are. -## Theme A — wire & family topology +## Theme A: wire & family topology -### OQ-01: ~~Does an `alk/ssh` relay/browser-leg consumer emerge?~~ — resolved +### OQ-01: ~~Does an `alk/ssh` relay/browser-leg consumer emerge?~~ (resolved) - **Origin**: [mapping-table.md](../research/mapping-table.md) §topology, `vision.md` scope Q1 -- **Status**: resolved — ADR-001 is the entire decision surface; what remained was a forecast about the world, not an alkssh decision. -- **Resolution**: no relay/browser-leg consumer is in scope and no crate behavior is gated on one. ADR-001 decided everything decidable now: the `alk/ssh` pin's raw-stream contract (§1), payload-blind relay as part of the contract (§2), the phase-1 edge-termination-only scope (§3), and future-leg compatibility by construction (§4). Whether a consumer ever materializes is an assembly/family use-case question — a future leg arrives via its own ADR referencing ADR-001's already-fixed contract and would exercise `client.md`'s accept surface in a new role. Nothing in the crate waits on this. (Door-type note retained for the record: the pin is one-way; consumer *existence* was never an architecture decision.) +- **Status**: resolved; ADR-001 is the entire decision surface; what remained was a forecast about the world, not an alkssh decision. +- **Resolution**: no relay/browser-leg consumer is in scope and no crate behavior is gated on one. ADR-001 decided everything decidable now: the `alk/ssh` pin's raw-stream contract (§1), payload-blind relay as part of the contract (§2), the phase-1 edge-termination-only scope (§3), and future-leg compatibility by construction (§4). Whether a consumer ever materializes is an assembly/family use-case question: a future leg arrives via its own ADR referencing ADR-001's already-fixed contract and would exercise `client.md`'s accept surface in a new role. Nothing in the crate waits on this. (Door-type note retained for the record: the pin is one-way; consumer *existence* was never an architecture decision.) - **Cross-references**: ADR-001, OQ-13 -## Theme B — session-channel dispatch & mapping details +## Theme B: session-channel dispatch & mapping details -### OQ-02: ~~What is the session-channel dispatch shape (SOCKS-detect vs parse, and who bounds the window)?~~ — resolved +### OQ-02: ~~What is the session-channel dispatch shape (SOCKS-detect vs parse, and who bounds the window)?~~ (resolved) - **Origin**: [poc-3-mapping-bakeoff.md](../research/poc-3-mapping-bakeoff.md) §Stage B.3, `mapping-table.md` §notes - **Status**: resolved. Decided by ADR-006 point 3, then simplified by the ADR-003 §1a amendment: the `dynamic-forward` feature retired (no consumer is ever the family-native SOCKS-in-channel client; vanilla `-D` is client-side SOCKS + plain `direct-tcpip`; the family `-D` door is alksocks composed locally at the assembly), so the detect lost its owner. **Session dispatch is parse-only**; request-less session channels decline. POC-3 stage B survives as the feasibility record for re-adding a remote SOCKS door via explicit ADR. - **Cross-references**: ADR-006, ADR-003 §1a -### OQ-03: ~~Username disposition~~ — resolved +### OQ-03: ~~Username disposition~~ (resolved) - **Origin**: [poc-1-auth-bridge.md](../research/poc-1-auth-bridge.md) §2, `auth-bridge.md` §The mapping - **Status**: resolved -- **Resolution**: usernames are never the identity model and carry no routing semantics — the fingerprint is the sole resolution path (POC-1: `ssh anything@host` works with the same identity). Whether a username string appears in logs is deployment policy, outside the crate. Rides as the recorded contract in the `auth.md` spec / ADR-004. +- **Resolution**: usernames are never the identity model and carry no routing semantics; the fingerprint is the sole resolution path (POC-1: `ssh anything@host` works with the same identity). Whether a username string appears in logs is deployment policy, outside the crate. Rides as the recorded contract in the `auth.md` spec / ADR-004. -### OQ-04: ~~OpenSSH certificate auth — is there ever a CA-trust use case?~~ — resolved +### OQ-04: ~~OpenSSH certificate auth, is there ever a CA-trust use case?~~ (resolved) - **Origin**: `mapping-table.md` (auth rows), [poc-1-auth-bridge.md](../research/poc-1-auth-bridge.md) OQ seed 4 -- **Status**: resolved — the decline is the settled disposition; the "is there ever" remainder was a forecast. -- **Resolution**: `auth_openssh_certificate` is declined unconditionally (ADR-004 decision 3: explicit reject implementation, contract-tested, POC-1-proven). There is no CA-trust story in the family identity model — exactly one SSH-shaped resolution path exists (verified key fingerprint → `IdentityProvider::resolve_from_fingerprint` → `Identity`). A certificate trust model would first require the family identity model to gain one upstream (alkcall identity territory) and would then enter alkssh as a new ADR — an auth-posture change is wire-visible. Nothing in the crate waits on this; no CA use case exists. +- **Status**: resolved. The decline is the settled disposition; the "is there ever" remainder was a forecast. +- **Resolution**: `auth_openssh_certificate` is declined unconditionally (ADR-004 decision 3: explicit reject implementation, contract-tested, POC-1-proven). There is no CA-trust story in the family identity model: exactly one SSH-shaped resolution path exists (verified key fingerprint → `IdentityProvider::resolve_from_fingerprint` → `Identity`). A certificate trust model would first require the family identity model to gain one upstream (alkcall identity territory) and would then enter alkssh as a new ADR, since an auth-posture change is wire-visible. Nothing in the crate waits on this; no CA use case exists. - **Cross-references**: ADR-004, [auth.md](auth.md) §out-of-scope -## Theme C — auth, identity & key custody +## Theme C: auth, identity & key custody -### OQ-05: ~~What is the client-side host-key pin store?~~ — resolved (policy-seam shape) +### OQ-05: ~~What is the client-side host-key pin store?~~ (resolved, policy-seam shape) - **Origin**: [auth-bridge.md](../research/auth-bridge.md) §Host keys, `reverse-runner.md` §5 -- **Status**: resolved — the *seam* is decided; where learned pins persist is assembly detail (the crate takes a policy, not a pin store). +- **Status**: resolved. The *seam* is decided; where learned pins persist is assembly detail (the crate takes a policy, not a pin store). - **Resolution**: two basic client cases, one seam: - 1. **Known beforehand** — trust anchors exist. Family-built runners mint their host key at assembly time, so the expected fingerprint is known before first contact: assembly-supplied pins, **hard decline on mismatch** (no soft failure). - 2. **Unknown beforehand** — vanilla `sshd` on container fleets the assembly didn't provision; no anchor exists until first contact. The crate surfaces the presented host key (host, port, fingerprint) to an **assembly-provided `HostKeyPolicy`** that decides: prompt-and-record, provider-metadata match, learn-and-record, or accept-and-continue (the ephemeral-fleet shape — see below). The OpenSSH "add to known_hosts?" moment becomes an assembly policy rather than crate behavior. + 1. **Known beforehand**: trust anchors exist. Family-built runners mint their host key at assembly time, so the expected fingerprint is known before first contact: assembly-supplied pins, **hard decline on mismatch** (no soft failure). + 2. **Unknown beforehand**: vanilla `sshd` on container fleets the assembly didn't provision; no anchor exists until first contact. The crate surfaces the presented host key (host, port, fingerprint) to an **assembly-provided `HostKeyPolicy`** that decides: prompt-and-record, provider-metadata match, learn-and-record, or accept-and-continue (the ephemeral-fleet shape, see below). The OpenSSH "add to known_hosts?" moment becomes an assembly policy rather than crate behavior. - Crate rules that make this family-shaped: the crate's default policy is **fail-closed** (reject unknown, hard-decline mismatch — russh's own reject-all default, kept); the crate never decides trust itself and never hard-codes a trust model — accept-all is not a built-in crate option, but a permissive case-2 policy is the assembly's to choose where its fleet model calls for it (alkminer's accept-all for rental fleets is that shape, made deliberately: fresh container per rent → fresh host key, so no pin can exist beforehand and the security anchor is hub-held per-instance dial keys plus what rides the tunnels, not host pins). The contract lands in the `auth.md` spec (host-key policy section) and `client.md`'s dial surface. + Crate rules that make this family-shaped: the crate's default policy is **fail-closed** (reject unknown, hard-decline mismatch; russh's own reject-all default, kept); the crate never decides trust itself and never hard-codes a trust model, so accept-all is not a built-in crate option, but a permissive case-2 policy is the assembly's to choose where its fleet model calls for it (alkminer's accept-all for rental fleets is that shape, made deliberately: fresh container per rent → fresh host key, so no pin can exist beforehand and the security anchor is hub-held per-instance dial keys plus what rides the tunnels, not host pins). The contract lands in the `auth.md` spec (host-key policy section) and `client.md`'s dial surface. - **Cross-references**: OQ-07, `reverse-runner.md`, [poc-2-vault-keys.md](../research/poc-2-vault-keys.md) §5 (host keys ride the vault model) -### OQ-06: ~~One vault, many roles?~~ — resolved +### OQ-06: ~~One vault, many roles?~~ (resolved) - **Origin**: [poc-2-vault-keys.md](../research/poc-2-vault-keys.md) OQ seed 3 - **Status**: resolved - **Resolution**: not an alkssh constraint. The crate takes vault handles the assembly injects; whether the assembly runs separate vault instances per role (client keys vs host keys) is assembly policy. ADR-005 records the boundary: vault handle in, key material out, version semantics owned by the vault. -### OQ-07: ~~Is the alkvault `decrypt` plain-`String` zeroization gap closed at the vault or at the adapter?~~ — resolved (at this level) +### OQ-07: ~~Is the alkvault `decrypt` plain-`String` zeroization gap closed at the vault or at the adapter?~~ (resolved, at this level) - **Origin**: [poc-2-vault-keys.md](../research/poc-2-vault-keys.md) §2 + OQ seed 1 -- **Status**: resolved — the alkssh-level answer was the adapter-side wrap; the vault-side improvement belongs upstream, not here. **Superseded upstream 2026-11-04 (alkvault 0.2.0)**: the ask shipped — `decrypt` returns `Zeroizing`, zeroizing the buffer on every path including the error one, and the `rotate` path never holds a bare plaintext either. -- **Resolution**: **ADR-005 decision 3 was the contract on this path**: the adapter owned a `Zeroizing` wrap at the seam (POC-2-proven; bounds the plaintext from adapter-takeover onward; ssh-key zeroizes the decoded key bytes on drop). The remaining unfenced pre-wrap window was **alkvault's API shape to close** — the ask was filed in alkvault's own tracker (downstream/family-space concern; alkforge and every secrets-bearing crate benefits, not just alkssh). alkssh did not carry it, did not gate on it, and ADR-005 recorded only the adapter obligation. With alkvault 0.2.0 the window is closed upstream: the adapter-side wrap is dropped (a double wrap would be dead posture), `decrypt_and_decode` consumes the vault's zeroized return directly, and ADR-005's decision-3 pointer is amended — the predicted one-line doc touch, exactly. +- **Status**: resolved. The alkssh-level answer was the adapter-side wrap; the vault-side improvement belongs upstream, not here. **Superseded upstream 2026-11-04 (alkvault 0.2.0)**: the ask shipped, `decrypt` returns `Zeroizing`, zeroizing the buffer on every path including the error one, and the `rotate` path never holds a bare plaintext either. +- **Resolution**: **ADR-005 decision 3 was the contract on this path**: the adapter owned a `Zeroizing` wrap at the seam (POC-2-proven; bounds the plaintext from adapter-takeover onward; ssh-key zeroizes the decoded key bytes on drop). The remaining unfenced pre-wrap window was **alkvault's API shape to close**; the ask was filed in alkvault's own tracker (downstream/family-space concern; alkforge and every secrets-bearing crate benefits, not just alkssh). alkssh did not carry it, did not gate on it, and ADR-005 recorded only the adapter obligation. With alkvault 0.2.0 the window is closed upstream: the adapter-side wrap is dropped (a double wrap would be dead posture), `decrypt_and_decode` consumes the vault's zeroized return directly, and ADR-005's decision-3 pointer is amended (the predicted one-line doc touch, exactly). - **Cross-references**: ADR-005 §3, OQ-05 -## Theme D — session-request mapping details +## Theme D: session-request mapping details -### OQ-08: ~~Shell command convention for `shell_request`~~ — resolved +### OQ-08: ~~Shell command convention for `shell_request`~~ (resolved) - **Origin**: `mapping-table.md` (shell row), [poc-3-mapping-bakeoff.md](../research/poc-3-mapping-bakeoff.md) OQ seed 3 -- **Status**: resolved — assembly-supplied serve-config field; no crate default. -- **Resolution**: the shell convention is **assembly-supplied serve configuration** — the crate bakes no default, and each unset field means its request **declines with a clean error** (never a silent fallback). Grounding for the shape: alktty's contract requires a non-empty `cmd` vector (there is no pass-through-absence seam at `drive_session`), the wire supplies nothing (`shell_request` carries no command), and the crate is not an sshd — it has no account/passwd model from which "the user's shell" could be derived, so a crate-level default (`bash`, `sh`) would be deployment policy smuggled into the crate (the composability boundary, `overview.md`). Two fields, each governing its own row: +- **Status**: resolved; assembly-supplied serve-config field, no crate default. +- **Resolution**: the shell convention is **assembly-supplied serve configuration**: the crate bakes no default, and each unset field means its request **declines with a clean error** (never a silent fallback). Grounding for the shape: alktty's contract requires a non-empty `cmd` vector (there is no pass-through-absence seam at `drive_session`), the wire supplies nothing (`shell_request` carries no command), and the crate is not an sshd and has no account/passwd model from which "the user's shell" could be derived, so a crate-level default (`bash`, `sh`) would be deployment policy smuggled into the crate (the composability boundary, `overview.md`). Two fields, each governing its own row: - 1. **shell argv** — what `shell_request` runs verbatim as `NegotiateRequest.cmd`. - 2. **exec wrapper argv** (e.g. `["/bin/sh", "-c"]`) — what a non-git `exec_request` translates to: the wire's single command string is **appended as the wrapper argv's final element** (`wrapper ++ [cmd]`), preserving vanilla `ssh host "cmd"` quoting/expansion semantics — command strings are never naively split (that would break quoting). The wrapper form is platform-neutral: the assembly configures whatever its fleet's shell is. + 1. **shell argv**: what `shell_request` runs verbatim as `NegotiateRequest.cmd`. + 2. **exec wrapper argv** (e.g. `["/bin/sh", "-c"]`): what a non-git `exec_request` translates to. The wire's single command string is **appended as the wrapper argv's final element** (`wrapper ++ [cmd]`), preserving vanilla `ssh host "cmd"` quoting/expansion semantics; command strings are never naively split (that would break quoting). The wrapper form is platform-neutral: the assembly configures whatever its fleet's shell is. - Unset field → its row declines; a `session` build with no configured shell serves neither shell nor exec (fail-closed — no half-capability where the feature is on). The git row is unaffected (parse to `alkgit` first — it never needs a shell). `cwd`/`env` in `NegotiateRequest` are assembly-supplied serve-config values, never wire-derived (`env_request` stays declined — OQ-09). Recorded on `mapping.md`'s `shell_request`/`exec_request` rows. + Unset field → its row declines; a `session` build with no configured shell serves neither shell nor exec (fail-closed, no half-capability where the feature is on). The git row is unaffected (parse to `alkgit` first, since it never needs a shell). `cwd`/`env` in `NegotiateRequest` are assembly-supplied serve-config values, never wire-derived (`env_request` stays declined, OQ-09). Recorded on `mapping.md`'s `shell_request`/`exec_request` rows. - **Cross-references**: OQ-09, [mapping.md](mapping.md) -### OQ-09: ~~`env_request` policy — is an allowlist ever wanted?~~ — resolved +### OQ-09: ~~`env_request` policy, is an allowlist ever wanted?~~ (resolved) - **Origin**: `mapping-table.md` (env_request row) -- **Status**: resolved — decline-by-default is the fixed contract; no allowlist ships. -- **Resolution**: `env_request` is declined **unconditionally**. Wire-supplied environment is not family config: session env comes from assembly serve configuration (`NegotiateRequest.env`), never from the wire. The only open shape was whether an allowlist ships — it does not; a future allowlist would be a new ADR (a serving capability is wire-visible) on a concrete consumer need, of which none exists. +- **Status**: resolved. Decline-by-default is the fixed contract; no allowlist ships. +- **Resolution**: `env_request` is declined **unconditionally**. Wire-supplied environment is not family config: session env comes from assembly serve configuration (`NegotiateRequest.env`), never from the wire. The only open shape was whether an allowlist ships; it does not, and a future allowlist would be a new ADR (a serving capability is wire-visible) on a concrete consumer need, of which none exists. - **Cross-references**: OQ-08, [mapping.md](mapping.md) (`env_request` row) -### OQ-10: ~~SSH `exit-signal` fidelity over the alktty exit-code convention~~ — resolved (closed as contract + upstream constraint) +### OQ-10: ~~SSH `exit-signal` fidelity over the alktty exit-code convention~~ (resolved: closed as contract + upstream constraint) -- **Origin**: `mapping-table.md` (exit_signal row), [poc-3-mapping-bakeoff.md](../research/poc-3-mapping-bakeoff.md) §D.2. **Premise corrected 2026-10-27** (with the alktty side): the POC seed's "the exit chunk carries no signal identity — alktty might widen the chunk" framing was wrong; the negative code *is* the signal encoding. Integrated into the seed rather than argued after the fact. -- **Status**: resolved — as asked, this was never a decision to make: the convention alkssh consumes does not change (alktty's exit-chunk convention, alktty ADR-004 / alknet ADR-055 lineage — no widening proposed or justified; "easier for alkssh" is not a tty/pty-grounded justification, and any widening ripples `ControlMessage::Exit`'s parsed shape, the backend API, and that ADR lineage, so it needs its own alktty ADR). What the entry surfaced was (a) a *derivation contract* on alkssh's side and (b) an upstream *fidelity limitation* — both now recorded where they constrain. +- **Origin**: `mapping-table.md` (exit_signal row), [poc-3-mapping-bakeoff.md](../research/poc-3-mapping-bakeoff.md) §D.2. **Premise corrected 2026-10-27** (with the alktty side): the POC seed's "the exit chunk carries no signal identity (alktty might widen the chunk)" framing was wrong; the negative code *is* the signal encoding. Integrated into the seed rather than argued after the fact. +- **Status**: resolved. As asked, this was never a decision to make: the convention alkssh consumes does not change (alktty's exit-chunk convention, alktty ADR-004 / alknet ADR-055 lineage; no widening proposed or justified). "Easier for alkssh" is not a tty/pty-grounded justification, and any widening ripples `ControlMessage::Exit`'s parsed shape, the backend API, and that ADR lineage, so it needs its own alktty ADR. What the entry surfaced was (a) a *derivation contract* on alkssh's side and (b) an upstream *fidelity limitation*, both now recorded where they constrain. - **Resolution**: - **The derivation contract** is formalized as `mapping.md` §"Exit chunk → SSH exit reporting" (the contract behind the `exit_status`/`exit_signal` rows): `code ≥ 0` → `exit-status(N)`; negative `−N` on the Unix pipe path → `exit-signal(name)` via a server-side Unix signal-number→name table; `−1` → `exit-status(255)` and never a signal claim (the sentinel collides with SIGHUP; fabricating `exit-signal("HUP")` for a wait failure invents a signal that never happened); non-Unix negatives → the `−1` rule; `core_dumped` never asserted. POC-3 §D.2 observed the pipe-path derivation live (`−15` → `exit-signal("TERM")`). - - **The upstream constraint** (superseded 2026-11-04, alktty 0.8.2): the PTY path used to lose the signal number (portable-pty 0.9 reaps a signal-terminated Unix child as a plain code 1 through its trait surface — verified against its source). alktty 0.8.2 retired that constraint consumer-input-driven (alktty review 004, this entry's bookkeeping): its Unix PTY waiter now reaps the direct child via `waitpid` and decodes signal deaths as `−N`, so the convention holds on both paths and the PTY-row signal-terminated session reports `exit-signal(name)` with **no alkssh contract change** (the negative code is consumed verbatim; `mapping.md` rule 5 amended). The `-1` sentinel collision (SIGHUP vs wait-failure) remains on the pipe path and gains the cancel path — the rule-3 reading stands unchanged. + - **The upstream constraint** (superseded 2026-11-04, alktty 0.8.2): the PTY path used to lose the signal number (portable-pty 0.9 reaps a signal-terminated Unix child as a plain code 1 through its trait surface; verified against its source). alktty 0.8.2 retired that constraint consumer-input-driven (alktty review 004, this entry's bookkeeping): its Unix PTY waiter now reaps the direct child via `waitpid` and decodes signal deaths as `−N`, so the convention holds on both paths and the PTY-row signal-terminated session reports `exit-signal(name)` with **no alkssh contract change** (the negative code is consumed verbatim; `mapping.md` rule 5 amended). The `-1` sentinel collision (SIGHUP vs wait-failure) remains on the pipe path and gains the cancel path; the rule-3 reading stands unchanged. - **Cross-references**: OQ-08, [mapping.md](mapping.md) (derivation contract + `exit_signal`/`exit_status`/`signal` rows), ADR-006, alktty ADR-004 / alknet ADR-055 -### OQ-11: ~~`direct-streamlocal` platform surface — compile-time or runtime decline shape?~~ — resolved +### OQ-11: ~~`direct-streamlocal` platform surface, compile-time or runtime decline shape?~~ (resolved) - **Origin**: `mapping-table.md` (streamlocal row) -- **Status**: resolved — universal compile, runtime decline. -- **Resolution**: `direct-streamlocal` compiles wherever `tunnel` compiles; the serve path is **runtime-Unix-gated** — on Unix it serves (`SubstrateAddr::Path` treatment), on every other platform it declines with a clean SSH open-failure (never a half-open). This wins over a unix-only compile split: no per-platform feature fork (the feature graph stays flat and platform-independent — ADR-003), the decline matrix stays uniform across builds, and the decline path is contract-tested in every build like every other decline (fail-closed posture). The research row's "compile-time feature + runtime decline" hybrid collapses to the runtime-decline half — no platform-conditional feature is needed. +- **Status**: resolved: universal compile, runtime decline. +- **Resolution**: `direct-streamlocal` compiles wherever `tunnel` compiles; the serve path is **runtime-Unix-gated** (on Unix it serves with the `SubstrateAddr::Path` treatment, on every other platform it declines with a clean SSH open-failure, never a half-open). This wins over a unix-only compile split: no per-platform feature fork (the feature graph stays flat and platform-independent, ADR-003), the decline matrix stays uniform across builds, and the decline path is contract-tested in every build like every other decline (fail-closed posture). The research row's "compile-time feature + runtime decline" hybrid collapses to the runtime-decline half; no platform-conditional feature is needed. - **Cross-references**: ADR-003, [mapping.md](mapping.md) (`direct-streamlocal` row + the `-R` listen flow) -### OQ-12: ~~Client reconnect: does the crate own forward/session restoration?~~ — resolved +### OQ-12: ~~Client reconnect: does the crate own forward/session restoration?~~ (resolved) - **Origin**: `reverse-runner.md` §4 - **Status**: resolved -- **Resolution**: the crate owns lifecycle primitives only — bound-port handles (forward listeners return the actually-bound port, per russh's `tcpip_forward -> u32`), explicit cancellable forward/session handles, and clean disconnects. Reconnect-and-restore policy (rebinding persisted tunnel ports per alkminer's flow) is assembly-owned. Recorded as a constraint in `client.md` §Lifecycle primitives; no reconnect policy is baked into the crate. +- **Resolution**: the crate owns lifecycle primitives only: bound-port handles (forward listeners return the actually-bound port, per russh's `tcpip_forward -> u32`), explicit cancellable forward/session handles, and clean disconnects. Reconnect-and-restore policy (rebinding persisted tunnel ports per alkminer's flow) is assembly-owned. Recorded as a constraint in `client.md` §Lifecycle primitives; no reconnect policy is baked into the crate. - **Cross-references**: [client.md](client.md), `reverse-runner.md` -## Theme E — future features +## Theme E: future features -### OQ-13: ~~The `sftp` client+server halves when alkfs lands~~ — resolved +### OQ-13: ~~The `sftp` client+server halves when alkfs lands~~ (resolved) - **Origin**: `mapping-table.md` (sftp rows), `reverse-runner.md`, POC-2 scope notes -- **Status**: resolved — the alkssh-side answer is fully decided; the remainder belongs to alkfs, not to this tracker. -- **Resolution**: the `sftp` feature ships as the **tested decline placeholder in both directions** from the first build — the feature exists in the graph (ADR-003), advertisement is honest, and the decline contract is tested on both sides (`mapping.md`/`client.md`). That *is* the shipped behavior for as long as alkfs is pending, and it needs no further decision. What remained "pending" was the eventual alkfs-protocol mapping — a decision downstream of alkfs' design (family ADR-002; alkssh follows alkfs' maturity): alkssh writes that mapping and its ADR when the target protocol exists to map, the same way every other row's mapping was decided before implementation. No alkssh decision waits on this; there is no alkssh-side sftp work. +- **Status**: resolved. The alkssh-side answer is fully decided; the remainder belongs to alkfs, not to this tracker. +- **Resolution**: the `sftp` feature ships as the **tested decline placeholder in both directions** from the first build: the feature exists in the graph (ADR-003), advertisement is honest, and the decline contract is tested on both sides (`mapping.md`/`client.md`). That *is* the shipped behavior for as long as alkfs is pending, and it needs no further decision. What remained "pending" was the eventual alkfs-protocol mapping, a decision downstream of alkfs' design (family ADR-002; alkssh follows alkfs' maturity): alkssh writes that mapping and its ADR when the target protocol exists to map, the same way every other row's mapping was decided before implementation. No alkssh decision waits on this; there is no alkssh-side sftp work. - **Cross-references**: OQ-01, ADR-003, [client.md](client.md) §op rows, family ADR-002 ## Upstream watch items (not OQs) @@ -130,14 +130,14 @@ noted at the end and carry their work where their owners are. Recorded so the dependency is visible, with the work carried where their owners are: -- **alkfs landing** — the `sftp` placeholder's eventual replacement +- **alkfs landing**: the `sftp` placeholder's eventual replacement (family ADR-002); alkssh writes the sftp mapping + ADR when the protocol exists (OQ-13). -- ~~**PTY-path exit fidelity**~~ — closed upstream (alktty 0.8.2, +- ~~**PTY-path exit fidelity**~~: closed upstream (alktty 0.8.2, review 004's resolution): the PTY waiter now reports signal deaths as `−N`; the derivation contract needed no alkssh change (`mapping.md` rule 5 amended; OQ-10). -- ~~**alkvault `Zeroizing`-out `decrypt`**~~ — closed upstream +- ~~**alkvault `Zeroizing`-out `decrypt`**~~: closed upstream (alkvault 0.2.0, review 001's resolution; also closes the `rotate` window and zeroizes the error path): the adapter seam wrap is dropped, ADR-005 decision 3 + OQ-07 amended as the predicted diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index c2ffa5f..ac33b35 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -3,7 +3,7 @@ status: reviewed last_updated: 2026-10-28 --- -# Overview — alkssh +# Overview: alkssh ## What this crate is @@ -13,18 +13,18 @@ bridges to/from family protocols. It is **a door in both directions, vanilla on the outside** (vision principle 2; ADR framing in `mapping.md` / `client.md`): -- **Server direction** — a door *for vanilla clients*: vanilla `ssh`, +- **Server direction**: a door *for vanilla clients*. Vanilla `ssh`, `git`, and (never for us, by design) sftp tooling connect as they would to any sshd; each accepted SSH channel/request is mapped onto exactly one family protocol seam and exactly one cargo feature. Primary current purpose: the alkforge git door. -- **Client direction** — a door *to vanilla servers*: a family app +- **Client direction**: a door *to vanilla servers*. A family app (the reverse-runner hub) dials vanilla `sshd` (rented fleets, runpod/vast.ai-style boxes) and the ops map family-side onto alktty/alktunnels/alkgit types; the runner stays vanilla by design. The crate's substance is glue and mapping; no SSH framing is authored -in-family (family ADR-003 decision 1) — russh owns the wire, and the +in-family (family ADR-003 decision 1); russh owns the wire, and the pin is ADR-002. ## Position in the family @@ -35,17 +35,17 @@ pin is ADR-002. | SSH channel-type → family-protocol mapping | **this crate** (the spine, `mapping.md` / `client.md`) | | Identity (fingerprint → `Identity`) | alkcall `IdentityProvider`, assembly-implemented; never the vault (vault = secrets *we* hold) | | Secrets we hold (client dial keys, host keys) | alkvault-backed sources (ADR-005) | -| The `alk/ssh` ALPN | ADR-001 (edge-termination contract; relay/browser legs have no consumer in scope — OQ-01 resolved) | +| The `alk/ssh` ALPN | ADR-001 (edge-termination contract; relay/browser legs have no consumer in scope, OQ-01 resolved) | | Deployment policy (which features, which ACLs) | the embedding assembly (alkforge) | -Composability boundary: alkssh is a door, not a backend — it depends +Composability boundary: alkssh is a door, not a backend. It depends on alkcall types at the wire layer and the mapped crates' session or client surfaces, and carries no deployment policy. A downstream app embeds it, enables features (ADR-003), brings identity/ACL config. ## Public surface (categories) -The crate is a library (`[lib]`, no binary — non-goal); the surface +The crate is a library (`[lib]`, no binary, a stated non-goal); the surface categories the phase-1 specs define: - **Server stack** (`mapping.md`): serve configuration (feature-gated @@ -55,7 +55,7 @@ categories the phase-1 specs define: - **Client stack** (`client.md`): dial surface (address, key source, host-key policy, keepalive/timeout config), the op-row consumers (session exec/pty, tunnel dial, `-R` listen + acceptor), and - lifecycle primitives (bound-port handles, cancel — OQ-12: no + lifecycle primitives (bound-port handles, cancel; OQ-12: no reconnect policy is owned). - **Key sources** (`auth.md`): vault-backed source construction per ADR-005 for both directions (client keys *and* host keys). @@ -63,7 +63,7 @@ categories the phase-1 specs define: at the server; the identity→seam propagation for mapped channels. Feature graph (ADR-003): `session`, `git`, `tunnel`, `sftp` -(placeholder) — flat, independent, `default = []` (no capabilities), +(placeholder); flat, independent, `default = []` (no capabilities), two-direction symmetry, fail-closed declines contract-tested on every side of every feature. @@ -82,17 +82,17 @@ surface notes): | Keepalives (client direction, cloud fleets) | `keepalive_interval`/`keepalive_max` | 15 s / 3 (reverse-runner evidence: NAT'd runners) | | Concurrent channels per session | ours (in-crate) + per-identity policy when bridged | ours to cap | -No unbounded loops, no unbounded buffers — the fail-closed declines +No unbounded loops, no unbounded buffers; the fail-closed declines plus the knobs above are the whole posture. ## Non-goals Per `vision.md`: no in-family SSH protocol machinery (ever); not an sshd (no local accounts, no OpenSSH `authorized_keys` semantics, no -PAM); no sftp serving until alkfs lands (OQ-13 resolved — the feature +PAM); no sftp serving until alkfs lands (OQ-13 resolved; the feature exists as a tested declining placeholder); no binary/CLI; no password or keyboard-interactive auth in either direction; no x11 or agent -forwarding at all (not even feature-gated — ADR-003 §5). +forwarding at all (not even feature-gated, ADR-003 §5). ## Lifecycle note @@ -101,13 +101,13 @@ This crate follows the family crate template (alktty root pattern): API = crate-root re-exports. The dependency set (russh per ADR-002, alkcall types, per-feature sibling deps: alktty/alktunnels/alkgit) and the manifest (`rust-version` 1.89, edition 2024) land at -implementation start — ADR-002 records the manifest decisions. +implementation start; ADR-002 records the manifest decisions. ## References - [README.md](README.md) (index), [open-questions.md](open-questions.md) (OQ-01…13) - ADRs 001–006 in [decisions/](decisions/) -- `docs/research/` — vision, POC findings (1/2/3), russh inventory, +- `docs/research/`: vision, POC findings (1/2/3), russh inventory, auth bridge, mapping table (validated), reverse-runner extraction - Family ADR-003 (the structural decision), family ADR-002 (alkforge; why exclusion matters) \ No newline at end of file diff --git a/docs/plans/implementation.md b/docs/plans/implementation.md index 13b932e..60166a9 100644 --- a/docs/plans/implementation.md +++ b/docs/plans/implementation.md @@ -1,11 +1,11 @@ --- status: closed last_updated: 2026-10-30 (the plan closed at the publish of alkssh - 0.1.0 — tag `alkssh-v0.1.0`; waves 1–5 decomposed + implemented + + 0.1.0, tag `alkssh-v0.1.0`; waves 1–5 decomposed + implemented + gated 2026-10-28/30) --- -# alkssh — Implementation plan +# alkssh: Implementation plan Wave-based decomposition of the architecture (`docs/architecture/`) into units of implementable work. The sibling pattern proven on @@ -15,7 +15,7 @@ shape of later ones (wave 1's config/error/key-source types are what every later task implements against; wave 2's server stack is what every mapping composes into), and decomposing only the next wave at a time keeps each session's task set reviewable and lets later -decompositions absorb earlier waves' course corrections — reviews +decompositions absorb earlier waves' course corrections: reviews sometimes change things, and tasks downstream then have to be edited, so decomposing too far ahead is paid rework. @@ -26,16 +26,16 @@ branches from stepping on each other). Wave boundaries are also review boundaries. Each wave's section here carries a **decomposition record** added when that wave actually decomposes (not written upfront). -Scope authority: **v0.1.0 publishes the crate** — the complete +Scope authority: **v0.1.0 publishes the crate**: the complete two-direction door (server stack + all mappings, client stack + op rows, key sources, the decline matrix contract-tested per build). The sftp decline placeholder *is* shipped behavior (OQ-13 resolved). **The assembly stays out of these waves**: alkforge's mounting of `SshAdapter::handle` at an alkcall `channel/open` boundary, its feature/ACL selection, its `IdentityProvider` and serve-config values -are downstream composition work — the crate ships the door, not the +are downstream composition work; the crate ships the door, not the deployment. No binary, no workspace split (single crate, the alktty -root pattern; no tracks — one crate, one publish). +root pattern; no tracks: one crate, one publish). ## The waves @@ -44,69 +44,69 @@ mappings ∥ client direction) → validation & release. | Wave | Contents | Depends on | Status | |---|---|---|---| -| [1](#wave-1--foundations) | Manifest landing (russh pin, feature graph); error model; config surface (serve-config + dial-config + `Limits`); vault-backed key sources; host-key policy seam; fingerprint; test doubles | — | decomposed | -| [2](#wave-2--the-server-stack) | `SshAdapter::handle` mount + `Config` construction; the auth bridge; parse-only dispatch + fail-closed decline core; spawn-and-return plumbing; bounded resources; loopback harness | wave 1 | decomposed | -| [3](#wave-3--server-mappings) | session → alktty (translation bridge + exit derivation); git → alkgit (exec grammar + dispatch); tunnel → alktunnels route 1; sftp placeholder declines | wave 2 | decomposed | -| [4](#wave-4--client-direction) | Dial surface; op rows (exec/pty, direct-tcpip, git exec); the `-R` leg (listen + acceptor registry); lifecycle primitives | wave 1 | decomposed | -| [5](#wave-5--validation--release) | Decline-matrix contract suite; real-tool e2e; fuzzing; docs; **the publish** | waves 3 + 4 | published | +| [1](#wave-1-foundations) | Manifest landing (russh pin, feature graph); error model; config surface (serve-config + dial-config + `Limits`); vault-backed key sources; host-key policy seam; fingerprint; test doubles | none | decomposed | +| [2](#wave-2-the-server-stack) | `SshAdapter::handle` mount + `Config` construction; the auth bridge; parse-only dispatch + fail-closed decline core; spawn-and-return plumbing; bounded resources; loopback harness | wave 1 | decomposed | +| [3](#wave-3-server-mappings) | session → alktty (translation bridge + exit derivation); git → alkgit (exec grammar + dispatch); tunnel → alktunnels route 1; sftp placeholder declines | wave 2 | decomposed | +| [4](#wave-4-client-direction) | Dial surface; op rows (exec/pty, direct-tcpip, git exec); the `-R` leg (listen + acceptor registry); lifecycle primitives | wave 1 | decomposed | +| [5](#wave-5-validation--release) | Decline-matrix contract suite; real-tool e2e; fuzzing; docs; **the publish** | waves 3 + 4 | published | -## Wave 1 — Foundations +## Wave 1: Foundations The contract surface in code, engine-blind: everything later waves implement *against*, and nothing that needs a live SSH session to test. The alkssh analog of alkgit's seam wave. -- **Manifest landing** (ADR-002) — the dependency set lands with +- **Manifest landing** (ADR-002): the dependency set lands with implementation start: `russh = "0.65"` with `aws-lc-rs` (no `flate2`, - no `rsa` — the tree RC-free except russh's own `ssh-key` pin), + no `rsa`; the tree RC-free except russh's own `ssh-key` pin), `alkcall 0.10` (identity/ACL types at the wire layer), `alkvault 0.1` (the custody model), plus the standing toolkit (`tokio`, `thiserror`, `tracing`, `parking_lot`, `zeroize`). `edition = "2024"`, `rust-version = "1.89"`. The ADR-003 feature graph is declared from the first commit: `session`, `git`, `tunnel`, `sftp`, `default = []` - — features gate nothing yet (the decline core references them from + `default = []`: features gate nothing yet (the decline core references them from wave 2); the per-feature sibling deps (`alktty` under `session`, `alktunnels` under `tunnel`, `alkgit` under `git`) land with their first consuming wave, not as dead manifest entries. All family deps pinned by version, never sibling-checkout paths; pins verified against crates.io before committing. -- **Error model** — the crate's `thiserror` taxonomy, fail-closed with +- **Error model**: the crate's `thiserror` taxonomy, fail-closed with no catch-alls: the key-source failure matrix as distinct variants - (ADR-005 decision 4 — locked / absent / decryption failure / unknown + (ADR-005 decision 4: locked / absent / decryption failure / unknown `key_version`, plus decode failure; the vault's taxonomy reshaped, never collapsed). Auth declines are *dispositions* (`Auth::Reject`), not errors; wire decline reasons are wave-2 shaped. What the config surface needs beyond this lands with it. -- **Config surface** — the pure configuration types both directions +- **Config surface**: the pure configuration types both directions implement against: the **serve-config** (feature wiring; OQ-08's two - assembly-supplied fields — shell argv and exec wrapper argv, each + assembly-supplied fields: shell argv and exec wrapper argv, each `Option`, unset → its row declines, never a fallback; the `IdentityProvider` handle; the host-key source handle; the tunnel `ChannelClient` handle; the `Limits`), the **dial-config** (address, key source, `HostKeyPolicy` handle, keepalive/inactivity/window - knobs), and **`Limits`** — the bounded-resources budget model + knobs), and **`Limits`**, the bounded-resources budget model (`overview.md`'s table: in-crate max-concurrent-channels cap, exec-parse length caps, and the russh knob mapping with the - documented default postures — `max_auth_attempts` 10 sized for the + documented default postures: `max_auth_attempts` 10 sized for the counting `none`-probe, keepalives 15 s / 3 client-side per the reverse-runner evidence). -- **Vault-backed key sources** (ADR-005) — the custody model in code: +- **Vault-backed key sources** (ADR-005): the custody model in code: decrypt-at-use (`vault.decrypt` → `Zeroizing` wrap at the adapter seam → one-time `decode_secret_key(plain, None)`), the client dial-key source and the server host-key load path (decrypt-at-construction; locked vault → no server constructed), `EncryptedData` opaque to the crate, plaintext never persists, every failure mode mapped to its distinct error variant. -- **Host-key policy seam** (OQ-05) — the `HostKeyPolicy` trait the +- **Host-key policy seam** (OQ-05): the `HostKeyPolicy` trait the client dial calls with (host, port, presented fingerprint): case-1 pins (hard-decline on mismatch) and case-2 assembly policies are the implementor's; the crate ships the fail-closed default (reject - unknown) and is otherwise policy-blind — no accept-all built-in. -- **Fingerprint** (ADR-004) — the `SHA256:` + unpadded base64 + unknown) and is otherwise policy-blind; no accept-all built-in. +- **Fingerprint** (ADR-004): the `SHA256:` + unpadded base64 computation via the `ssh-key` re-export (`HashAlg::Sha256`); the string format is a contract invariant, proven against independent issuers (`ssh-keygen -lf` vectors, the POC-1 §1 checkpoint shape). -- **Test doubles** — the in-memory seams every later wave's contract +- **Test doubles**: the in-memory seams every later wave's contract tests run against: an in-memory `IdentityProvider` (registered fingerprints → identities), an in-memory vault (encrypt/decrypt round-trip + the failure postures), an in-memory `HostKeyPolicy`, @@ -123,10 +123,10 @@ across both directions. The gate reads the landed surface against ADR-002/003/004/005 and the OQ-08/05 resolutions line by line. **Decomposition record** (2026-10-28): wave 1 decomposed into eight -task files under `tasks/implementation/` (flat — one track, no +task files under `tasks/implementation/` (flat, one track, no per-track subdir). Shape: `w1-manifest` first (the ADR-002 dependency landing + the ADR-003 feature graph declaration everything lands -inside); then four independent foundations in parallel — +inside); then four independent foundations in parallel: `w1-error-model` (the `KeySourceError` five-variant taxonomy), `w1-fingerprint` (the contract-invariant computation + parity vectors), `w1-key-sources` (the ADR-005 custody chain, over manifest + @@ -140,7 +140,7 @@ gate) depending on all seven. Interpretations recorded (flagged for the wave-1 gate): - **Per-feature sibling deps land with their first consuming wave**, - not in the wave-1 manifest — `alktty`/`alktunnels`/`alkgit` would be + not in the wave-1 manifest: `alktty`/`alktunnels`/`alkgit` would be dead manifest entries in a build where the features gate nothing yet; the wave-1 manifest lands russh + alkcall + alkvault + the toolkit only. The feature *names* are declared from the first @@ -152,25 +152,25 @@ Interpretations recorded (flagged for the wave-1 gate): shaped. - **The policy verdict type** (bool vs a richer reason-carrying verdict) is pinned in `w1-host-key-policy` against the russh - `check_server_key` hook shape at this pin — flagged for the gate. + `check_server_key` hook shape at this pin; flagged for the gate. - **The vault-double shape** (wrap alkvault's in-memory construction if it ships one, else a local double behind the source trait) is - pinned in `w1-test-doubles` — flagged for the gate. + pinned in `w1-test-doubles`; flagged for the gate. -## Wave 2 — The server stack +## Wave 2: The server stack -The russh server substrate every mapping composes into — the door -frame, before any door is hung: +The russh server substrate every mapping composes into (the door +frame, before any door is hung): -- **`SshAdapter::handle` + `Config` construction** (ADR-001/002) — the +- **`SshAdapter::handle` + `Config` construction** (ADR-001/002): the mount point ADR-001 pins as the `alk/ssh` receiving endpoint: a generic stream in, the full russh server stack per stream (edge - termination only — no ALPN code, no relay leg; the pin is a ledger + termination only: no ALPN code, no relay leg; the pin is a ledger entry, already decided). `Config` built from the serve-config: honest advertisement (`methods = {publickey}` exactly, ADR-004 decision 4), host keys from the vault-backed source (locked → no server constructed), the bounded knobs from `Limits`. -- **The auth bridge** (ADR-004) — the dispositional handler surface: +- **The auth bridge** (ADR-004): the dispositional handler surface: `auth_publickey` fingerprint → `resolve_from_fingerprint` → accept with the resolved identity / reject fail-closed; ed25519-only (non-ed25519 keys declined explicitly); `auth_publickey_offered` @@ -179,24 +179,24 @@ frame, before any door is hung: `auth_openssh_certificate` explicit declines (family contract, not inherited defaults); the username carries no semantics (OQ-03). - **Parse-only dispatch + the fail-closed decline core** (ADR-006 as - amended, ADR-003) — session-channel dispatch skeleton (parse path: + amended, ADR-003): session-channel dispatch skeleton (parse path: git-parseable exec → `git`, else → `session`; request-less session channels decline), unfeatured channel types decline at open, x11 / `auth-agent` always decline (never feature-gated), the sftp - subsystem decline-with-reason placeholder (OQ-13 — folded here, + subsystem decline-with-reason placeholder (OQ-13, folded here server side), **decline first, alloc never** (the POC-3 stage-A matrix: unknown repo → collapsed generic denial before any protocol byte). -- **Spawn-and-return plumbing** (ADR-006 decision 4) — the structural +- **Spawn-and-return plumbing** (ADR-006 decision 4): the structural constraint in code: every serving path `tokio::spawn`ed, handlers return fast, exit/status reporting from spawned tasks rides the cloned `Session::handle()`. The reporting *mechanics* land here; the derivation contract's rules are wave-3 session-mapping material. -- **Bounded resources** — the in-crate concurrent-channel cap enforced +- **Bounded resources**: the in-crate concurrent-channel cap enforced at the handler surface, `inactivity_timeout`/`max_auth_attempts` wired from `Limits`, per-length exec-parse limits reserved for the grammar's consumption (wave 3). -- **Loopback harness** — the in-process client/server test pair the +- **Loopback harness**: the in-process client/server test pair the decline matrix and every mapping's contract tests run through (in-memory doubles from wave 1; real-tool smokes ride the mapping waves). @@ -208,61 +208,62 @@ corrects. green): wave 2 decomposed into six task files under `tasks/implementation/` plus the gate. Shape: `w2-server-mount` first (the `SshAdapter` mount + russh `Config` construction from the -serve-config — the substrate, with the Handler skeletoned at its +serve-config, the substrate, with the Handler skeletoned at its fail-closed defaults); then `w2-auth-bridge` (the ADR-004 dispositions fill the auth hooks, over the mount + the wave-1 fingerprint path and doubles); then `w2-loopback-harness` (the -in-process client/server pair — the contract-test vehicle — proving +in-process client/server pair, the contract-test vehicle, proving the doubles live through the stack); then `w2-dispatch-decline-core` (parse-only dispatch + the fail-closed decline core, contract-tested per feature build through the harness); then `w2-spawn-return` (the structural constraint pinned by test + the exit-reporting mechanics via the cloned `Session::handle()`); then `w2-bounded-resources` (the in-crate channel cap at the handler surface); and `review-wave-2` -(the gate) depending on all six. The chain is deliberately sequential -— the auth hooks, the dispatch, and the cap all edit the same +(the gate) depending on all six. The chain is deliberately sequential: +the auth hooks, the dispatch, and the cap all edit the same per-connection Handler impl, so no two tasks hold it in parallel. Interpretations recorded (flagged for the wave-2 gate): - **The git-parseability probe in wave 2 is skeleton-level** (service - prefix + path shape) — enough to contract-test the routing decision + prefix + path shape), which is enough to contract-test the routing + decision and the session-build's git-gated decline; the full grammar (quoted/unquoted paths, service-only → decline-with-reason, per-length limits) is wave-3's git task, which replaces the probe. -- **Wave-2 routing stubs decline cleanly at the family-seam boundary** - — the seams land in wave 3; the stub behavior is documented as +- **Wave-2 routing stubs decline cleanly at the family-seam boundary**: + the seams land in wave 3; the stub behavior is documented as skeleton so the routing decisions are contract-tested now. The spawn-and-return mechanics are proven on a stub serving path. -- **The adapter error surface is shaped in `w2-server-mount`** — the +- **The adapter error surface is shaped in `w2-server-mount`**: the construction error wrapping `KeySourceError` (locked → no server) plus the Handler `type Error` (`From`); this is where the wave-1 note's "wire decline reasons are wave-2 shaped" lands. - **The harness transport and placement are pinned in `w2-loopback-harness`** (duplex pair vs TCP loopback; module - placement beside the wave-1 doubles) — flagged for the gate. + placement beside the wave-1 doubles); flagged for the gate. -## Wave 3 — Server mappings +## Wave 3: Server mappings Three parallel branches over the stack (plus the folded sftp row), each carrying a minimal real-tool smoke (one clone, one `-L`, one pty -session — the POC-3 gates re-landed cheaply); the full matrix is +session: the POC-3 gates re-landed cheaply); the full matrix is wave 5's: -- **session → alktty** — the translation bridge, not a byte pump +- **session → alktty**: the translation bridge, not a byte pump (POC-3 §D.1): `pty_request` args → `TerminalParamsWire` (SSH modes → the reserved modes JSON); shell argv / exec wrapper from the serve-config (`wrapper ++ [cmd]`, never naively split; unset → clean - decline — OQ-08); stdin/stdout/stderr chunk translation + decline, per OQ-08); stdin/stdout/stderr chunk translation (`extended_data(1)` for stderr); `window_change_request`/`signal` rows; `env_request` declined unconditionally (OQ-09); the **exit derivation contract** (`mapping.md` rules 1–6: `code ≥ 0` → `exit-status(N)`, negative `−N` → `exit-signal(name)` via the server-side Unix signal-number→name table, `−1` → `exit-status(255)` never a signal claim, `core_dumped` never asserted, PTY-path rule-5 - fidelity limit) — exit-status sent before close, from the spawned + fidelity limit); exit-status sent before close, from the spawned task. -- **git → alkgit** — exec-only (ADR-006 decision 2): the parse grammar +- **git → alkgit**: exec-only (ADR-006 decision 2), with the parse grammar (`git-{upload,receive}-pack '/repo'`, quoted/unquoted paths, service-only → decline-with-reason, non-git → parse-then-fail, per-length limits), unknown-repo → collapsed generic denial before @@ -270,10 +271,10 @@ wave 5's: `serve_duplex_resolved(repo_id, identity, service, duplex, limits, …)` over the channel duplex, process-mode dialect, exit-status both directions. -- **tunnel → alktunnels route 1** (ADR-006 decision 1) — the - adapter-side bridge: the serve-config's `ChannelClient` into the - family fabric (identity propagated once via `set_identity` so - `AccessControl::check` gates the mapped opens — the visible-surface +- **tunnel → alktunnels route 1** (ADR-006 decision 1): the + adapter-side bridge taking the serve-config's `ChannelClient` into + the family fabric (identity propagated once via `set_identity` so + `AccessControl::check` gates the mapped opens, the visible-surface invariant), `TunnelSession::open_direct` + `take_halves()` pumped against the russh channel, accept only after the fabric open succeeds (failure → explicit `ConnectFailed` reject), @@ -281,7 +282,7 @@ wave 5's: clean open-decline off-Unix), the `forwarded-tcpip` handler shape, the `-R` listen flow (`tcpip_forward`/`streamlocal_forward` + the same runtime-Unix gate). -- **sftp placeholder** — the decline-with-reason rows (folded into the +- **sftp placeholder**: the decline-with-reason rows (folded into the dispatch task server-side; the client-side placeholder folds into wave 4's acceptor/op-row work; the wave-5 matrix covers both explicitly). @@ -293,74 +294,73 @@ corrects. green): wave 3 decomposed into six task files under `tasks/implementation/` plus the gate. Shape: `w3-seam-config` first (the serve-config's per-feature seam handles + the optional -`alktty`/`alkgit` deps — see the interpretations — the mappings +`alktty`/`alkgit` deps; see the interpretations; the mappings dispatch against them, so it lands before the mappings); then `w3-decline-tracing` (the wave-2 gate's booked finding: the decline/ serve-path event vocabulary, landed **before** the mappings so they inherit a uniform shape); then the three mapping branches -deliberately sequential — `w3-git-mapping` (the full grammar replaces +(deliberately sequential): `w3-git-mapping` (the full grammar replaces the wave-2 probe + the `serve_duplex_resolved` dispatch + the store-and-take handoff it lands first for the session task to reuse), `w3-tty-session` (the translation bridge + the exit-derivation -contract — the biggest single-row surface), `w3-tunnel-direct` (the +contract, the biggest single-row surface), `w3-tunnel-direct` (the POC-proven route-1 bridge leg + identity `set_identity` propagation), and `w3-tunnel-listen` (the `-R` listen flow + `forwarded-tcpip` -data leg — the POC-untested leg, sized with the stop-and-book +data leg: the POC-untested leg, sized with the stop-and-book provision); and `review-wave-3` (the gate) depending on all six. As with wave 2, the branch DAG is sequential: the three mappings replace stubs in the same `Handler` impl and share the decline-matrix test -binaries, so no two tasks hold them in parallel — the branches are +binaries, so no two tasks hold them in parallel: the branches are parallel *conceptually* (independent seams), sequential *logistically*. Interpretations recorded (flagged for the wave-3 gate): - **The serve-config extension is wave 3's first course correction to - the wave-1 config surface** — the assembly-supplied alktty/alkgit + the wave-1 config surface**: the assembly-supplied alktty/alkgit seam handles the mappings' seam signatures take (the tty backends map + selector + ownership provider; the alkgit backend quartet + pipeline semaphore + push-options flag) were never carried by the wave-1 config bullet, and `w3-seam-config` lands them feature-gated with the optional sibling deps (`alktty = "0.8.1"`, `alkgit = - "0.1.0"` — crates.io-verified at decomposition) before the + "0.1.0"`, crates.io-verified at decomposition) before the mappings; `alktunnels = "0.4.1"` still lands with its first consuming code (`w3-tunnel-direct`) to avoid a dead entry. The unset-declines posture extends to the new handles (unset → the owned rows decline even with the feature on). -- **The server-side sftp bullet is already discharged** — the OQ-13 +- **The server-side sftp bullet is already discharged**: the OQ-13 decline rows landed with `w2-dispatch-decline-core` (every subsystem name declines in every build) and are already contract-tested per - build in `tests/dispatch_core.rs`; no wave-3 task exists for it — - the client-side placeholder folds into wave 4's acceptor/op-row + build in `tests/dispatch_core.rs`; no wave-3 task exists for it: the + client-side placeholder folds into wave 4's acceptor/op-row work and the wave-5 matrix covers both rows explicitly. -- **The `-R` listen flow carries the wave-3 gate's biggest open** — - the fabric-side inbound-forwarded mechanics (who dials the +- **The `-R` listen flow carries the wave-3 gate's biggest open**: the fabric-side inbound-forwarded mechanics (who dials the forwarded op at the door, whether the assembly-supplied `Arc` composition can carry the door-side accept or the serve-config's tunnel handle must grow) is decided in `w3-tunnel-listen` against alkcall 0.10.1 / alktunnels 0.4.1's published surfaces; if the topology does not close, the task stops - and books it for the gate — nothing is silently forked or absorbed. -- **The wave-2 gate's three booked items all have wave-3 homes** — the + and books it for the gate; nothing is silently forked or absorbed. +- **The wave-2 gate's three booked items all have wave-3 homes**: the decline-path tracing task (`w3-decline-tracing`), the forwarded- tcpip acceptor vocabulary (`w3-tunnel-listen`, with the client-side accept hook landing in the harness), and the deferred identity- landing assertion (each mapping asserts the resolved identity reaches its seam; the tunnel branch additionally propagates it via - `set_identity` — auth.md §Server direction step 4). + `set_identity`; see auth.md §Server direction step 4). -## Wave 4 — Client direction +## Wave 4: Client direction -The door outward (independent of wave 3 — shares only wave 1's +The door outward (independent of wave 3; shares only wave 1's foundations), each branch carrying its real-tool smoke where cheap: -- **Dial surface** (`client.md`) — connect with address, key source - (decrypt-at-dial, fail-closed on locked/absent — never env vars), +- **Dial surface** (`client.md`): connect with address, key source + (decrypt-at-dial, fail-closed on locked/absent; never env vars), host-key policy at `check_server_key` (the wave-1 seam consumed: case-1 pins hard-decline on mismatch, case-2 policies decide), the bounded config (keepalives 15 s / 3, inactivity, window knobs). The authenticated connection handle everything else rides. -- **Op rows** — family op → vanilla translation per `client.md`'s +- **Op rows**: family op → vanilla translation per `client.md`'s table: session exec/pty opens (the POC-3 §D.3 mechanics: `Channel::exec()` not `request_exec`, Success/Failure via `wait()`-drain not `Err`, `window_change`/`signal` before @@ -369,14 +369,14 @@ foundations), each branch carrying its real-tool smoke where cheap: connection stays usable); git exec consumption (the same grammar the server parses, pointed upstream); the sftp subsystem placeholder decline. -- **The `-R` leg** — `tcpip_forward(address, port) -> bound` (the +- **The `-R` leg**: `tcpip_forward(address, port) -> bound` (the actually-bound port returned), server-initiated `forwarded-tcpip` opens routed to the **acceptor registry**: served only under a registered feature's acceptor, unregistered/unfeatured accepted opens declined cleanly (never russh's accept-and-drop default), late-install across a live connection (the alkminer dynamic), `streamlocal_forward` with the runtime-Unix gate. -- **Lifecycle primitives** (OQ-12) — explicitly cancellable +- **Lifecycle primitives** (OQ-12): explicitly cancellable listener/session handles, clean `Disconnect::ByApplication`; **no reconnect or restore policy owned by the crate** (assembly policy; the primitives make it possible). @@ -390,24 +390,24 @@ the mappings surfaced). green): wave 4 decomposed into eight task files under `tasks/implementation/` plus the gate. The wave-3 gate's four booked findings are folded in as dedicated tasks (the gate's own -disposition — "wave-4 material"), not wave-3 backports: the -hardening set is server-side and independent of the client chain — +disposition, "wave-4 material"), not wave-3 backports. The +hardening set is server-side and independent of the client chain: `w4-git-exit-close` (booked item 1: the git row's status-before-close -by construction — the seam/shape decided + recorded in-task), +by construction, the seam/shape decided + recorded in-task), `w4-channel-not-held-rows` (item 2: the guard's wire rows on all -three rows, per build — test-only), `w4-tty-coverage` (item 3: the +three rows, per build, test-only), `w4-tty-coverage` (item 3: the `channel_close`-fired cancel path + the Closed/Break no-exit-chunk rows + the write_all no-deadline known-limit record), and `w4-router-collision` (item 4: the `ListenRouter` token-collision fail-closed pre-check); the gate's item 5 (the upstream asks) stays -booked with its owners — no task, nothing absorbed. The client chain -is deliberately sequential — `w4-dial` first (the russh client stack +booked with its owners: no task, nothing absorbed. The client chain +is deliberately sequential: `w4-dial` first (the russh client stack + the connection handle + the client Handler skeleton at fail-closed defaults, over an in-process fake-`sshd` server double this task lands), then `w4-op-rows` (exec/pty, direct-tcpip, git exec over the -shared grammar, the sftp client-side decline — the POC-3 §D.3 +shared grammar, the sftp client-side decline with the POC-3 §D.3 mechanics pinned), then `w4-reverse-listen` (the `-R` leg: forward -requests + the acceptor registry + late-install — the wave's +requests + the acceptor registry + late-install, making it the wave's highest-risk task, growing the harness's `ForwardedAcceptor` vocabulary into the crate's real surface), then `w4-lifecycle` (the OQ-12 primitives); `review-wave-4` (the gate) depends on all eight. @@ -420,75 +420,75 @@ are scheduled first as cheap remediation. Interpretations recorded (flagged for the wave-4 gate): - **The client Handler skeleton lands fail-closed in `w4-dial`, the - acceptor registry in `w4-reverse-listen`** — the dial task pins the + acceptor registry in `w4-reverse-listen`**: the dial task pins the decline-all default for server-initiated opens (the ADR-003 §4 rule) and the listen task grows the registry that serves them; the split mirrors wave 2's mount-before-mappings shape and keeps the dial task's surface reviewable. - **The client-direction test vehicle is an in-process fake `sshd`** - (a hand-rolled russh server double in `tests/common`) — the + (a hand-rolled russh server double in `tests/common`): the loopback harness drives the *server* direction and is not the vehicle here; real-`sshd` client-direction e2e stays wave-5 territory (the plan's wave-5 bullet), with per-task check-gated real-tool smokes where cheap (the wave-3 smoke posture). -- **No `DialConfig` course-correction is anticipated** — the wave-1 +- **No `DialConfig` course-correction is anticipated**: the wave-1 shape already carries everything `client.md`'s dial surface names; `w4-dial` decides + records if that proves wrong (the `w3-seam-config` pattern), and the acceptor registry is client-connection state, not serve-config. - **The hardening tasks are ordered by single-writer needs, not - risk** — `w4-channel-not-held-rows` follows `w4-git-exit-close` + risk**: `w4-channel-not-held-rows` follows `w4-git-exit-close` (both touch `tests/git_mapping.rs`) and `w4-tty-coverage` follows the rows task (both touch `tests/tty_mapping.rs`), the wave-3 no-two-tasks-hold-one-file lesson applied to the test binaries. -## Wave 5 — Validation & release +## Wave 5: Validation & release -- **The decline-matrix contract suite** (`mapping.md`'s table) — every +- **The decline-matrix contract suite** (`mapping.md`'s table): every build (none / `git` / `session` / `tunnel` / `--all-features`) × every row it can appear in × both directions, including the sftp placeholder rows and the never-features (x11, `auth-agent`). The fail-closed invariant is contract territory on both sides of every feature (AGENTS.md conventions 5–6). -- **Real-tool e2e** — the POC-3 gates re-landed against the shipped +- **Real-tool e2e**: the POC-3 gates re-landed against the shipped crate: OpenSSH (pty/shell/exec, `-L`, `-R`, `-D`'s `direct-tcpip` leg, decline observability), git 2.43+ (clone/push through the git mapping), curl through a `-D` proxy; client direction against a real `sshd` (the reverse-runner shape: exec, direct-tcpip dial, `-R` listen + acceptor). -- **Fuzzing** — the family standing rule (anything worth fuzzing gets +- **Fuzzing**: the family standing rule (anything worth fuzzing gets fuzzed) on the crate's ingress surfaces: the exec parse grammar, the fingerprint format path, the key-decode path, the TerminalParamsWire conversion. The `fuzz/` sidecar convention (detached runner, corpus gate joining the verification list). -- **Release readiness** — crate docs (public-API rustdoc +- **Release readiness**: crate docs (public-API rustdoc warning-free; docs honesty), the feature matrix (`default`, each feature over it, `--all-features`), CHANGELOG/README, publish - dry-run, then **the publish of `alkssh 0.1`** — the gate review that + dry-run, then **the publish of `alkssh 0.1`**, the gate review that closes the plan. **Decomposition record** (2026-10-30, after the wave-4 gate closed -green — the gate's verdict: "wave 5 decomposition is unblocked and +green, with the gate's verdict: "wave 5 decomposition is unblocked and should absorb nothing beyond its own plan"; the wave-3 gate's item-5 upstream asks verified still booked with their owners, no task): wave 5 decomposed into seven task files under `tasks/implementation/`. -Shape: four independent implementation tasks over the landed waves — +Shape: four independent implementation tasks over the landed waves: `w5-decline-matrix` (the coverage audit of `mapping.md`'s matrix table cell by cell + the gap rows in the existing per-build binaries + the matrix runner joining the verification list), `w5-e2e-server` (OpenSSH pty/shell/exec/`-L`/`-R`/`-D`+curl, decline observability, -git clone **and** push — a dedicated new e2e binary, the wave-3 +git clone **and** push in a dedicated new e2e binary, the wave-3 smokes re-landed/extended), `w5-e2e-client` (the reverse-runner shape -against a spawned real `sshd` — the sshd fixture, exec/direct-tcpip/ +against a spawned real `sshd`: the sshd fixture, exec/direct-tcpip/ `-R`+acceptor, the decline rows), and `w5-fuzz-harness` (the `fuzz/` -sidecar per the alkgit convention: four targets — exec_parse, -fingerprint, key_decode, terminal_params — the shared-library corpus +sidecar per the alkgit convention: four targets (exec_parse, +fingerprint, key_decode, terminal_params), the shared-library corpus gate joining the verification list, the detached runner, the root workspace `exclude`); then `w5-release-readiness` (rustdoc warning-free × builds, README lands, the CHANGELOG 0.1.0 entry, the feature matrix, the publish dry-run + package-policy file-list check, the freeze inventory recorded for the gate) over all four; -`review-wave-5` (the release gate — a **pure review**) over all five; +`review-wave-5` (the release gate, a **pure review**) over all five; and `w5-publish` (the publish checklist: manifest sanity, the full verification set, the dry-run, the crate-qualified `alkssh-v0.1.0` tag, `cargo publish`, the plan's closure recorded) over the gate. @@ -496,17 +496,17 @@ The four implementation tasks are mutually independent by construction (disjoint files: the matrix task owns the existing per-build binaries, the e2e tasks own new binaries + their own fixture modules, the fuzz task owns `fuzz/` + at most -`#[doc(hidden)]` exposures) — parallel *conceptually*; any src/ +`#[doc(hidden)]` exposures), parallel *conceptually*; any src/ fix either finds is fixed in-task, with the gate as the merge point. Interpretations recorded (flagged for the wave-5 gate): -- **The gate-then-publish split** — the plan's release-readiness +- **The gate-then-publish split**: the plan's release-readiness bullet folds the publish into release readiness, but the decomposition follows the alkgit pattern: `review-wave-5` stays a pure review and `w5-publish` is its own task (the publish is a one-way door; it executes only over a green gate). -- **The e2e vehicle/placement** — dedicated new test binaries +- **The e2e vehicle/placement**: dedicated new test binaries (`tests/e2e_server.rs` / `tests/e2e_client.rs`, reusing `tests/common`) rather than extensions of the wave-3/4 mapping binaries, keeping those single-writer for the matrix task; the @@ -519,23 +519,23 @@ Interpretations recorded (flagged for the wave-5 gate): fuzz-visible exposures per target) is decided + recorded in `w5-fuzz-harness` (the alkgit pattern); the sidecar is excluded from the root single-member workspace and never a root dep. -- **The tag convention** — crate-qualified `alkssh-v0.1.0` (the +- **The tag convention**: crate-qualified `alkssh-v0.1.0` (the alkgit convention; a bare `v0.1.0` would be ambiguous across the family's multi-crate repos). Pinned in `w5-publish`; flagged for the gate to confirm. -- **No version bump** — the manifest already carries 0.1.0; the +- **No version bump**: the manifest already carries 0.1.0; the publish task verifies rather than bumps. ## Plan closure (2026-10-30) -The plan closed at the publish of **`alkssh 0.1.0`** — the publish +The plan closed at the publish of **`alkssh 0.1.0`**: the publish commit, tagged **`alkssh-v0.1.0`** (the crate-qualified alkgit tag convention) and pushed to `origin/main`. Scope authority discharged: the complete two-direction door shipped (server stack + all mappings, client stack + op rows, key sources, host-key policy seam, the -decline matrix contract-tested per build per `scripts/matrix.sh` — -see AGENTS.md's verification list), the assembly stays out (seams -only — identity/ACL/fabric policy is downstream composition), no +decline matrix contract-tested per build per `scripts/matrix.sh` +(see AGENTS.md's verification list), the assembly stays out (seams +only; identity/ACL/fabric policy is downstream composition), no binary, single crate. All five review gates closed green (waves 1–4 2026-10-28/29; the wave-5 release gate 2026-10-30). @@ -555,9 +555,9 @@ mapping.md's correlation-miss event-vocabulary nuance) and the ## References -- `docs/architecture/` — overview, mapping, client, auth, +- `docs/architecture/`: overview, mapping, client, auth, open-questions (all reviewed 2026-10-28), ADR-001…006 -- `docs/research/` — vision, russh inventory, auth bridge, mapping +- `docs/research/`: vision, russh inventory, auth bridge, mapping table, POC-1/2/3 findings, reverse-runner extraction - Family ADR-003 (the structural decision); the alkgit implementation plan (the pattern this follows) \ No newline at end of file diff --git a/docs/research/auth-bridge.md b/docs/research/auth-bridge.md index 41395ab..969269a 100644 --- a/docs/research/auth-bridge.md +++ b/docs/research/auth-bridge.md @@ -1,4 +1,4 @@ -# Auth bridge — russh auth ↔ alkcall identity +# Auth bridge: russh auth ↔ alkcall identity Status: Draft (phase 0 research). Sources: alkcall 0.10.1 source (`src/core/auth.rs`, `src/core/types.rs`, `src/protocol/dispatch.rs`), @@ -31,7 +31,7 @@ pub trait IdentityProvider: Send + Sync + 'static { } ``` -`IdentityProvider` is a trait only — implementations are supplied by +`IdentityProvider` is a trait only; implementations are supplied by the assembly layer (alkcall ships `NoopIdentityProvider`, which resolves nothing). alkhttp's server resolves tokens through exactly this one backend for every inbound surface (bearer + door-Basic, @@ -53,13 +53,13 @@ already-verified key. 1. russh `auth_publickey(user, public_key)` fires. 2. alkssh computes `public_key.fingerprint()` via the `ssh-key` re- - export (`HashAlg::Sha256`, `SHA256:base64` OpenSSH format) — + export (`HashAlg::Sha256`, `SHA256:base64` OpenSSH format): **fingerprint-format parity with the assembly layer's registered identities is a contract invariant and a POC-1 checkpoint** (the format is stable and self-describing, but the registration path is assembly-owned, so the exact string compare must be verified, not assumed). -3. `IdentityProvider::resolve_from_fingerprint(fp)` — `Some(identity)` +3. `IdentityProvider::resolve_from_fingerprint(fp): `Some(identity)` → `Result::Authenticate { .. }`; `None` → decline (`Reject`). The user name string is **ignored for identity** (it is not the family identity model); whether the username is checked at @@ -69,7 +69,7 @@ already-verified key. 4. On success, the resolved identity rides the session: `auth_succeeded(session)` attaches it to alkssh's per-session state; when the session is bridged over alkcall, the identity is - pushed with the once-only `Connection::set_identity(identity)` — + pushed with the once-only `Connection::set_identity(identity)`, after which `AccessControl::check(peer_identity)` gates every mapped channel open exactly as in alkhttp (visible-surface = authorized-surface). Unresolvable keys decline fail-closed; the @@ -81,20 +81,20 @@ ownership unverified there); the constant-rejection-time contract (`Config::auth_rejection_time`) plus `Config::max_auth_attempts` covers the timing/DoS posture. Note russh's own doc warning: seeing an unknown key at the `offered` stage should not count toward the attempt -limit — the offered hook's disposition is "accept the offer, let +limit; the offered hook's disposition is "accept the offer, let `auth_publickey` decide", which keeps counting honest. ### Auth-method disposition (fail-closed matrix) | russh hook | Disposition | Rationale | |---|---|---| -| `auth_publickey` | Implement — the only method | The family identity model is keys; fingerprint → `Identity` | +| `auth_publickey` | Implement (the only method) | The family identity model is keys; fingerprint → `Identity` | | `auth_publickey_offered` | Implement (accept-and-let-decide) | Keeps `max_auth_attempts` honest | | `auth_none` | Decline (implement explicitly) | Fail-closed is ours to enforce | | `auth_password` | Decline (implement explicitly) | No password concept in family identity | | `auth_keyboard_interactive` | Decline (implement explicitly) | Same | | `auth_openssh_certificate` | Decline initially (OQ candidate) | OpenSSH CA trust model ≠ family key registry; revisit on a use case | -| `authentication_banner` | Assembly-provided (e.g. alkforge notice) or none | — | +| `authentication_banner` | Assembly-provided (e.g. alkforge notice) or none | (n/a) | `Config::methods: MethodSet` must advertise exactly what is implemented (honest advertisement = only `publickey` in every current configuration). @@ -103,7 +103,7 @@ limit — the offered hook's disposition is "accept the offer, let corrects an earlier working assumption):** the default impls of `auth_none`, `auth_password`, `auth_keyboard_interactive`, `auth_publickey`, and `auth_openssh_certificate` are *reject* -(`Auth::reject()`) in 0.64.1 — only `auth_publickey_offered` defaults +(`Auth::reject()`) in 0.64.1; only `auth_publickey_offered` defaults to accept. The fail-closed posture is therefore largely inherited at this pin, but every hook is still implemented explicitly as a family contract (a future pin bump could alter defaults; the pin is a one-way @@ -116,7 +116,7 @@ client *signs* with a private key; the server verifies. alkssh's client side therefore holds secret material, and the family rule applies: that material comes from alkvault-backed sources, never env vars, never plaintext at rest (family ADR-002 decision 5). The guiding framing: -**a held SSH key is just another bearer-token-class secret** — opaque +**a held SSH key is just another bearer-token-class secret**: opaque bytes the family holds, vault-encrypted at rest, decrypted at use. ### The alkhttp pattern being mirrored (alkhttp ADR-014) @@ -127,28 +127,28 @@ injects the secret as an alkcall `Capabilities` entry at registration, and the adapter reads it fail-closed at use time (`Capabilities::get` → absent → loud internal error, never an unauthenticated send). The alkssh translation: the client adapter takes *key sources*, not static -keys — the assembly layer supplies an alkvault-backed source (the +keys: the assembly layer supplies an alkvault-backed source (the encrypted-at-rest blob); the adapter decodes it into a russh-usable key at dial time; absent/locked vault → fail-closed decline, no anonymous dial. ### The gap: alkvault-backed key storage -alkvault's role for SSH keys is **at-rest encryption, nothing more** — +alkvault's role for SSH keys is **at-rest encryption, nothing more**: it is to an SSH client key what it is to every outgoing secret: a secret store. Held key material is treated exactly like a random -bearer token — opaque bytes the family holds, vault-encrypted at rest, +bearer token: opaque bytes the family holds, vault-encrypted at rest, decrypted at use (the alkhttp ADR-014 model: vault is assembly-layer only, secrets are zeroized `Secret`s at the adapter, never env vars). alkvault's *derivation* surface (`derive_ed25519`, SLIP-0010 paths) is -a different feature of that crate and is **not** the SSH-keys path — +a different feature of that crate and is **not** the SSH-keys path: there is no derivation story here, and no vault-custody design where key bytes never leave the vault. The bridge that does not exist yet, and is alkssh phase 0/1 design work: -- **At-rest shape**: what byte format sits in the vault store — +- **At-rest shape**: what byte format sits in the vault store: OpenSSH private-key encoding (the format `ssh-key` itself supports with a passphrase) is the natural candidate; alkvault `encrypt`/ `decrypt` (`EncryptedData`) is the outer wrapper. @@ -156,13 +156,13 @@ work: (`decode_secret_key` for OpenSSH encoding) at dial/construction time, held as a zeroized `Secret`-like value for the dial's lifetime. russh's `Signer` trait was considered as an - extraction-avoidance shape; withdrawn — the at-rest model implies + extraction-avoidance shape; withdrawn, since the at-rest model implies the bytes are handled client-side at use time anyway (as every bearer-token-like secret is), so a one-time decode at the adapter boundary is the honest shape. Lifecycle questions (zeroization bounds, locked-vault behavior → fail-closed no-dial) are POC-2's checkpoints. -- **Source shape**: the adapter takes *key sources*, not static keys — +- **Source shape**: the adapter takes *key sources*, not static keys: an alknet-`main`-shaped `KeySource` seam (`{ File, Memory }`-equivalent) backed by vault-encrypted material at the assembly layer, mirroring alkhttp's capability injection. @@ -171,14 +171,14 @@ work: comparison; note alkvault's `encrypt`/`decrypt` speak `String` only, so binary formats need a base64 layer); one-time `decode_secret_key` at dial confirmed; ssh-key zeroizes ed25519 key - bytes on drop but the vault's `decrypt` returns a plain `String` — - the adapter owns plaintext zeroization at the seam (`Zeroizing` + bytes on drop but the vault's `decrypt` returns a plain `String`, + so the adapter owns plaintext zeroization at the seam (`Zeroizing` wrap) [OQ seed]; the `Signer` seam is dismissed with evidence (no provided impl in 0.64.1; works when hand-rolled; in-process vault gains nothing). Locked/absent/tampered/rotation all fail closed through distinct, mappable error shapes. - **Scope note**: the vault is **client-side only**. The server side - never touches the vault for *peer* identity — it receives clients' + never touches the vault for *peer* identity: it receives clients' public keys and resolves fingerprints through the assembly layer's `IdentityProvider`. The server's own host key, though, is also a held secret and uses the same at-rest model. Vault material is for @@ -187,9 +187,9 @@ work: ### Host keys and `check_server_key` The server's host identity (`Config::keys: Vec`, -`certificates`) is assembly-configured — a held secret exactly like a +`certificates`) is assembly-configured, a held secret exactly like a client key, same at-rest model as above. The client's -`Handler::check_server_key` defaults to *reject all* — russh forces a +`Handler::check_server_key` defaults to *reject all*: russh forces a deliberate host-key policy. Family posture (no TOFU): pin expected host fingerprints at the assembly layer; `check_known_hosts` exists in russh, but the family identity registry is likely the better pin store @@ -200,7 +200,7 @@ for the ADR. **Post-POC refinement (client-direction host-policy cases; see `reverse-runner.md` and OQ-05 in the architecture tracker):** the no-TOFU rule is a rule about trust *anchors*, not an absolute. Two -client cases: (1) hosts the assembly controls or can provision — the +client cases: (1) hosts the assembly controls or can provision; the host key is knowable beforehand (family-built runners mint theirs at assembly time) → pinned, hard-decline on mismatch; (2) hosts that are unknown until first contact (rental-fleet vanilla `sshd`) → the @@ -214,16 +214,16 @@ policy-blind. - alkcall: `src/core/auth.rs` (Identity/AuthToken/AuthContext/ IdentityProvider verbatim above), `src/core/types.rs` (`set_identity` - once-only), `src/protocol/dispatch.rs` (`resolve_identity` — token + once-only), `src/protocol/dispatch.rs` (`resolve_identity`: token wins per-request, else connection identity), `src/registry/spec.rs` (`AccessControl::check`) -- alkcall ADRs: 002 (ProtocolHandler, hybrid auth completion — +- alkcall ADRs: 002 (ProtocolHandler, hybrid auth completion, "handlers must not assume AuthContext is fully resolved"), 003 (auth-as-shared-core; the SshAdapter credential-table row), 006 (AuthContext shape/set_identity), 010 (secret-material flow) -- alkhttp ADR-014 (secret material flow + capability injection — the +- alkhttp ADR-014 (secret material flow + capability injection, the client-side pattern), ADR-073 (one backend behind every door surface) -- alkvault 0.1.0: `src/service.rs` (`VaultServiceHandle` — for SSH +- alkvault 0.1.0: `src/service.rs` (`VaultServiceHandle`: for SSH keys, the `encrypt`/`decrypt`/`EncryptedData` at-rest surface is the relevant one; the derivation surface is not on this path), `src/protocol.rs` (`EncryptedData`) diff --git a/docs/research/mapping-table.md b/docs/research/mapping-table.md index 9b5fed5..fe9bc8b 100644 --- a/docs/research/mapping-table.md +++ b/docs/research/mapping-table.md @@ -1,9 +1,9 @@ -# Mapping table — SSH channel types × family protocol seams +# Mapping table: SSH channel types × family protocol seams Status: Draft (phase 0). The crate's spine. Every row maps a russh server-side entry point onto exactly one family protocol seam plus exactly one cargo feature. Names below are *feature-name -candidates*, not decisions — the feature graph is phase 1 ADR +candidates*, not decisions; the feature graph is phase 1 ADR territory. All downstream surfaces verified against published sibling source (alktty 0.8.1, alktunnels, alksocks, alkgit 0.1.0, alkcall 0.10). @@ -16,19 +16,19 @@ run are marked **[POC-3 corrected]** below. ## The shape that makes this work Every family protocol crate already exposes a generic duplex-stream or -`Connection`-serving entry — no crate needs to know SSH exists: +`Connection`-serving entry, so no crate needs to know SSH exists: | Crate | Serve-over-a-stream entry | Signature shape | |---|---|---| | alktty | `drive_session[_pre_negotiated]` | `(impl AsyncWrite, impl AsyncRead, backends, identity)` | | alkgit | `serve_duplex_resolved` | `(repo_id, identity, service, T: AsyncRead+AsyncWrite, limits, backend traits)` | | alksocks | `serve_socks5_over` | `(T: AsyncRead+AsyncWrite+Unpin, dial, egress, accept, can_bind)` | -| alktunnels | — **no serve-over-stream entry; see absence note** | consumer wrappers / open-op registrations instead | +| alktunnels | **no serve-over-stream entry; see absence note** | consumer wrappers / open-op registrations instead | russh channels convert to duplex via `Channel::{into_stream, split, make_reader/make_writer}` (`ChannelStream`/com halves). So each mapping is: accept-or-decline the channel open -(`ChannelOpenHandle`; dropping it auto-rejects — russh's default is +(`ChannelOpenHandle`; dropping it auto-rejects, russh's default is already fail-closed), pump/serve the channel through the seam, map the request-level extras. @@ -36,16 +36,16 @@ request-level extras. | SSH channel type | Vanilla use | Feature (candidate) | Family seam | Notes | |---|---|---|---|---| -| `session` → `pty_request`+`shell_request` | `ssh host` interactive shell | `session` | alktty producer: `NegotiateRequest` constructed locally → `drive_session` (or pre-allocated variant) | `pty_request` args → `TerminalParamsWire` (term/cols/rows/pixels; SSH modes → `modes` JSON reserved); shell case needs a cmd convention (OQ); PTY mode merges stderr into stdout (kernel PTY). **[POC-3 corrected]** the data-plane mapping is a *translation bridge*, not a byte pump: alktty's plane is chunk-framed both directions (stdin/stdout/stderr + CTRL_IN/CTRL_OUT chunks), so alkssh encodes SSH data→stdin chunks and decodes stdout→data, stderr→extended_data, exit chunk→exit-status/exit-signal. The exit chunk encodes pipe-path signal termination as the negative code `−N` (alktty ADR-004's convention — `pipe.rs::exit_code_from`); the PTY path reduces signal termination to a plain exit code (portable-pty's exit surface), so PTY-mode `exit-signal` fidelity is an alktty-backend question | -| `session` → `exec_request` (non-git command) | `ssh host "cmd"` | `session` | alktty pipe mode: `terminal: None`, separate stderr | stderr flows back as SSH `extended_data` code 1 — only valid without PTY | -| `session` → `exec_request`/`subsystem_request` git | `git push/clone ssh://` | `git` | alkgit: parse command → `Service` + repo (same grammar as `preamble.rs`, without `\0host=`) → `serve_duplex_resolved(repo_id, identity, service, duplex, ...)` | Process-mode dialect is correct (SSH supplies service+repo out of band; server speaks first = advertisement). **[POC-3 corrected]** git over SSH is exec-only: a `subsystem` request carries the service *name* but no repo id, so even a git-service subsystem must decline — vanilla git always executes `git-upload-pack '/repo'`. POC-3: vanilla clone/push/second-push through the published seam + substrate composition, exit-status in both directions | -| `session` → SOCKS5-in-channel | (n/a — retired) | ~~`dynamic-forward`~~ **retired (ADR-003 §1a)** | was alksocks: `serve_socks5_over` | **Post-architecture retirement:** no consumer is ever the family-native client shape this row served. Vanilla `ssh -D` does SOCKS client-side and opens plain `direct-tcpip` (POC-3 correction 2 → the `tunnel` row); the family `-D` behavior is a *client-local* alksocks door over the alkssh client's `direct-tcpip` opens (assembly composition). POC-3 stage B remains the evidence that SOCKS-in-channel is feasible if a family client ever wants a remote SOCKS door (back via explicit ADR). **[ADR-003 amended]** | +| `session` → `pty_request`+`shell_request` | `ssh host` interactive shell | `session` | alktty producer: `NegotiateRequest` constructed locally → `drive_session` (or pre-allocated variant) | `pty_request` args → `TerminalParamsWire` (term/cols/rows/pixels; SSH modes → `modes` JSON reserved); shell case needs a cmd convention (OQ); PTY mode merges stderr into stdout (kernel PTY). **[POC-3 corrected]** the data-plane mapping is a *translation bridge*, not a byte pump: alktty's plane is chunk-framed both directions (stdin/stdout/stderr + CTRL_IN/CTRL_OUT chunks), so alkssh encodes SSH data→stdin chunks and decodes stdout→data, stderr→extended_data, exit chunk→exit-status/exit-signal. The exit chunk encodes pipe-path signal termination as the negative code `−N` (alktty ADR-004's convention, `pipe.rs::exit_code_from`); the PTY path reduces signal termination to a plain exit code (portable-pty's exit surface), so PTY-mode `exit-signal` fidelity is an alktty-backend question | +| `session` → `exec_request` (non-git command) | `ssh host "cmd"` | `session` | alktty pipe mode: `terminal: None`, separate stderr | stderr flows back as SSH `extended_data` code 1; only valid without PTY | +| `session` → `exec_request`/`subsystem_request` git | `git push/clone ssh://` | `git` | alkgit: parse command → `Service` + repo (same grammar as `preamble.rs`, without `\0host=`) → `serve_duplex_resolved(repo_id, identity, service, duplex, ...)` | Process-mode dialect is correct (SSH supplies service+repo out of band; server speaks first = advertisement). **[POC-3 corrected]** git over SSH is exec-only: a `subsystem` request carries the service *name* but no repo id, so even a git-service subsystem must decline, as vanilla git always executes `git-upload-pack '/repo'`. POC-3: vanilla clone/push/second-push through the published seam + substrate composition, exit-status in both directions | +| `session` → SOCKS5-in-channel | (n/a, retired) | ~~`dynamic-forward`~~ **retired (ADR-003 §1a)** | was alksocks: `serve_socks5_over` | **Post-architecture retirement:** no consumer is ever the family-native client shape this row served. Vanilla `ssh -D` does SOCKS client-side and opens plain `direct-tcpip` (POC-3 correction 2 → the `tunnel` row); the family `-D` behavior is a *client-local* alksocks door over the alkssh client's `direct-tcpip` opens (assembly composition). POC-3 stage B remains the evidence that SOCKS-in-channel is feasible if a family client ever wants a remote SOCKS door (back via explicit ADR). **[ADR-003 amended]** | | `direct-tcpip` | `ssh -L` | `tunnel` | alktunnels (two routes, below) | Target = `SubstrateAddr::HostPort`/`Path`; `Substrate` = Tcp/Unix | -| `forwarded-tcpip` | `ssh -R` (data channel) | `tunnel` | alktunnels: `register_tunnel_forwarded_openable`'s `ForwardedHandler(params, BiStream)` shape — in the adapter, hand the russh channel duplex to the handler | listen flow via global-request rows below | +| `forwarded-tcpip` | `ssh -R` (data channel) | `tunnel` | alktunnels: `register_tunnel_forwarded_openable`'s `ForwardedHandler(params, BiStream)` shape; in the adapter, hand the russh channel duplex to the handler | listen flow via global-request rows below | | `direct-streamlocal` | unix-socket forward | `tunnel` | same `SubstrateAddr::Path` treatment | unix-only; decline on other platforms (compile-time feature + runtime decline) | -| `x11` | X forwarding | **never** (no feature) | — | decline, always; not even feature-gated initially (OQ: revisit years later if a use case arrives) | -| `sftp` (`subsystem_request("sftp")`) | sftp clients | `sftp` (future) | alkfs (deferred, family ADR-002) | The declinable placeholder — the feature name exists in the feature graph, decline until alkfs lands | -| `auth-agent` (`agent_request`) | ssh-agent forwarding | **never** | — | decline; family holds keys in alkvault — forwarding agents to remote sessions is the anti-pattern this posture rejects | +| `x11` | X forwarding | **never** (no feature) | none | decline, always; not even feature-gated initially (OQ: revisit years later if a use case arrives) | +| `sftp` (`subsystem_request("sftp")`) | sftp clients | `sftp` (future) | alkfs (deferred, family ADR-002) | The declinable placeholder: the feature name exists in the feature graph, decline until alkfs lands | +| `auth-agent` (`agent_request`) | ssh-agent forwarding | **never** | none | decline; family holds keys in alkvault, since forwarding agents to remote sessions is the anti-pattern this posture rejects | Notes: the rows deliberately split one channel type (`session`) across three features, because vanilla SSH multiplexes everything through a @@ -62,20 +62,20 @@ a shell, a pipe-mode command. The failure matrix this produces: Dispatch is parse-then-route by command shape: parsing to a git service and repo requires `git`; otherwise the alktty pipe/pty path requires `session`. Parsing happens before dispatch and before any -resource allocation (a leaked channel alloc is a capability leak — -decline first). (Post-architecture: the former SOCKS-detect dispatch -arm is retired with the `dynamic-forward` feature — ADR-003 §1a / +resource allocation (a leaked channel alloc is a capability leak, +so decline first).. (Post-architecture: the former SOCKS-detect dispatch +arm is retired with the `dynamic-forward` feature, per ADR-003 §1a / ADR-006 amendment; request-less session channels decline.) Grammar/ precedence per ADR-006. -## Global-request rows (`tcpip_forward` / `streamlocal_forward`) — the `-R` listen flow +## Global-request rows (`tcpip_forward` / `streamlocal_forward`): the `-R` listen flow `Handler::tcpip_forward(address, &mut port) -> bool` + per-open `channel_open_forwarded_tcpip`. Consumer-side alktunnels: `TunnelListener`, `open_reverse_channel`; the forwarded data channel maps to the `ForwardedHandler` route above. Dispositions follow the `tunnel` feature (decline both listen and data when `tunnel` absent). -`port = 0` (server picks) interacts with the `&mut u32` out-param — +`port = 0` (server picks) interacts with the `&mut u32` out-param; spec detail deferred to phase 1. ## Request-level mapping on a `session` channel @@ -83,33 +83,33 @@ spec detail deferred to phase 1. | SSH request | Family mapping | |---|---| | `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{term, cols, rows, pixel_w, pixel_h, modes}` | -| `shell_request` | `NegotiateRequest.cmd` per the backend's shell convention (OQ — probably `cmd` + `cwd`/`env` from assembly policy, not from the wire) | +| `shell_request` | `NegotiateRequest.cmd` per the backend's shell convention (OQ: probably `cmd` + `cwd`/`env` from assembly policy, not from the wire) | | `exec_request` | Parse command → git/service dispatch or alktty pipe `cmd`; unknown commands fail with `session.channel_failure` (parse-then-decline, no resource alloc) | | `subsystem_request` | `sftp` → decline until `sftp` feature serves alkfs; git subsystem names → same parse as exec; anything else → decline | | `env_request` | **Decline by default.** Wire-supplied env is not family identity config; `TtyParams.env` comes from assembly. (OQ: allowlist shape, e.g. `LANG`, later.) | | `window_change_request` | `TtyControlHandle::resize(cols, rows, pixel_w, pixel_h)` | | `signal` | `TtyControlHandle::signal(name)` (alktty's `signal_from_name` accepts the HUP/INT/TERM/... names verbatim) | | data / `extended_data(1)` | channel duplex in/out; stderr only meaningful in pipe mode (PTY merges) | -| `exit_status` | from `TtyHandle.exit_code` (`TtySession.wait()` semantics: i32; negative = signal-terminated on the pipe path — `pipe.rs::exit_code_from` maps Unix `ExitStatus::signal()` to `−sig`; the PTY path reduces signal termination to a plain exit code, see the exit_signal row) — alkssh sends the SSH `exit-status` request before channel close (POC-3: proven both directions via the cloned `Session::handle()` from the spawned serving task); `−1` (alktty's could-not-determine sentinel, alktty ADR-004 §4) → `exit-status(255)` | -| `exit_signal` | derived server-side from the exit chunk's negative code — `−N` → name via a Unix signal-number→name table; `core_dumped` never asserted (alktty discards `ExitStatus::core_dumped()`); no chunk change is needed or proposed (the encoding is alktty's documented contract, alknet ADR-055 lineage). POC-3 observed `−15` → `exit-signal("TERM")` live on the pipe path. PTY path: portable-pty 0.9's `ExitStatus` conversion yields `code = status.code().unwrap_or(1)` (**1**) for signal-terminated children, keeping the signal only in a separate humanized (`strsignal`) string field alktty's waiter discards — faithful `exit-signal` on the PTY row needs the alktty backend to conform to its own ADR convention (upstream ask: alktty/portable-pty trackers; the alkssh contract adapts automatically) | +| `exit_status` | from `TtyHandle.exit_code` (`TtySession.wait()` semantics: i32; negative = signal-terminated on the pipe path; `pipe.rs::exit_code_from` maps Unix `ExitStatus::signal()` to `−sig`; the PTY path reduces signal termination to a plain exit code, see the exit_signal row), so alkssh sends the SSH `exit-status` request before channel close (POC-3: proven both directions via the cloned `Session::handle()` from the spawned serving task); `−1` (alktty's could-not-determine sentinel, alktty ADR-004 §4) → `exit-status(255)` | +| `exit_signal` | derived server-side from the exit chunk's negative code, `−N` → name via a Unix signal-number→name table; `core_dumped` never asserted (alktty discards `ExitStatus::core_dumped()`); no chunk change is needed or proposed (the encoding is alktty's documented contract, alknet ADR-055 lineage). POC-3 observed `−15` → `exit-signal("TERM")` live on the pipe path. PTY path: portable-pty 0.9's `ExitStatus` conversion yields `code = status.code().unwrap_or(1)` (**1**) for signal-terminated children, keeping the signal only in a separate humanized (`strsignal`) string field alktty's waiter discards, so faithful `exit-signal` on the PTY row needs the alktty backend to conform to its own ADR convention (upstream ask: alktty/portable-pty trackers; the alkssh contract adapts automatically) | ## Data-plane / limits contract - russh windows: `Config::window_size`, `maximum_packet_size`, `channel_buffer_size` cap per-channel memory; `Limits` caps - rekey volume; `inactivity_timeout` the session wall clock — the + rekey volume; `inactivity_timeout` the session wall clock, and the family bounded-resources convention maps onto these knobs plus the crate's own max-concurrent-channels (per session) and the per-length parse limits on exec commands. - alkcall bridge leg (when the family half is remote): channels-layer reassembly cap 1 MiB/channel, chunk max 16 MiB, per-identity - channel-count policy — an SSH channel bridged over `alk/channels` + channel-count policy, so an SSH channel bridged over `alk/channels` rides one reassembled byte stream per SSH channel; SSH's own multiplexing is why ADR-034 grants `alk/ssh` only stream_types `[0, 1]` ("SSH multiplexes internally, including its own control"). - Honest advertisement: `Config::methods` mirrors implemented auth; an unfeatured channel type is *declined at open*, never half-opened. - Vanilla clients see a clean failure — which is the contract. + Vanilla clients see a clean failure, which is the contract. ## alktunnels absence note @@ -126,7 +126,7 @@ decision: `composed::direct_egress` is the family precedent for exactly this bridging.) 2. **Registry-side bridge**: within an alkcall serving endpoint, - `register_tunnel_direct_openable` with a `DialFn` — but that puts + `register_tunnel_direct_openable` with a `DialFn`, but that puts alkssh-shaped work in the serving endpoint's registry, which inverts the door posture. Likely rejected on composability grounds; recorded here for the ADR to decide explicitly. @@ -136,17 +136,17 @@ decision: Three alkcall ADR constraints collide productively: - ADR-004 reserves `alk/ssh` → `SshAdapter` (custom protocol). -- ADR-034's per-ALPN table: `alk/ssh` gets stream_types `[0, 1]` — +- ADR-034's per-ALPN table: `alk/ssh` gets stream_types `[0, 1]`; SSH multiplexes internally, the channels layer is byte-blind to it. - ADR-042/051: "The hub never runs a handler for `alk/tty`, `alk/ssh`, - or `alk/tunnel`" — `alk/ssh` channels *relay payload-blind* between + or `alk/tunnel`": `alk/ssh` channels *relay payload-blind* between spokes; ACL fires at `channel/open`. ADR-009 additionally sketches a browser route: a WebTransport session to `/alk/ssh` where each bidi stream is handed to `SshAdapter::handle` as a `Connection`, with a WASM SSH parser speaking SSH over it. -Reading: `alk/ssh` is plausibly *SSH-over-channels* — the raw vanilla- +Reading: `alk/ssh` is plausibly *SSH-over-channels*: the raw vanilla- SSH byte stream carried between alkssh instances (edge termination at one end, mapped serving at the other), reused by the browser route. Whether the edge-terminated topology (russh at the edge, family seams @@ -160,11 +160,11 @@ phase 1 ADR-001 (ALPN pin, ledger rule 1). ## Open questions seeded for phase 1 - Session-channel dispatch precedence + the exec-command grammar - (git parse vs pipe cmd) — parse-then-fail-closed contract. + (git parse vs pipe cmd), a parse-then-fail-closed contract. - Shell command convention for `shell_request` (assembly policy vs wire-supplied; `wd`/`env` provenance). - `env_request` policy (decline-only vs allowlist). -- OQ: OpenSSH certificate auth (`auth_openssh_certificate`) — decline +- OQ: OpenSSH certificate auth (`auth_openssh_certificate`): decline until a use case. - OQ: username disposition (ignored entirely vs a routing hint). - Feature-graph names + the `session`-vs-`git`-vs-`dynamic-forward` diff --git a/docs/research/poc-1-auth-bridge.md b/docs/research/poc-1-auth-bridge.md index e2227e7..e5808bd 100644 --- a/docs/research/poc-1-auth-bridge.md +++ b/docs/research/poc-1-auth-bridge.md @@ -1,7 +1,7 @@ -# POC-1 findings — auth bridge + fail-closed declines end to end +# POC-1 findings: auth bridge + fail-closed declines end to end Status: Complete (phase 0). POC source: `/workspace/alkssh-poc1` -(standalone crate, disposable — never merges). All hypotheses in +(standalone crate, disposable, never merges). All hypotheses in `pocs.md` §POC-1 were confirmed; the doc below records the evidence, one correction to the phase 0 research docs, two API-shape facts, and the OQ seeds this POC produced. @@ -11,8 +11,8 @@ the OQ seeds this POC produced. - russh **0.64.1** from crates.io (the current published release; crates.io `newest_version` re-verified during the POC), default features off, crypto backend `ring`. Clean build under edition 2024, - rustc 1.99.0. The `ring` backend built with no external provisioning - — relevant data point for the pin ADR's crypto-backend question. + rustc 1.99.0. The `ring` backend built with no external provisioning, + a relevant data point for the pin ADR's crypto-backend question. - Dependency tree confirms the pin considerations in `russh.md`: `ssh-key 0.7.0-rc.11` exact-pinned and re-exported via `russh::keys::ssh_key`; `rand_core 0.10` in-tree. Key generation in @@ -22,20 +22,20 @@ the OQ seeds this POC produced. - Client side: russh client (`client::Handler`, `client::connect`) as the primary programmatic client, plus a stub `IdentityProvider` registered via fingerprints taken **from `ssh-keygen -lf` output** - (independent OpenSSH implementation) — making the parity check a + (independent OpenSSH implementation), making the parity check a real cross-implementation check, not a same-code self-check. - Vanilla client: OpenSSH 9.6p1 (`ssh`, `ssh -L`), isolated `UserKnownHostsFile`, `BatchMode`, `IdentitiesOnly`. ## Validated: all checks pass (31/31) -### 1. Fingerprint-format parity — confirmed, format is exactly the registered string +### 1. Fingerprint-format parity confirmed: format is exactly the registered string `public_key.fingerprint(HashAlg::Sha256).to_string()` (via the `ssh-key` re-export) yields `SHA256:` — byte-identical to `ssh-keygen -lf` on the same public key. (An +alphabet>`, byte-identical to `ssh-keygen -lf` on the same public key. (An earlier phrasing of this section called the encoding "url-safe"; the -observed strings and OpenSSH's own format use the standard alphabet — +observed strings and OpenSSH's own format use the standard alphabet, unpadded standard base64 is the invariant.) The `resolve_from_fingerprint` string compare works with values that were registered from OpenSSH tooling output. Parity was additionally @@ -47,7 +47,7 @@ self-describing; the assembly layer can register `SHA256:` strings taken from any standard issuer (ssh-keygen, alkcall-side computation via the same re-export) and the server-side compare holds. -### 2. Auth bridge end to end — confirmed as designed +### 2. Auth bridge end to end (confirmed as designed) - `auth_publickey` fires **after** russh's own signature verification (documented behavior observed: the vanilla key was accepted without @@ -59,14 +59,14 @@ via the same re-export) and the server-side compare holds. - `auth_publickey_offered` accept-and-let-decide works with OpenSSH's probe-then-sign flow: the hook fires (key seen, ownership unverified) before the signed attempt, and **does not count toward - `max_auth_attempts`** — consistent with russh's own doc warning + `max_auth_attempts`**, consistent with russh's own doc warning about keeping the count honest. - Username: ignored entirely for identity (keys resolve independently - of the username string sent by the client) — supports the + of the username string sent by the client), which supports the "ignore username entirely" OQ direction; `ssh anything@host` compatibility is unaffected. -### 3. Decline matrix — every method declined and proven +### 3. Decline matrix: every method declined and proven - `auth_none` / `auth_password` / `auth_keyboard_interactive` all observed declining (russh client forced each; vanilla client with @@ -84,10 +84,10 @@ Only `auth_publickey_offered` defaults to accept. The research assumed older-russh accept-defaults. The fail-closed discipline at 0.64.1 is therefore largely *inherited*; the family contract still requires implementing every hook explicitly (a future russh bump could change -defaults — the pin is a one-way door) and POC-1 proves the explicit +defaults; the pin is a one-way door) and POC-1 proves the explicit implementations end to end. -### 4. Bounded-resource auth behavior — `max_auth_attempts` semantics +### 4. Bounded-resource auth behavior: `max_auth_attempts` semantics The **initial `none` probe counts toward `max_auth_attempts`**: with a limit of 3, a BatchMode vanilla client got the `none` probe plus two @@ -101,7 +101,7 @@ signed declined attempts and was disconnected with mind (e.g. limit 10 default is comfortable; a small limit eats one attempt on every connection). -### 5. Fail-closed channel declines observable by vanilla ssh — confirmed +### 5. Fail-closed channel declines observable by vanilla ssh (confirmed) - `ChannelOpenHandle` **drop = auto-reject** sends `AdministrativelyProhibited` (verified in russh source @@ -109,16 +109,16 @@ signed declined attempts and was disconnected with explicit reject call needed anywhere: unfeatured channel types are handled by simply not accepting the handle. - Vanilla `ssh host` after successful auth: `channel 0: open failed: - administratively prohibited: Administratively prohibited`, exit 255 — + administratively prohibited: Administratively prohibited`, exit 255: clean diagnosis, no hang, and the **connection-level auth remains intact** (the decline is channel-scoped, not a disconnect). - Vanilla `ssh -L 127.0.0.1:P:127.0.0.1:P -N`: the local listener establishes; dialing through it opens a `direct-tcpip` channel that - is refused — the local dial gets TCP-level reset ("Connection reset + is refused, so the local dial gets TCP-level reset ("Connection reset by peer"), no data ever crosses. The server-side log shows the drop with the forward target. - Session remains usable after declines (further channel opens - correctly declined too — no state poisoning). + correctly declined too, with no state poisoning). ### 6. russh client API shapes on rejects (new facts for the mapping docs) @@ -133,7 +133,7 @@ signed declined attempts and was disconnected with auto-reject covers x11, direct-tcpip, forwarded-tcpip, direct-streamlocal uniformly. - `PublicKeyOrCertificate::PublicKey` is a struct variant - (`{ key, hash_alg }`) — small API detail when reading host keys in + (`{ key, hash_alg }`), a small API detail when reading host keys in `check_server_key`. - **Client-side plumb caveat (recorded for POC-2/3):** `Channel::wait()` on a just-opened client channel *before* `into_stream()` breaks the @@ -142,7 +142,7 @@ signed declined attempts and was disconnected with internally; do not consume the channel queue before converting to a stream. With that caveat respected, the end-to-end echo works. -### 7. Channel ↔ AsyncRead/AsyncWrite plumbing — confirmed +### 7. Channel ↔ AsyncRead/AsyncWrite plumbing (confirmed) `into_stream()` works on both sides: server accepts a session channel, `channel.into_stream()`, reads/writes in a plain @@ -154,17 +154,17 @@ POC-2 (dial lifecycle) and POC-3 (mapping bake-off) will reuse. ## Hypothesis verdicts (pocs.md §POC-1) - "`auth_publickey` → fingerprint string parity → resolve → accept; - unknown key → clean decline" — **confirmed**. + unknown key → clean decline" is **confirmed**. - "`auth_publickey_offered` accept-and-let-decide with OpenSSH - probe-then-sign" — **confirmed** (and confirmed it doesn't inflate + probe-then-sign" is **confirmed** (and confirmed it doesn't inflate the attempt counter). -- "Default auth impls are accept — every default must be overridden" - — **corrected**: at 0.64.1 only the offered hook defaults to accept. +- "Default auth impls are accept, so every default must be overridden" + is **corrected**: at 0.64.1 only the offered hook defaults to accept. Explicit implementations still required by the family contract and proven here. - "`ChannelOpenHandle` drop = auto-reject; declines observable by - vanilla ssh; clean diagnostics" — **confirmed**. -- "`Channel` ↔ AsyncRead/AsyncWrite (`into_stream`) works" — + vanilla ssh; clean diagnostics" is **confirmed**. +- "`Channel` ↔ AsyncRead/AsyncWrite (`into_stream`) works" is **confirmed**, with the wait-before-into_stream caveat. ## OQ seeds carried forward @@ -174,12 +174,12 @@ POC-2 (dial lifecycle) and POC-3 (mapping bake-off) will reuse. whether usernames are ever logged/matched (vanilla clients always send one). 2. `max_auth_attempts` sizing must account for the counting `none` - probe (see §4) — a config default note for the ADR. + probe (see §4), a config default note for the ADR. 3. Vanilla clients probe with `none` first; a future russh bump that - changes reject-defaults would be a silent-auth posture change — + changes reject-defaults would be a silent-auth posture change, which reinforces recording the russh pin as a deliberate one-way door. 4. `auth_openssh_certificate` override-decline exists but was not - exercised with a real certificate (no CA in the POC) — the + exercised with a real certificate (no CA in the POC), so the "decline initially" row stands unfalsified. ## Reference run @@ -187,13 +187,13 @@ POC-2 (dial lifecycle) and POC-3 (mapping bake-off) will reuse. Full output of a clean run at `pocs.md` commit time: 31 checks, 6 groups (fingerprint parity 2, russh client auth 9, channel declines 3, vanilla OpenSSH 11, max_auth_attempts 1, echo -plumbing 5) — all PASS. Vanilla diagnostic strings quoted above are +plumbing 5), all PASS. Vanilla diagnostic strings quoted above are verbatim from that run. ## References - `pocs.md` §POC-1 (plan), `auth-bridge.md` (design), `russh.md` (API - inventory — updated with the 0.64.1 default-impl correction) + inventory, updated with the 0.64.1 default-impl correction) - POC source: `/workspace/alkssh-poc1` (single `src/main.rs` + stub alkcall-shaped `IdentityProvider` in `authbridge` module) - russh 0.64.1 (crates.io, ring backend), ssh-key 0.7.0-rc.11 diff --git a/docs/research/poc-2-vault-keys.md b/docs/research/poc-2-vault-keys.md index 42bbea5..67cca93 100644 --- a/docs/research/poc-2-vault-keys.md +++ b/docs/research/poc-2-vault-keys.md @@ -1,7 +1,7 @@ -# POC-2 findings — client-side at-rest key handling (vault-backed storage) +# POC-2 findings: client-side at-rest key handling (vault-backed storage) Status: Complete (phase 0). POC source: `/workspace/alkssh-poc2` -(standalone crate, disposable — never merges). All hypotheses in +(standalone crate, disposable, never merges). All hypotheses in `pocs.md` §POC-2 were confirmed; the doc below records the evidence, the inner-format comparison, the Signer-seam dismissal (now with runtime + source evidence instead of the earlier reasoning-only @@ -13,16 +13,16 @@ and the OQ seeds this POC produced. - russh **0.64.1** (crates.io, ring backend) + **alkvault 0.1.0** (crates.io; its pinned surface: `EncryptedData`, `VaultServiceHandle`, `CURRENT_KEY_VERSION`), ssh-key **0.7.0-rc.11** exact-pinned and - re-exported through `russh::keys::ssh_key` (identical tree to POC-1's - — the two POCs exercised the same dependency graph). + re-exported through `russh::keys::ssh_key` (identical tree to POC-1's, + since the two POCs exercised the same dependency graph). - Server side: POC-1's loopback shape verbatim (fingerprint registry fed from `ssh-keygen -lf` output, stub `IdentityProvider`, `into_stream` echo server, no `wait()` before `into_stream`). - The adapter-shaped seam under test (`vaultkey` module in the POC): - - `KeySource { blob: Option }` — the assembly-layer + - `KeySource { blob: Option }`, the assembly-layer storage stand-in (`None` = absent material); - - `VaultKeyStore::load(&KeySource) -> Result` — - decrypt-at-dial: `vault.decrypt(blob)` → plaintext wrapped in + - `VaultKeyStore::load(&KeySource) -> Result`, the + decrypt-at-dial path: `vault.decrypt(blob)` → plaintext wrapped in `Zeroizing` at the seam → one-time `russh::keys::decode_secret_key(&plain, None)` → fingerprint computed in the same step. Every error shape maps to a distinct @@ -33,7 +33,7 @@ and the OQ seeds this POC produced. ## Validated: all checks pass (39/39) -### 1. At-rest shape — OpenSSH-encoded key inside alkvault `EncryptedData` confirmed; the `&str`-only API constraint is decisive +### 1. At-rest shape: OpenSSH-encoded key inside alkvault `EncryptedData` confirmed; the `&str`-only API constraint is decisive alkvault's `encrypt`/`decrypt` surface is **`&str` in, `String` out** (`VaultServiceHandle::encrypt(plaintext: &str, key_version: u32) -> @@ -44,9 +44,9 @@ dialed: | inner format | plaintext | blob ciphertext | key reconstruction at use | verdict | |---|---|---|---|---| -| **OpenSSH PEM** (unencrypted) | 387 B | 540 B | `decode_secret_key(plain, None)` — one call | **winner** | -| raw ed25519 seed (base64) | 32 B | 80 B | `Ed25519Keypair::from_seed(&seed) -> PrivateKey` (`From` exists) | works, smallest blob; drops comment/public metadata (pub is re-derived from seed — fine) | -| ssh-key self-encrypted PEM (passphrase, bcrypt KDF) | 449 B | 616 B | two decryptions: vault, then `decode_secret_key(pem, Some(pass))` | works but pointless — the passphrase is *itself* a stored secret, so the model just moves the same problem one level down; double-wrapping buys nothing | +| **OpenSSH PEM** (unencrypted) | 387 B | 540 B | `decode_secret_key(plain, None)`, one call | **winner** | +| raw ed25519 seed (base64) | 32 B | 80 B | `Ed25519Keypair::from_seed(&seed) -> PrivateKey` (`From` exists) | works, smallest blob; drops comment/public metadata (pub is re-derived from seed, which is fine) | +| ssh-key self-encrypted PEM (passphrase, bcrypt KDF) | 449 B | 616 B | two decryptions: vault, then `decode_secret_key(pem, Some(pass))` | works but pointless: the passphrase is *itself* a stored secret, so the model just moves the same problem one level down; double-wrapping buys nothing | | PKCS#8 PEM (unencrypted) | 246 B | 252 B | `encode_pkcs8_pem` (russh) at rest; `decode_secret_key(plain, None)` at use | works; loses the native comment/model round-trip; adds nothing over OpenSSH PEM | (All four rows produced the correct fingerprint through the full @@ -59,7 +59,7 @@ The **OpenSSH PEM inner wins** on honest grounds: ASCII natively (no encode layer), self-describing (algorithm + public key + comment embedded), vanilla-OpenSSH-tool-compatible at every boundary, and exactly one decode call from russh. The seed variant is a legitimate -size optimization the ADR can note (80 B vs 540 B blobs) — it couples +size optimization the ADR can note (80 B vs 540 B blobs), but it couples alkssh's storage to a fixed key shape though, whereas "a PEM file's bytes" works for every algorithm ssh-key can decode. This mirrors the alkhttp ADR-014 analogy exactly as hypothesized in `pocs.md`. @@ -69,17 +69,17 @@ At-rest hygiene, verified end to end: - `EncryptedData { key_version: 2, salt, iv, data }` serializes to stable JSON (serde), roundtrips, and decrypts to the byte-identical original PEM. The salt field is 32 B (44 b64 chars), the IV 12 B - (16 b64 chars) — pure wire-format confirmation against the type's + (16 b64 chars), pure wire-format confirmation against the type's documented shape. - AES-256-GCM opacity: the ciphertext's `data` field contains no recognizable substrings of the key (checked `OPENSSH`, `PRIVATE`, - `ssh-ed25519`, and a 30-byte interior slice of the PEM — none found + `ssh-ed25519`, and a 30-byte interior slice of the PEM; none found in ciphertext or the serialized JSON). - Fresh IV + fresh salt per encrypt: re-encrypting the identical plaintext produces entirely different blobs (no determinism trap for naive dedup code to stumble into). -### 2. Dial lifecycle — decrypt-at-dial, zeroization bounds, lock/unlock mid-session +### 2. Dial lifecycle: decrypt-at-dial, zeroization bounds, lock/unlock mid-session - The full dial through the adapter works: `load` → `authenticate_publickey` → `Success`, the server's @@ -95,10 +95,10 @@ At-rest hygiene, verified end to end: - Re-`unlock` with the same mnemonic → decrypt works again, same fingerprint. Lock/unlock cycles are side-effect-free on the key. - **Zeroization bounds** (source-verified in ssh-key 0.7.0-rc.11): - - `Ed25519PrivateKey` zeroizes on `Drop` (`private/ed25519.rs` — + - `Ed25519PrivateKey` zeroizes on `Drop` (`private/ed25519.rs`, `impl Drop { self.0.zeroize() }`), so the decoded key's secret bytes are cleaned up by ssh-key itself when the `PrivateKey` (or - clones of it — the type is `Clone` with zeroizing storage) drops; + clones of it, the type is `Clone` with zeroizing storage) drops; - alkvault zeroizes its own key material (`EncryptionKey` `#[zeroize(drop)]`, mnemonic phrase via `Zeroizing`, `lock()` purges seed + cache); @@ -107,32 +107,32 @@ At-rest hygiene, verified end to end: from the vault's return until the adapter takes ownership. The adapter shape proven here wraps it in `Zeroizing` at the seam (owning the move), so the adapter-bound bytes zeroize on - drop — but the pre-wrap window is the vault's API shape to close. + drop, but the pre-wrap window is the vault's API shape to close. → OQ seed below. -### 3. Failure matrix — every failure mode observed; all fail closed +### 3. Failure matrix: every failure mode observed, all fail closed | disturbed condition | observed | adapter mapping | |---|---|---| -| vault locked | `VaultServiceError::VaultLocked` | `LoadError::Locked` — no dial | -| blob absent at source (assembly layer has no record) | no vault call needed | `LoadError::Absent` — no dial | -| wrong mnemonic (unrelated seed) | `VaultServiceError::Encryption("decryption error: aead::Error")` — a *decryption* failure, distinguishable from `Locked` | no dial | +| vault locked | `VaultServiceError::VaultLocked` | `LoadError::Locked`; no dial | +| blob absent at source (assembly layer has no record) | no vault call needed | `LoadError::Absent`; no dial | +| wrong mnemonic (unrelated seed) | `VaultServiceError::Encryption("decryption error: aead::Error")`, a *decryption* failure, distinguishable from `Locked` | no dial | | tampered ciphertext (one flipped byte) | GCM integrity failure (same decrypt-error family) | no dial | -| `key_version = 0` (reserved/legacy) | `VaultServiceError::InvalidPath` — the vault refuses before touching crypto | no dial | +| `key_version = 0` (reserved/legacy) | `VaultServiceError::InvalidPath`; the vault refuses before touching crypto | no dial | | `key_version = 7` (future, undecodable) | derivation-path error | no dial | | healthy rotation v2→v3 | `rotate()` → new blob stamped `key_version: 3`, decrypt yields the identical PEM, decoded fingerprint identical | dial succeeds | The wrong-mnemonic/tampered cases land in -`VaultServiceError::Encryption` *as strings* — the error taxonomy at +`VaultServiceError::Encryption` *as strings*, and the error taxonomy at the adapter is ours to shape (distinct variants), but the root causes are distinguishable today via the variant + message. The `InvalidPath` behavior for version < 2 is deliberate (v1 is the TS_PBKDF2 legacy the vault cannot decrypt; rotation re-encryption is the migration path, -per alkvault's own docs) — consistent with alkvault's ADR-021 +per alkvault's own docs), consistent with alkvault's ADR-021 version-indexed rotation model. -### 4. Signer-seam dismissal — confirmed, now with runtime and source evidence +### 4. Signer-seam dismissal: confirmed, now with runtime and source evidence -`pocs.md` expected the answer "no — recorded so the ADR can dismiss it +`pocs.md` expected the answer "no, recorded so the ADR can dismiss it with evidence". Evidence assembled: - **The trait exists but has no provided implementation**: russh @@ -141,50 +141,50 @@ with evidence". Evidence assembled: hash_alg, to_sign)`), and `Handle::authenticate_publickey_with(user, ssh_key::PublicKey, hash_alg, &mut S)` accepts it. The doc comment claims "Currently, this crate only provides an implementation for an - SSH agent" — **that is stale**: there is no `impl Signer` for + SSH agent", but **that is stale**: there is no `impl Signer` for `AgentClient` (or anything else) anywhere in the 0.64.1 tree. A custom signer must be built by hand. - **The wire contract is non-obvious and hand-rolled**: the flow is probe (`has_sig=0`, `write_auth_request`) → server `USERAUTH_PK_OK` → client builds `session_id || userauth-prefix` (`client_make_to_sign`) → `Reply::SignRequest` carries the full buffer → the signer returns - **`to_sign || string{signature}`** — the original buffer with the - signature length-prefixed as an SSH string — and the client splices + **`to_sign || string{signature}`** (the original buffer with the + signature length-prefixed as an SSH string) and the client splices `buffer[i0..]` as the packet payload. (First attempt appended the raw signature without the outer length prefix: the server silently - misparsed and declined — `USERAUTH_FAILURE` — *without the + misparsed and declined (`USERAUTH_FAILURE`), *without the `auth_publickey` hook ever firing*. Signature-verification failures happen inside russh before any handler hook, which is also a clean fail-closed property worth noting for the mapping docs.) - **Runtime proof that the seam works anyway**: `VaultSigner` (decrypt-per-sign against the vault) authenticated successfully - end-to-end via `authenticate_publickey_with` — the splice convention + end-to-end via `authenticate_publickey_with`: the splice convention replicated, `PrivateKey: signature::Signer::try_sign` + `.encoded()` (i.e. `sig` → SSH wire blob) then length-prefixed into the returned buffer. - **Locked-vault fail-closed on the Signer path, too**: with the vault locked, the sign-time decrypt fails and the auth call itself errors - (`Err` from `authenticate_publickey_with`) — no half-auth, no + (`Err` from `authenticate_publickey_with`): no half-auth, no fallback. - **Verdict: dismissed for the vault path.** The vault is in-process: decrypted key bytes necessarily exist client-side at *either* dial - time or sign time — the Signer seam buys no custody improvement, and + time or sign time; the Signer seam buys no custody improvement, and decrypt-per-sign merely multiplies GCM+parse work per signature while keeping *more* plaintext copies alive (per-signature) rather than fewer. The seam's genuine home is *external* signer processes (ssh-agent), which the family treats as out of scope. The ADR can record: one-time `decode_secret_key` at dial; `Signer` not taken. -### 5. Host keys ride the same model — server bootstrap + vanilla compatibility +### 5. Host keys ride the same model: server bootstrap + vanilla compatibility - The server's host key was generated in-POC, vault-encrypted, and decrypted-at-construction into `Config::keys` through the same - `VaultKeyStore::load` the client dial uses — one model, both sides. + `VaultKeyStore::load` the client dial uses: one model, both sides. Vanilla OpenSSH authenticated against that server, the `known_hosts` fingerprint (written by the vanilla client) matched the vault host key's fingerprint exactly, and the russh client's `check_server_key` observed the same fingerprint. -- The client's *pin* — the expected host fingerprint — is **public +- The client's *pin* (the expected host fingerprint) is **public config, not vault material** (`check_server_key` policy + the known-hosts entry); only the server's *private* host key is held secret. The ADR's "assembly configures `Config::keys`" note can now @@ -192,39 +192,39 @@ with evidence". Evidence assembled: - **Vanilla-tool compatibility of the plaintext**: the decrypted client-key PEM written to a 0600 temp file was accepted by vanilla `ssh -i` directly (auth succeeded; `Permission denied` never - appeared — exit 255 only because the POC server intentionally serves + appeared, with exit 255 only because the POC server intentionally serves no exec). The at-rest bytes are, at rest *and* in motion, plain OpenSSH key material. - Locked vault at server bootstrap → `LoadError::Locked` → **no - server is constructed** — the fail-closed posture extends to the + server is constructed**; the fail-closed posture extends to the host-key boot path. ## Hypothesis verdicts (pocs.md §POC-2) - "OpenSSH private-key encoding as the inner format; alkvault - `encrypt`/`decrypt` (`EncryptedData`) as the outer wrapper" — + `encrypt`/`decrypt` (`EncryptedData`) as the outer wrapper" is **confirmed**, and strengthened by the 4-way comparison table (the seed-only variant as a size optimization; the ssh-key self-encrypted variant recorded as a double-wrapping antipattern). - "decrypt-at-dial → one-time `decode_secret_key` → zeroized handling - for the dial's lifetime" — **confirmed** (ssh-key zeroizes key bytes + for the dial's lifetime" is **confirmed** (ssh-key zeroizes key bytes on drop; adapter owns plaintext zeroization at the seam; vault in-memory state self-purges on `lock`). -- "what happens locked / absent / rotation" — **confirmed as +- "what happens locked / absent / rotation" is **confirmed as fail-closed** with distinct, mappable error shapes; healthy rotation (v2→v3) preserves key identity. -- "Signer-shaped seam still worth anything — expected: no" — +- "Signer-shaped seam still worth anything, expected answer: no" is **dismissed with evidence** (no provided impl; hand-rolled splice convention; in-process vault gains nothing; runtime proof the seam works anyway). -- "Server and client host-key material riding the same model" — +- "Server and client host-key material riding the same model" is **confirmed** end to end, including vanilla-tool compatibility and locked-at-bootstrap fail-closed. ## API facts for the ADRs (all verified against crates.io sources) - **alkvault 0.1.0**: `VaultServiceHandle` is a sync, cloneable handle - (interior `Arc`; short-held locks — no tokio coupling); + (interior `Arc`; short-held locks, no tokio coupling); `unlock_new(word_count)` returns the phrase as `Zeroizing`; `encrypt(&str, u32)` / `decrypt(&EncryptedData) -> Result` / `rotate(&EncryptedData, to_version)`; current version 2, versions @@ -235,16 +235,16 @@ with evidence". Evidence assembled: `ssh-key/encryption` feature), PuTTY PPK, PKCS#5 and PKCS#8 (+ the `BEGIN PRIVATE KEY` / `BEGIN ENCRYPTED PRIVATE KEY` PEM cases); `keys::encode_pkcs8_pem[_encrypted]` exist for writers (PKCS#8 - support lives in *russh*, not in ssh-key — ssh-key rc11 has no + support lives in *russh*, not in ssh-key, which at rc11 has no `pkcs8` feature; the research note about ssh-key-own-PKCS#8 does not apply at this pin). `PrivateKey: signature::Signer`, `Signature::encoded()` via `signature::SignatureEncoding`; russh's own signing (`helpers::sign_with_hash_alg`) is exactly - `try_sign(keypair, msg)?.encoded()` — a vault signer can mirror it, + `try_sign(keypair, msg)?.encoded()`, which a vault signer can mirror, but `helpers` (and its `EncodedExt`) is crate-private; the public path is `russh::keys::signature::Signer` + `russh::keys::ssh_encoding::Encode`. - **Server-side auth pipeline**: a failed/malformed signature produces - `USERAUTH_FAILURE` *without* calling `auth_publickey` — handler + `USERAUTH_FAILURE` *without* calling `auth_publickey`, so handler hooks only fire on cryptographically valid signatures (POC-1 established the "after verification" half; POC-2 adds "invalid sigs never reach the handler at all"). @@ -255,7 +255,7 @@ with evidence". Evidence assembled: ## OQ seeds carried forward -1. **alkvault `decrypt` returns plain `String`** — a zeroization gap at +1. **alkvault `decrypt` returns plain `String`**: a zeroization gap at the seam (the vault's own key material is zeroized; the decrypted *plaintext* is not until the adapter wraps it). Candidate fixes: alkvault returns `Zeroizing`, or the alkssh ADR documents @@ -267,17 +267,17 @@ with evidence". Evidence assembled: `InvalidPath` for unknown versions is the fail-closed behavior). Rotation (at-rest version - bump, key constant) is a storage-side operation — proven to + bump, key constant) is a storage-side operation; proven to preserve key identity (consistent with alkvault's own version-indexed rotation decision, alkvault ADR-021). 3. **One vault, many roles?** The POC held client dial keys *and* the server host key in one vault (one mnemonic). Whether the assembly layer should run separate vault instances per role (client vs server custody) is an assembly policy question, not an alkssh - design constraint — recorded so phase 1 doesn't bake it in. + design constraint, recorded so phase 1 doesn't bake it in. 4. **Never persist decrypted plaintext**: the vanilla-tool check wrote a decrypted key to a 0600 temp file (then zeroized and - deleted it) — that is a test-only demonstration of the byte format. + deleted it); that is a test-only demonstration of the byte format. Adapters do not spill plaintext to disk; the vanilla-compatible PEM is handed to processes only via the assembly layer if ever. @@ -285,13 +285,13 @@ for unknown Full output of a clean run at findings time: **39 checks, 5 groups** (at-rest shape 11, dial lifecycle 8, failure matrix 10, Signer seam 3, -host key + vanilla 7) — all PASS. `overall=true`, exit 0. +host key + vanilla 7), all PASS. `overall=true`, exit 0. ## References -- `pocs.md` §POC-2 (plan), `auth-bridge.md` §The client side (design — +- `pocs.md` §POC-2 (plan), `auth-bridge.md` §The client side (design, updated with a pointer to these verdicts), `russh.md` - (`client::Handle` authenticate methods — updated with the + (`client::Handle` authenticate methods, updated with the Signer-verdict pointer) - POC-1: `poc-1-auth-bridge.md` + `/workspace/alkssh-poc1` (server loopback shape reused) diff --git a/docs/research/poc-3-mapping-bakeoff.md b/docs/research/poc-3-mapping-bakeoff.md index dfd1df2..8779a17 100644 --- a/docs/research/poc-3-mapping-bakeoff.md +++ b/docs/research/poc-3-mapping-bakeoff.md @@ -1,26 +1,26 @@ -# POC-3 findings — the mapping bake-off: channel bridge through real sibling seams +# POC-3 findings: the mapping bake-off, channel bridge through real sibling seams Status: Complete (phase 0). POC source: `/workspace/alkssh-poc3` -(standalone crate, disposable — never merges). All four stages ran +(standalone crate, disposable, never merges). All four stages ran against **vanilla clients** (OpenSSH 9.6 `ssh -L`/`-R`, real `git` 2.43, real `curl` 8.5) and the **published crates.io forms** of every -sibling — no path dependencies, no in-family forks. The doc records +sibling, with no path dependencies and no in-family forks. The doc records the evidence: three corrections to `mapping-table.md`, the russh 0.64.1 runtime facts phase 1 must code around (the inline-dispatch rule), the identity/scope live check, and the OQ seeds. ## Setup exercised -- russh **0.64.1** (crates.io, ring backend — the same published tree +- russh **0.64.1** (crates.io, ring backend; the same published tree POC-1/POC-2 pinned; re-verified still the newest release). Same dependency tree as both prior POCs across all four stages. - Sibling seams, published forms: **alkgit 0.1.0 + alkgit-substrate 0.1.0** (the shipped backend composition, with - `alkstore-mem` engine + `alkblobs::Store::mem_only()` object pool — + `alkstore-mem` engine + `alkblobs::Store::mem_only()` object pool, the substrate's own mem-assembly shape, `SubstrateAssembly::mem`), - **alksocks 0.3.1** (`local` feature — the shipped `tcp_dial`), - **alktunnels 0.4.1** (`local` feature — the shipped `local_dial`), - **alktty 0.8.1** (`local` feature — the shipped `LocalTtyBackend` + **alksocks 0.3.1** (`local` feature, the shipped `tcp_dial`), + **alktunnels 0.4.1** (`local` feature, the shipped `local_dial`), + **alktty 0.8.1** (`local` feature, the shipped `LocalTtyBackend` over portable-pty), **alkcall 0.10.1** (channels + identity types). - Servers per stage were built as **feature-shaped builds**: stage A's server serves only git-parseable exec (a git-only build), stage B's @@ -33,7 +33,7 @@ rule), the identity/scope live check, and the OQ seeds. - POC mechanics: single binary, stage functions returning check reports (POC-1's harness); full clean runs reproduced twice. -## Stage A — git exec mapping (24/24 PASS) +## Stage A: git exec mapping (24/24 PASS) ### 1. Vanilla git works through the seam with zero seam fixups @@ -47,7 +47,7 @@ vanilla git (GIT_SSH_COMMAND) → OpenSSH → russh server Limits, registry, refs, packgen, ingest, false, pipeline) ``` -with the **real alkgit-substrate mem composition** as the backend — +with the **real alkgit-substrate mem composition** as the backend: registry/ref-rows/packgen/ingest, an in-memory alkstore engine, and an in-memory object pool. The published alkgit seam needed no wrapper, no adapter glue beyond the command parse, and no dialect nudges. The @@ -56,9 +56,9 @@ ADR work) is confirmed. ### 2. The SSH exec-command grammar (the alkssh-side parse) -What real OpenSSH git actually sends: `git-upload-pack '/owner/demo'` -— single-quoted repo path with a leading `/` (the parse strips quotes -and the leading slash; no `\0host=` rides the SSH exec — the +What real OpenSSH git actually sends: `git-upload-pack '/owner/demo'`, +a single-quoted repo path with a leading `/` (the parse strips quotes +and the leading slash; no `\0host=` rides the SSH exec, so the mapping-table's "same grammar as preamble.rs, without `\0host=`" is exactly right). The POC grammar table (all rows verified): @@ -66,14 +66,14 @@ exactly right). The POC grammar table (all rows verified): |---|---| | `git-upload-pack '/r'` / `git-receive-pack '/r'` / `git-upload-pack /r` | git parse, dispatched | | `git-send-pack '/r'`, `git-upload-archive '/r'` | declined (unknown service, honest surface) | -| `git-upload-pack` (service-only, no repo) | declined — SSH exec must carry the repo | +| `git-upload-pack` (service-only, no repo) | declined: SSH exec must carry the repo | | `uptime` (non-git) | declined parse-then-fail, no resource alloc | -### 3. Subsystem mapping row — corrected at the detail level +### 3. Subsystem mapping row (corrected at the detail level) `mapping-table.md` says git subsystem names "arrive as either" (exec or subsystem). On the SSH wire a subsystem request carries **only the -name** — no repository is available to resolve. The POC proves the +name**, and no repository is available to resolve. The POC proves the forced decline: `subsystem("git-upload-pack")` and `subsystem("sftp")` both fail cleanly with a channel failure, with the finding recorded: *over SSH, git always uses exec; a subsystem git request can never @@ -85,21 +85,21 @@ The mapping-table row needs the detail fix. - **Server handler callbacks run inline inside the session's packet loop.** Blocking inside `exec_request`/`pty_request`/`shell_request` (or any handler) suspends packet processing for the *entire - connection* — the awaited channel data is only routed by that same + connection*: the awaited channel data is only routed by that same loop, so an inline serve **deadlocks** (receive-pack pushes bytes immediately after the exec request). Confirmed by construction: the serving entry is spawned and the handler returns immediately. This is the binding implementation constraint for the alkssh adapter: **every serving path is `tokio::spawn` + handler returns fast**. - The spawned task gets exit reporting through - **`Session::handle()`** — a cloneable server-side `Handle` + **`Session::handle()`**, a cloneable server-side `Handle` carrying `data` / `extended_data` / `eof` / `close` / `exit_status_request` / `exit_signal_request`. The exit-status mapping row is implementable exactly as the table prescribes: send `exit-status` before `close` from the serving task. - The accepted-but-held `Channel` can be stored in the handler and taken later in the request callback (`channels.remove(&id)` → - `into_stream()` in the spawned task) — no `wait()` anywhere + `into_stream()` in the spawned task), with no `wait()` anywhere (POC-1's caveat holds server-side too). ### 5. Exit-status fidelity, denial fidelity, vanilla diagnostics @@ -109,7 +109,7 @@ The mapping-table row needs the detail fix. protocol byte* (no existence oracle) and vanilla git reports `fatal: remote error: access denied: owner/absent`, exit 128. - A russh client exec against the absent repo observed - **`ExitStatus{1}`** after the denial band — the exit-status flow is + **`ExitStatus{1}`** after the denial band; the exit-status flow is proven in both directions, including the failure leg. - The git-only build's decline matrix held under vanilla ssh: `ssh host uptime` failed with the parse-then-fail decline logged; @@ -117,7 +117,7 @@ The mapping-table row needs the detail fix. replies (russh client observed `ChannelMsg::Failure`); the fail- closed feature matrix of the mapping table is live end to end. -## Stage B — SOCKS5-in-channel (6/6 PASS) +## Stage B: SOCKS5-in-channel (6/6 PASS) ### 1. `serve_socks5_over` works over a russh session channel @@ -129,32 +129,32 @@ Construction: server accepts the session channel, spawns the domain-form targets). Client side: a local bridge listener pumps each accepted TCP socket bidirectionally into one authenticated session channel. **Real `curl --socks5-hostname` through the whole chain** -returned the endpoint body byte-identically — CONNECT over the SSH +returned the endpoint body byte-identically: CONNECT over the SSH wire, dialed at the server through the family seam. -### 2. OpenSSH's own `-D` never speaks SOCKS on the wire — mapping row corrected +### 2. OpenSSH's own `-D` never speaks SOCKS on the wire (mapping row corrected) This is a **mapping-table correction**: vanilla `ssh -D` performs the SOCKS5 handshake *client-side* and then opens plain **`direct-tcpip` channels** per connection. The `session` → SOCKS-in-channel row is therefore the family-native client shape (an embedder/alktty-style client speaking SOCKS5 on the channel), not what a vanilla `ssh -D` -emits — vanilla `-D` traffic takes the stage-C row (direct-tcpip → +emits; vanilla `-D` traffic takes the stage-C row (direct-tcpip → egress). Both rows are real; the table should say which client shape each serves. (POC evidence: this stage's proxy uses the session channel; stage C's `-L`-style row serves forwarded and SOCKS-originated direct-tcpip alike.) -### 3. The dispatch split is real — and needs a bounded detect window +### 3. The dispatch split is real and needs a bounded detect window Parse-then-route (stage A) and SOCKS-detect (this stage) are the two session-channel dispatch modes the mapping table predicted, and both work. The detect runs in a spawned task with a bounded window (500 ms in the POC): the first byte decides the route; a silent channel is declined. Two consequences for the ADR: (a) the detect must not block -inline (the §A.4 deadlock rule applies — the sniffed byte arrives via +inline (the §A.4 deadlock rule applies: the sniffed byte arrives via the same loop that awaits it); (b) the *detect window* is a design -shape to pin — an OQ seed: whether the dispatch is a first-byte sniff, +shape to pin, an OQ seed: whether the dispatch is a first-byte sniff, an in-band preamble, or an exec-request marker chosen by the assembly. ### 4. Refusals are RFC-faithful through the seam @@ -165,15 +165,15 @@ curl exits **97** (proxy-connection-not-allowed class) with no data crossing. Backpressure/throughput: **8 MiB through the session-channel proxy in ~585 ms** (in-process route; the 1 MiB channels-reassembly leg is not on this path). Non-SOCKS first byte on a session channel → -declined, logged — no HTTP serving happens on the SOCKS route. +declined, logged; no HTTP serving happens on the SOCKS route. -## Stage C — direct-tcpip via alktunnels route 1 (8/8 PASS) +## Stage C: direct-tcpip via alktunnels route 1 (8/8 PASS) ### 1. The bridged route works end to end with the published crates Route 1 (adapter-side bridge) exercised as the mapping table's route note describes: the russh server holds a **real `ChannelClient`** into -a family fabric — an in-process `alk/channels` connection whose +a family fabric, an in-process `alk/channels` connection whose serving side (the `ChannelsAdapter` accept end) registers `ChannelOperations` + `register_tunnel_direct_openable` with the shipped `alktunnels::local::local_dial()` injected. Per vanilla `ssh @@ -188,27 +188,27 @@ channel_open_direct_tcpip → TunnelSession::open_direct( Vanilla `ssh -L` + `curl` to the bridged port returned the endpoint body byte-identically; **8 MiB through the bridge in ~333 ms**. The `direct_egress` precedent in alksocks' `composed` feature is the same -shape — the family precedent survives contact with the SSH door. +shape; the family precedent survives contact with the SSH door. ### 2. Failure shapes at both ends of the bridge - Fabric open failure → the SSH channel is rejected **explicitly** (`ChannelOpenHandle::reject(ConnectFailed)`); accept is possible only after `open_direct` succeeds because `accept(mut self)` - consumes the handle — so the handle moves into the spawned task. + consumes the handle, so the handle moves into the spawned task. A vanilla client dials a dead target and gets a clean curl failure (exit 56) instead of a hang. - The `-R` ingress direction is **declined** on this egress-only build: `Handler::tcpip_forward` returns false (the global request refuses), vanilla `ssh -R` dies with `remote port forwarding failed for listen port` (exit 255), and the event log records the - refusal — honest advertisement, gate-before-wire. + refusal: honest advertisement, gate-before-wire. - Unfeatured session-channel opens drop (auto-reject) unchanged from POC-1 on this and the other stage builds. ### 3. Identity/scope propagation observed live -The direct establisher's identity witness recorded **`u-writer`** — +The direct establisher's identity witness recorded **`u-writer`**; the SSH-authenticated identity rides the transport connection into the channels layer (CF-005 propagation), and the establisher's per-call auth (CF-006) sees it. The visible-surface invariant's @@ -227,30 +227,30 @@ only real lift, and the alkforge assembly will build exactly this): transport; `ChannelClient::from_connection` on the alkssh side with the identity attached before dialing. -## Stage D — session/pty mapping (13/13 PASS) +## Stage D: session/pty mapping (13/13 PASS) -### 1. The mapping is a translation bridge, not a pump — mapping-table note confirmed with a shape +### 1. The mapping is a translation bridge, not a pump (mapping-table note confirmed with a shape) alktty's data plane is **chunk-framed in both directions** -(STREAM_STDIN/STDOUT/STDERR/CTRL_IN/CTRL_OUT, 5-byte header + payload -— `alktty::wire`). The SSH mapping therefore wraps the channel with +(STREAM_STDIN/STDOUT/STDERR/CTRL_IN/CTRL_OUT, 5-byte header + payload; +`alktty::wire`). The SSH mapping therefore wraps the channel with two small adapters: SSH data/EOF → stdin chunks (+ zero-length EOF sentinel); alktty stdout chunks → SSH data, stderr chunks → SSH `extended_data(1)`, ctrl-out exit chunk → SSH `exit-status` request + close. `drive_session_pre_negotiated(writer, reader, NegotiateRequest -built locally, backends, ownership, identity)` — the exact producer -construct the mapping table names — then runs the real session. Proven +built locally, backends, ownership, identity)`, the exact producer +construct the mapping table names, then runs the real session. Proven against a **real PTY** (`LocalTtyBackend` over portable-pty): - `pty_request` args → `TerminalParamsWire` (`term`/cols/rows/ - pixels; SSH modes → the reserved `{}` JSON — an alktty-side - reservation, alktty OQ-44; modes are deferred there) — live: bash on + pixels; SSH modes → the reserved `{}` JSON, an alktty-side + reservation, alktty OQ-44; modes are deferred there); live: bash on the PTY answered `stty size` with the requested 24×80. - Keystrokes ride in, PTY echo and shell output ride out (real bash prompt captured in the run output). - `window_change_request` → `ControlMessage::Resize` as a CTRL_IN chunk → the PTY resized: `stty size` flipped to `40 120` after the - client sent `window_change(120, 40)` — the mapping row proven + client sent `window_change(120, 40)`, the mapping row proven behaviorally. - Pipe-mode `exec_request`: stdout rode SSH data, stderr rode SSH `extended_data(1)` (client received both distinctly), exit @@ -259,11 +259,11 @@ against a **real PTY** (`LocalTtyBackend` over portable-pty): local backend produces. - `signal(TERM)` → `ControlMessage::Signal` CTRL_IN chunk → the backend killed the session; the exit chunk carried the negative - code and the SSH reply was an **`exit-signal`** request — the + code and the SSH reply was an **`exit-signal`** request, matching the mapping-table's "only reachable in signal-termination cases" row, now with live wire evidence. - The git-parseable exec declined in the session-only build - (parse-then-route: the git parse is the `git` feature's gate) — + (parse-then-route: the git parse is the `git` feature's gate); the feature-split failure matrix holds in this build too. ### 2. Signal/exit fidelity over the alktty exit-code convention @@ -272,14 +272,14 @@ The exit chunk is `{"type":"exit","code":N}` (alktty ADR-004, ported from alknet ADR-055). Findings of this stage, verified against the published sources: -- **Pipe path — the negative code is the signal encoding.** Pipe-mode +- **Pipe path: the negative code is the signal encoding.** Pipe-mode signal-terminated children arrive as the negative signal number verbatim from std's `ExitStatusExt` (`pipe.rs::exit_code_from` maps `ExitStatus::signal()` to `−sig`; the alknet ADR-055 convention). So SSH `exit-signal` needs no wire change: the adapter's check - mapped `−15` → `exit-signal("TERM")` — the name derivation is a + mapped `−15` → `exit-signal("TERM")`; the name derivation is a server-side number→name table lookup, not an alktty ask. -- **PTY path — the signal number does not survive the backend.** +- **PTY path: the signal number does not survive the backend.** portable-pty 0.9's `ExitStatus` conversion records `code = status.code().unwrap_or(1)` → **1** for signal-terminated children, keeping the signal only in a separate humanized @@ -288,16 +288,16 @@ published sources: through portable-pty 0.9's trait surface). Alktty's own PTY signal tests assert `code != 0`, never the encoding. Consequence: faithful `exit-signal` for signal-killed PTY sessions needs the alktty - backend to conform to its own ADR's negative-code convention — an - alktty/portable-pty ask with tty/pty-grounded justification (upstream + backend to conform to its own ADR's negative-code convention, +an alktty/portable-pty ask with tty/pty-grounded justification (upstream trackers; the alkssh mapping adapts automatically, no hedge needed). - **The `−1` sentinel** (backend wait failure) collides with a real - SIGHUP termination (signal 1) — documented in alktty ADR-004 §4, + SIGHUP termination (signal 1), documented in alktty ADR-004 §4, with an additive `exit_error` control type as its recorded extension - seam. Alkssh renders `−1` as `exit-status(255)` — it fabricates no + seam. Alkssh renders `−1` as `exit-status(255)`, fabricating no signal for a wait failure. - **Pipe-mode signals are pid-only** (a SIGTERM to `sh -c "sleep 60"` - leaves the orphaned `sleep` holding the stdout pipe — no exit chunk + leaves the orphaned `sleep` holding the stdout pipe, with no exit chunk until the whole group dies); PTY-mode signals reach the process group (correct terminal semantics). The signal row's fidelity depends on the tty/pipe mode split the table already notes. @@ -306,38 +306,38 @@ published sources: - russh client has `Channel::exec()` (there is no `request_exec`); pty/subsystem request *failures* surface as `ChannelMsg::Failure` - via `wait()` — **not** as `Err` from the request call; the caller + via `wait()` (not as `Err` from the request call); the caller must read the channel until Success/Failure. - `window_change`/`signal` are fire-and-forget (no want-reply), and must be sent **before** `into_stream()` (after the conversion the - Channel is consumed) — server-side they carry no SSH reply, so the + Channel is consumed); server-side they carry no SSH reply, so the handler must not send one. - The server handler loop dispatches `CHANNEL_SUCCESS`-style replies only through explicit handler calls; request-level failure replies in stage A/D all used `Session::channel_failure`. - The channel message loop client-side delivers interleaved - Data/ExtendedData/Success/ExitStatus on one queue — drain to the + Data/ExtendedData/Success/ExitStatus on one queue; drain to the expected message, `continue` past the rest (the POC hit the break-on-first-message trap twice; noted so the alkssh consumer half doesn't). ## Hypothesis verdicts (pocs.md §POC-3) -- "one-seam-per-mapping hypothesis" — **confirmed** across all four +- "one-seam-per-mapping hypothesis" is **confirmed** across all four seams with zero seam fixups; the mapping glue is adapter-side (command parse, chunk translation, handle plumbing). -- "parse-then-route dispatch" — **confirmed** (stage A), with the +- "parse-then-route dispatch" is **confirmed** (stage A), with the SOCKS-detect variant confirmed in stage B plus the bounded-window and spawn-not-block constraints. -- "EOF/close/signal fidelity end to end" — **confirmed**: exec → +- "EOF/close/signal fidelity end to end" is **confirmed**: exec → serve → exit-status (0 and 1 legs), exit-signal for signal termination, close mapping, refusal diagnostics at both bridge ends. -- "stderr semantics in pipe vs PTY mode" — **confirmed** exactly as +- "stderr semantics in pipe vs PTY mode" is **confirmed** exactly as the table describes (separate extended_data in pipe; merged under PTY). -- "throughput sanity" — **confirmed** with margin: 8 MiB in ~0.3–0.6 s +- "throughput sanity" is **confirmed** with margin: 8 MiB in ~0.3–0.6 s per bridged route. - "stage D may surface PTY-merge stderr surprises that reshape the - request-level mapping rows" — partially: no surprises in the + request-level mapping rows": partially. No surprises in the PTY/pipe split, but the **signal/exit-fidelity details** and the **translation-bridge shape** (two adapters, not a pump) do reshape the session row's implementation notes. @@ -345,7 +345,7 @@ published sources: ## Corrections recorded against phase 0 research 1. `mapping-table.md` §channel-type rows: the git **subsystem** claim - ("git-upload-pack etc. arrive as either") is wrong over SSH — + ("git-upload-pack etc. arrive as either") is wrong over SSH: subsystem cannot carry the repo id, so even a git service name must decline. Git over SSH is exec-only. 2. `mapping-table.md`: `ssh -D` from a vanilla client does **not** @@ -356,19 +356,19 @@ published sources: alktty bridge is a **translation bridge** (chunk framing both directions), not a byte pump; plus the exit/signal fidelity findings above (pipe-path `−N` encoding holds; PTY path reduces - signal termination to a plain code — portable-pty's exit surface). + signal termination to a plain code, portable-pty's exit surface). ## OQ seeds carried forward 1. Session-channel dispatch shape for SOCKS (first-byte sniff vs in-band preamble vs exec marker; the detect window's bound). 2. Exit/signal fidelity over the alktty exit-code convention (the - `−N` encoding holds on the pipe path — name derivation is + `−N` encoding holds on the pipe path, and the name derivation is server-side; the PTY path's signal-identity loss is an alktty/portable-pty matter; pipe-mode pid-only vs PTY process-group scope). -3. Shell command convention (already seeded; the POC's stand-in — - `bash` for shell, `sh -c ` for exec — is an explicit assembly +3. Shell command convention (already seeded; the POC's stand-in, + `bash` for shell and `sh -c ` for exec, is an explicit assembly policy, not wire-derived). 4. The `-R` listen flow (forwarded-tcpip mapping): untouched by the POC beyond the refusal shape; route choice (ingress via @@ -381,7 +381,7 @@ published sources: ## Reference run -Full clean run at findings time: **51 checks across 4 stages — 24 + 6 +Full clean run at findings time: **51 checks across 4 stages: 24 + 6 + 8 + 13, all PASS, `overall=true`, exit 0** (reproduced twice; second run byte-identical PASS set with different ephemeral ports). Server-side event logs captured per stage in the run output (the @@ -389,7 +389,7 @@ diagnostics quoted above are verbatim from that run). ## References -- `pocs.md` §POC-3 (plan), `mapping-table.md` (the rows under test — +- `pocs.md` §POC-3 (plan), `mapping-table.md` (the rows under test, three corrections recorded above), `poc-1-auth-bridge.md` (the auth loopback everything rides), `russh.md` (API inventory) - POC source: `/workspace/alkssh-poc3` (`src/stage_{a_git,b_socks, diff --git a/docs/research/pocs.md b/docs/research/pocs.md index 1242580..631e09d 100644 --- a/docs/research/pocs.md +++ b/docs/research/pocs.md @@ -1,4 +1,4 @@ -# POCs — phase 0 validation plan +# POCs: phase 0 validation plan Status: **Complete** (phase 0). All three POCs ran against the current published russh (0.64.1) and passed; the findings docs @@ -6,17 +6,17 @@ published russh (0.64.1) and passed; the findings docs `poc-3-mapping-bakeoff.md`) are canonical and supersede the planned hypothesis wording below wherever they record corrections. POC source is disposable and never merges to main. Mode for all: **standalone** -(repos have no code to build on yet) — scratch cargo projects at +(repos have no code to build on yet), scratch cargo projects at `/workspace/` unless a POC explicitly builds on repo code (then branch mode per `docs/sdd_process.md`). The hypotheses below are what phase 1's ADRs will cite. If a POC -refutes a hypothesis, the finding doc records the refutation — that is +refutes a hypothesis, the finding doc records the refutation, which is the point of running it. -## POC-1 — auth bridge + fail-closed declines end to end +## POC-1: auth bridge + fail-closed declines end to end -Findings: `poc-1-auth-bridge.md` (complete — 31/31; all hypotheses +Findings: `poc-1-auth-bridge.md` (complete: 31/31; all hypotheses confirmed, one correction to the research docs' russh default-impl assumption). POC source: `/workspace/alkssh-poc1`. @@ -42,9 +42,9 @@ keys; russh client available as a secondary client for with OpenSSH's probe-then-sign flow. - Decline matrix: `auth_none`/`password`/`keyboard-interactive` declined (0.64.1 correction: the *default impls already reject* at - this russh — only `auth_publickey_offered` defaults to accept; - `poc-1-auth-bridge.md` §3. The family contract still requires - implementing every hook explicitly — the pin is a one-way door); + this russh: only `auth_publickey_offered` defaults to accept; + `poc-1-auth-bridge.md` §3). The family contract still requires + implementing every hook explicitly, since the pin is a one-way door; `Config::methods` advertises `publickey` only. - `ChannelOpenHandle` drop = auto-reject semantics; unfeatured channel-type declines observable by vanilla `ssh` @@ -56,12 +56,12 @@ keys; russh client available as a secondary client for uncertainty is fingerprint-format parity details, which this either confirms or corrects. -## POC-2 — client-side at-rest key handling (vault-backed storage) +## POC-2: client-side at-rest key handling (vault-backed storage) -Findings: `poc-2-vault-keys.md` (complete — 39/39; OpenSSH-PEM-inside- +Findings: `poc-2-vault-keys.md` (complete: 39/39; OpenSSH-PEM-inside- `EncryptedData` confirmed, Signer seam dismissed with evidence). -**Question:** how are held SSH keys stored and used — the vault as +**Question:** how are held SSH keys stored and used: the vault as pure at-rest encryption (the key is just another bearer-token-class secret: opaque bytes, vault-encrypted at rest, decrypted at use), what byte format sits inside, and how does the decrypt→dial lifecycle @@ -82,39 +82,39 @@ the key's fingerprint through the same stub `IdentityProvider`). → zeroized handling for the dial's lifetime; what happens locked / absent (fail-closed no-dial) / mid-session unlock rotation. - Whether a `Signer`-shaped seam is still worth anything given the - bytes are handled client-side at use anyway (expected answer: no — + bytes are handled client-side at use anyway (expected answer: no, recorded so the ADR can dismiss it with evidence). - Server and client host-key material riding the same model. -**Scope note:** the vault is for secrets *we* hold — client-side -outgoing keys and the server's own host key — and is explicitly not +**Scope note:** the vault is for secrets *we* hold (client-side +outgoing keys and the server's own host key) and is explicitly not involved in resolving *clients'* fingerprints (that path is the assembly-layer `IdentityProvider`). The vault's derivation surface -(`derive_ed25519`, SLIP-0010 paths) is not on this path either — that +(`derive_ed25519`, SLIP-0010 paths) is not on this path either; that is a different alkvault feature, not SSH key custody. Kept as its own POC because its failure modes (at-rest/lifecycle, zeroization, lock behavior) are unrelated to the auth-flow failure modes of POC-1. **Hypothesized outcome:** OpenSSH-encoded key inside alkvault `EncryptedData`, one-time `decode_secret_key` at dial, zeroizing -wrapper — confirming the alkhttp ADR-014 analogy end to end. +wrapper, confirming the alkhttp ADR-014 analogy end to end. -## POC-3 — the mapping bake-off: channel bridge through a real sibling seam +## POC-3: the mapping bake-off, channel bridge through a real sibling seam -Findings: `poc-3-mapping-bakeoff.md` (complete — 51/51 across stages +Findings: `poc-3-mapping-bakeoff.md` (complete: 51/51 across stages A–D; every hypothesis confirmed, three mapping-table corrections recorded, plus the russh inline-dispatch rule). POC source: `/workspace/alkssh-poc3`. **Question:** does "russh channel duplex → family serve-over-a-stream -entry" actually work against the real published seams — and which of +entry" actually work against the real published seams, and which of the mapping-table's route choices survives contact with reality? **Construction:** standalone, but *depending on the real sibling crates* (alktty/alksocks/alkgit + alkcall types via their published forms). russh server loopback + vanilla clients; per stage: -- **Stage A — git exec mapping** (the alkforge-critical path): vanilla +- **Stage A, git exec mapping** (the alkforge-critical path): vanilla `git clone/push` over `ssh://` → russh server → parse exec command → `serve_duplex_resolved(...)` over the split channel halves with stub `GitRefs`/`GitPackGen`/`GitPackIngest` (or the substrate composition @@ -122,26 +122,26 @@ forms). russh server loopback + vanilla clients; per stage: (server-speaks-first advertisement) dialect satisfies vanilla git's expectations over exec; channel EOF/close maps to session termination; exit-status flows. -- **Stage B — `-D` mapping**: russh server, feature `dynamic-forward`- +- **Stage B, `-D` mapping**: russh server, feature `dynamic-forward`- shaped build → `serve_socks5_over` over a session channel → `curl` through the SOCKS5 proxy to a local HTTP endpoint. Confirms: the dispatch-split inside `session` (parse-then-route vs SOCKS-detect), duplex backpressure under throughput. -- **Stage C — `-L` mapping**: `direct-tcpip` → the mapping-table's two +- **Stage C, `-L` mapping**: `direct-tcpip` → the mapping-table's two alktunnels routes (adapter-side `TunnelSession::open_direct` via a real `ChannelClient` vs local-dial stand-in). Whichever route the POC exercises informs the phase 1 ADR on the presence/absence of a serve-over-stream entry in alktunnels. -- **Stage D — session/pty mapping**, final stage (ran in full): shell, +- **Stage D, session/pty mapping**, final stage (ran in full): shell, exec, window-change and signal mapping against a real PTY (`LocalTtyBackend`, alktty `local`). The "optional" hedge in the - original plan was unnecessary — stage D completed 13/13 and produced + original plan was unnecessary: stage D completed 13/13 and produced the load-bearing session-row corrections: the alktty bridge is a **translation bridge** (chunk framing both directions, not a byte pump) and the exit/signal findings over the alktty exit-code convention (pipe-path signal termination rides the negative code `−N`; the PTY path reduces signal termination to a plain exit - code — `poc-3-mapping-bakeoff.md` §D.2). + code, per `poc-3-mapping-bakeoff.md` §D.2). **Validates:** the one-seam-per-mapping hypothesis; the parse-then- route dispatch; stderr semantics in pipe vs PTY mode; EOF/close/signal @@ -160,14 +160,14 @@ vanilla-SSH server and consumes its surface (exec/pty channels, `direct-tcpip` opens, the `tcpip_forward` + accepted-`forwarded-tcpip` listener flow). No dedicated POC was needed: the POCs exercised the client API throughout (POC-1's russh-client auth leg, POC-3 §D.3's -client-side request mechanics — `Channel::exec()`, the `wait()`-drain -trap, fire-and-forget `window_change`/`signal` before `into_stream()`). +client-side request mechanics (`Channel::exec()`, the `wait()`-drain +trap, fire-and-forget `window_change`/`signal` before `into_stream()`)). Two client-side facts phase 1 designs from: 1. **The acceptor rule.** The only server-initiated channel type in the pattern is `forwarded-tcpip`, and russh's client default for an - unhandled forwarded open is *accept-and-drop* — which is not the + unhandled forwarded open is *accept-and-drop*, which is not the family fail-closed posture. The client-side accepted-open disposition (accept into a registered handler vs decline) is alkssh's contract to define. @@ -179,9 +179,9 @@ Two client-side facts phase 1 designs from: ## Sequencing and gates -1. POC-1 first — everything downstream presumes its auth plumbing. +1. POC-1 first; everything downstream presumes its auth plumbing. 2. POC-2 after POC-1 (reuses POC-1's server loopback). -3. POC-3 last; stage A gates phase 1's mapping ADR work — it is the +3. POC-3 last; stage A gates phase 1's mapping ADR work; it is the path alkforge phase 0 consumes first. 4. Phase 0 completed when the findings docs existed and the OQ list in `vision.md` §scope questions was either answered by findings or @@ -191,12 +191,12 @@ Two client-side facts phase 1 designs from: verdicts; scope question 4 (auth dispositions + client-side vault keys) is answered per POC-1/POC-2; scope question 2 (russh pin facts) is verified; questions 1 (the `alk/ssh` topology) and 3 - (feature naming) are decision territory carried as phase 1 OQs — + (feature naming) are decision territory carried as phase 1 OQs, and the seeded OQ list is in each findings doc. ## References -- `vision.md` (principles the POCs must honor — including that POC +- `vision.md` (principles the POCs must honor, including that POC code carries relaxed constraints but never merges) - `russh.md` (API shapes the POCs exercise), `auth-bridge.md`, `mapping-table.md` diff --git a/docs/research/reverse-runner.md b/docs/research/reverse-runner.md index 08404e6..7a80565 100644 --- a/docs/research/reverse-runner.md +++ b/docs/research/reverse-runner.md @@ -1,10 +1,10 @@ -# Reverse runner — client-direction pattern extraction (alkminer) +# Reverse runner: client-direction pattern extraction (alkminer) -Status: phase 0 post-POC extraction. **Reading aid only — concepts and +Status: phase 0 post-POC extraction. **Reading aid only: concepts and wire shapes, never code** (the source is not open-sourced and stays that way; per repo license hygiene, reference code is for reading and facts). Source: -`/workspace/@alkminer/alkminer` — specifically +`/workspace/@alkminer/alkminer`, specifically `crates/alkminer-host/src/remote/{ssh,exec,forward,manager,sftp}.rs` and `src/oai/mod.rs`. Also old (russh 0.62 era) and POC-grade; its weak spots are as instructive as its strengths, and both are recorded @@ -14,19 +14,19 @@ below as facts to design from, not precedents to copy. The reverse-runner pattern inverts the typical runner flow: the **hub/coordinator is the SSH client** and dials out to vanilla `sshd` -on rented containers (vast.ai, runpod — any box that exposes SSH). The +on rented containers (vast.ai, runpod, any box that exposes SSH). The runner exposes nothing but the wire; the hub is the active party and pushes all work and all channel opens. Security shape (why this beat the alternatives in practice): -- **No inbound surface on runners** — only `sshd` with one key. -- **Secrets never leave the hub** — the pattern's killer use was an +- **No inbound surface on runners**, only `sshd` with one key. +- **Secrets never leave the hub**; the pattern's killer use was an OAI-compatible proxy served *by the hub* (`oai/mod.rs`, alkminer ADR-016): the instance points its LLM tooling at a localhost URL that rides the tunnel to the hub's proxy, which injects the real provider key. The runner authenticates to the proxy with a scoped - dev token — least privilege, no upstream-secret custody. + dev token: least privilege, no upstream-secret custody. - **Reachback both ways, deliberately**: hub→runner services via `-L`, runner→hub services via `-R` (the two legs below were both used, sometimes chained: instance port ‑L→ hub port ‑L→ dev workstation). @@ -39,9 +39,9 @@ One authenticated SSH connection per instance carried: |---|---|---| | blocking `exec` | provisioning commands, health checks | session channel open + `exec()`; alktty pipe-mode seam when the runner is family-built | | background jobs (`start_job`/`job_status`/`job_logs`/`stop_job`) | mining worker lifecycle; pid files on the runner, job rows in hub-side storage | assembly-level job supervision over exec channels; the alkminer pid-file approach is the ad-hoc analog of POC-3 §D's pipe-mode signal gap (alktty exit-chunk signal identity OQ) | -| SFTP | binary deploy to the instance | **future alkfs client half** — real consumer evidence that the `sftp` feature's deferred implementation needs both directions (alkforge CI artifact deploy is the consumer) | +| SFTP | binary deploy to the instance | **future alkfs client half**: real consumer evidence that the `sftp` feature's deferred implementation needs both directions (alkforge CI artifact deploy is the consumer) | | `-L` client-side tunnel | opencode dev tunnel (instance:4096 → hub) | client opens `direct-tcpip` per accepted local connection and pumps (POC-1's `into_stream` plumbing, reversed) | -| `-R` client-side tunnel | hub-service reach tunnel (instance → hub's HTTP/OAI proxy) | `tcpip_forward` global request (returns the bound port — no mpsc sink param, matching `russh.md`'s client inventory) then **accept the server-initiated `forwarded-tcpip` opens** and relay each to the hub-local target | +| `-R` client-side tunnel | hub-service reach tunnel (instance → hub's HTTP/OAI proxy) | `tcpip_forward` global request (returns the bound port; no mpsc sink param, matching `russh.md`'s client inventory) then **accept the server-initiated `forwarded-tcpip` opens** and relay each to the hub-local target | ## Client-direction facts that phase 1 designs from @@ -49,7 +49,7 @@ One authenticated SSH connection per instance carried: client handler receives `server_channel_open_forwarded_tcpip` for `-R` data. In alkminer's 0.62-era code (and unchanged in kind at 0.64.1), an accepted forwarded channel with no handler installed is - **accept-and-drop** — a fail-open-ish default. alkssh's client + **accept-and-drop**, a fail-open-ish default. alkssh's client contract: only channel types/features the assembly registered are served; otherwise decline cleanly. This is the client side of the feature-graph symmetry rule; unlike the server auth defaults (which @@ -60,11 +60,11 @@ One authenticated SSH connection per instance carried: (`install_forward_sink`) set after `connect` by the consumer that owns the forward. The family translation is an assembly-injected acceptor-handlers registry (per channel type / per feature), not a - static config — but the *late-install* dynamic is real: consumers + static config, but the *late-install* dynamic is real: consumers turn capabilities on/off over a live connection. 3. **Keepalives are load-bearing on cloud runners.** The hub needed `keepalive_interval` 15 s + `keepalive_max` 3 (OpenSSH - `ServerAliveInterval=15`/`ServerAliveCountMax=3` parity) — with + `ServerAliveInterval=15`/`ServerAliveCountMax=3` parity). With russh's default (no keepalives), dead NAT'd connections sat unnoticed until a health-check exec failed. Evidence for the client-side bounded-resources defaults (the server side has @@ -72,28 +72,28 @@ One authenticated SSH connection per instance carried: 4. **Reconnect-and-restore semantics.** Tunnel ports were persisted (hub storage) and re-bound identically on reconnect; forwards were tracked per connection with idempotent stop handles. This is - assembly policy (session/forward lifecycle), not wire contract — + assembly policy (session/forward lifecycle), not wire contract; kept in view so phase 1 doesn't accidentally bake reconnect policy into the crate. -5. **Host-key acceptance for ephemeral fleets — the pattern the fleet +5. **Host-key acceptance for ephemeral fleets: the pattern the fleet model demands.** alkminer's `check_server_key` returned accept-all for rental fleets. That was the correct pattern for that problem, not a lapse: a fresh container per rent mints a fresh host key, so - no pin can exist beforehand — strict TOFU/"pin everything" is + no pin can exist beforehand, so strict TOFU/"pin everything" is unusable there, and the churn is exactly why accept-all won. The security anchor is elsewhere: hub-held per-instance dial keys make a captured hub credential worthless, plus what rides the tunnels. - The family rule is therefore not "pin everything" — it is "no + The family rule is therefore not "pin everything"; it is "no trust decisions inside the crate": default fail-closed at the crate, with an assembly-provided policy seam so each fleet model - gets the control that fits it — host pinning where anchors can + gets the control that fits it: host pinning where anchors can exist (family-built runners, whose host key the assembly mints and therefore knows beforehand), permissive accept where they can't (rental fleets). The fleet churn is the reason the two-case policy seam (OQ-05) is the correct shape rather than "pin everything". 6. **Per-runner key registration.** Keys were minted per instance and registered hub-side (their provisioning flow, including a real - gitea client for dev workspaces) — the deployment precedent for + gitea client for dev workspaces), is the deployment precedent for alkforge's per-runner `IdentityProvider` rows. ## Not carried @@ -103,7 +103,7 @@ One authenticated SSH connection per instance carried: files on the runner) belongs to the assembly layer in a family deployment; the crate's surface is channels and tunnels. - No per-target scoping of proxied traffic existed (dev token proved - "a valid dev instance", nothing more) — a real least-privilege gap + "a valid dev instance", nothing more), a real least-privilege gap the family model fixes via per-identity ACLs on the service side. ## References @@ -115,4 +115,4 @@ One authenticated SSH connection per instance carried: `poc-3-mapping-bakeoff.md` §D.3 (client-request mechanics) - `auth-bridge.md` §Host keys and `check_server_key` (the trust-policy seam this doc's ephemeral-fleet acceptance pattern motivates) -- alkminer ADR-016 (the OAI proxy pattern — secrets stay at the hub) \ No newline at end of file +- alkminer ADR-016 (the OAI proxy pattern: secrets stay at the hub) \ No newline at end of file diff --git a/docs/research/russh.md b/docs/research/russh.md index 1bc08db..f1b9eea 100644 --- a/docs/research/russh.md +++ b/docs/research/russh.md @@ -1,4 +1,4 @@ -# russh — crate inventory and pin considerations +# russh: crate inventory and pin considerations Status: Draft (phase 0 research). Facts from docs.rs and the GitHub release notes as of 2026-10-25; versions re-verified at architecture @@ -6,7 +6,7 @@ time before any pin is recorded. ## Version landscape -**Re-verified at architecture time (2026-10-26) — see the section at +**Re-verified at architecture time (2026-10-26); see the section at the end of this file.** russh **0.65.0** was published 2026-10-25 (after the POCs verified 0.64.1 as newest); the pin ADR is written against 0.65.0. The POC wire-behavior evidence transfers: the 0.65.0 @@ -25,22 +25,22 @@ breaking changes are handler-signature shapes, not wire behavior. GHSA-3hm4-p7h4-rvwp (pre-auth stack exhaustion via nested OpenSSH certificates); the agent/pageant fixes are off our path (agent forwarding declines, ADR-003). -- 0.64.1 (2026-10-05) was the POC pin — security-fix-only patch of - 0.64.0 (GHSA-4wc5-f2rc-q74m — unbounded memory allocation in the +- 0.64.1 (2026-10-05) was the POC pin, a security-fix-only patch of + 0.64.0 (GHSA-4wc5-f2rc-q74m, unbounded memory allocation in the agent protocol server; no API change). The POC findings' evidence and this file's earlier inventory facts were verified against 0.64.1 and carry to 0.65.0 as noted above. - 0.64.0 (2026-10-04) broke API: dropped the `legacy-ed25519-pkcs8-parser` feature (pre-0.43 non-compliant ed25519 keys now hard-error); fixed GHSA-j7h2-66j4-ghjf (server - pubkey auth accepted mismatched signature algorithms — e.g. + pubkey auth accepted mismatched signature algorithms, e.g. rsa-sha2-512 silently downgraded to rsa-sha1); added `Handle::hostkeys_prove` and `Handle::send_global_request`; fixed `Limits` not being fully enforced. - There are older russh families (0.4x–0.5x) with materially different handler shapes in circulation; one rough family-internal integration example exists (alknet `main`, pinned 0.49) but it is a reading aid - only — a rough working example, months stale — and is **not decision + only: a rough working example, months stale, and **not decision input** for anything here. The pin is chosen against the current published russh, verified at architecture time. @@ -52,14 +52,14 @@ breaking changes are handler-signature shapes, not wire behavior. our license satisfies it); record this in the pin ADR so the audit trail is explicit. - **MSRV 1.89, edition 2024.** Our crate's `rust-version` must be ≥ - russh's once the dependency lands (currently `1.88` in the manifest - — a pending decision, deferred to the pin ADR). + russh's once the dependency lands (currently `1.88` in the manifest, + a pending decision, deferred to the pin ADR). - **Crypto backend: exactly one of `aws-lc-rs` (default) or `ring`.** The build errors if zero or both are selected. Feature choice is a phase 1 decision (alktls family precedent may pull one way). - Other features: `flate2` (compression, default), `rsa` (default; pulls **RC-pinned** `rsa =0.10.0-rc.18` + `pkcs1 =0.8.0-rc.4`), - `async-trait`, `des`/`dsa` ("Danger: insecure" — never enable), + `async-trait`, `des`/`dsa` ("Danger: insecure"; never enable), `serde`. The `rsa` question interacts with the RC-pin question below. - **RC dependencies in the current pin set:** `ssh-key =0.7.0-rc.11` (exact-pinned by russh itself via `russh::keys::ssh_key` re-export), @@ -77,7 +77,7 @@ Required: `type Error: From + Send`. Everything else is provided methods; at 0.64.1 the default auth impls are **reject** (`Auth::reject()`) for `auth_none`, `auth_password`, `auth_keyboard_interactive`, `auth_publickey`, and -`auth_openssh_certificate` — only `auth_publickey_offered` defaults to +`auth_openssh_certificate`; only `auth_publickey_offered` defaults to accept (verified in 0.64.1 source; POC-1 corrected this doc's earlier accept-defaults assumption). Non-auth defaults decline/no-op. The fail-closed discipline is nonetheless *ours to implement explicitly* @@ -86,7 +86,7 @@ on a bump), and POC-1 proves the explicit implementations. Key inventory: - Auth: `auth_none(user)`, `auth_password(user, password)`, - `auth_publickey_offered(user, &PublicKey)` (pre-auth probe — cheap + `auth_publickey_offered(user, &PublicKey)` (pre-auth probe, a cheap rejection hook; key ownership not yet verified), `auth_publickey(user, &PublicKey)` (called **after** russh verified the signature and key ownership), `auth_openssh_certificate(user, @@ -96,7 +96,7 @@ inventory: `auth_succeeded(&mut Session)` fires on success. - Channel opens (each receives `channel: Channel` + `reply: ChannelOpenHandle`; **dropping the handle without calling - `reply.accept()`/`reply.reject()` auto-rejects the open** — a + `reply.accept()`/`reply.reject()` auto-rejects the open**, a natural fail-closed default): `channel_open_session`, `channel_open_direct_tcpip(host_to_connect, port_to_connect, originator_address, originator_port)`, @@ -119,12 +119,12 @@ inventory: `lookup_dh_gex_group`. **Dispatch/runtime facts (POC-3, load-bearing for the adapter):** the -session's event loop invokes handler callbacks *inline* — a blocking +session's event loop invokes handler callbacks *inline*: a blocking handler suspends all packet routing for the connection (channel data is only routed by that same loop; an inline serve deadlocks against a peer's follow-up bytes, e.g. a receive-pack push). Every serving path must `tokio::spawn` and return fast. Exit reporting from spawned -tasks rides `Session::handle()` — a cloneable server-side `Handle` +tasks rides `Session::handle()`, a cloneable server-side `Handle` with `data` / `extended_data(channel, ext, data)` / `eof` / `close` / `exit_status_request` / `exit_signal_request` (send `exit-status` before `close`). Client-side: `Channel::exec()` (no `request_exec`), @@ -138,14 +138,14 @@ explicitly. Details and wire evidence: ### `server::Config` knobs (bounded-resources posture) -`server_id`, `methods: MethodSet` (advertised auth methods — honest +`server_id`, `methods: MethodSet` (advertised auth methods; honest advertisement means this must mirror the feature graph), `keys: Vec` (host keys), `certificates: Vec`, `auth_rejection_time` + `auth_rejection_time_initial`, `max_auth_attempts`, `limits: Limits` (bytes/time before rekey), `window_size`, `maximum_packet_size`, `channel_buffer_size`, `event_buffer_size`, `inactivity_timeout`, `keepalive_interval`, `keepalive_max`, `nodelay`, `preferred: Preferred` (algorithm -preferences — the place any algorithm restriction would be enforced). +preferences, the place any algorithm restriction would be enforced). Most family bounded-resource conventions map directly: max concurrent channels is additionally ours (alkcall-side per-identity policy when @@ -158,14 +158,14 @@ bridged; in-crate when serving locally), session timeout = Same shape as server: `type Error: From + Send + Debug`; provided methods default sensibly, but **`check_server_key` defaults -to reject-all and must be overridden** — host-key policy is a phase 1 +to reject-all and must be overridden**; host-key policy is a phase 1 design point (for the family client: pin the expected server fingerprint at the assembly layer, per the no-trust-on-first-use direction this implies). Inventory: `check_server_key` (&PublicKeyOrCertificate) → bool; `kex_done`; the full server-initiated channel-open set (`server_channel_open_session / _direct_tcpip / _forwarded_tcpip / _forwarded_streamlocal / _x11 / -_agent_forward / _unknown` — the client is an acceptor for forwarded +_agent_forward / _unknown`; the client is an acceptor for forwarded channels, i.e. the `-R` data path) with the same `ChannelOpenHandle` accept/reject semantics; `should_accept_unknown_server_channel`; data plane (`data`, `extended_data`, `exit_status`, `exit_signal`, `xon_xoff`, @@ -180,30 +180,30 @@ PrivateKeyWithHashAlg)`, `authenticate_password`, `authenticate_none`, `authenticate_keyboard_interactive_start/ respond`, `authenticate_openssh_cert`, the `*_with` signer-injected variants (`authenticate_publickey_with(user, PublicKey, hash_alg, -&mut Signer)` — a signer-injection seam; under alkssh's at-rest +&mut Signer)`, a signer-injection seam; under alkssh's at-rest storage model (held keys are bearer-token-class secrets decrypted at -use — see `auth-bridge.md`) it is not presumed to be the vault +use; see `auth-bridge.md`) it is not presumed to be the vault integration shape, but it exists and POC-2 evaluates it against the -simple decrypt-and-decode flow; POC-2 verdict: dismissed — no +simple decrypt-and-decode flow; POC-2 verdict: dismissed, since no provided `impl Signer` exists anywhere in 0.64.1 (the doc's "SSH agent" implementation claim is stale), a custom signer must replicate the splice convention (`to_sign || string{sig}`), and an -in-process vault gains nothing over one-time decode at dial — -see `poc-2-vault-keys.md` §4), `authenticate_gssapi_with_mic`, +in-process vault gains nothing over one-time decode at dial; see +`poc-2-vault-keys.md` §4), `authenticate_gssapi_with_mic`, `best_supported_rsa_hash` (waits ≤1s for EXT_INFO `server-sig-algs`). -Channel opening: only the four typed opens exist — +Channel opening: only the four typed opens exist: `channel_open_session`, `channel_open_x11`, `channel_open_direct_tcpip(host, port, originator...)`, -`channel_open_direct_streamlocal(socket_path)` — no generic +`channel_open_direct_streamlocal(socket_path)`; there is no generic `channel_open(name)`. On a server **reject**, the typed open call -itself resolves to `Err(Error::ChannelOpenFailure(reason))` — no +itself resolves to `Err(Error::ChannelOpenFailure(reason))`, and no `wait()`-and-match pattern needed (POC-1 verified with vanilla declines); the session stays usable after declines. Channels expose `wait() -> Option`, `split()`, `into_stream() -> ChannelStream` (AsyncRead + AsyncWrite), `make_reader()/make_reader_ext(ext)/make_writer()/make_writer_ext()` -(tokio duplex adapters — **the bridge primitives for every mapping**), +(tokio duplex adapters, **the bridge primitives for every mapping**), `request_pty/request_shell/exec/request_subsystem/set_env/ window_change/signal/eof/close/exit_status`, `writable_packet_size()`. @@ -226,7 +226,7 @@ plus `PrivateKey`, `PublicKey`, `Certificate`, `HashAlg`, (`load_secret_key`, `decode_secret_key`, `load_public_key`, `parse_public_key_base64`, `load_openssh_certificate`, `decode_openssh`). Fingerprint computation lives in `ssh-key` -(`PublicKey::fingerprint`, SHA-256 OpenSSH format `SHA256:base64`) — +(`PublicKey::fingerprint`, SHA-256 OpenSSH format `SHA256:base64`), relevant to `auth-bridge.md`. `known_hosts::check_known_hosts[_path]` exists for host-key verification. OpenSSH agent interop (`keys::agent`) exists but is out of family scope (agent forwarding declines; see @@ -240,20 +240,20 @@ items below are the questions as phase 0 posed them: 1. Which family/russh line (current stable 0.64.1 is the default candidate) and whether the RC-pinned `ssh-key`/`rsa`/`pkcs1` tree is acceptable or gates the pin. -2. Crypto backend (`aws-lc-rs` vs `ring`) — align with alktls. -3. `rust-version` bump in our manifest (≥ 1.89) — lands with the +2. Crypto backend (`aws-lc-rs` vs `ring`); align with alktls. +3. `rust-version` bump in our manifest (≥ 1.89), landing with the dependency. 4. Whether to enable the `rsa` feature at all (family keys are ed25519-first; disabling `rsa` drops two RC deps from the tree). 5. Confirm compatibility with the vanilla clients alkforge needs (ssh - + git + scp-era behavior) — POC-1's deliverable. + + git + scp-era behavior); POC-1's deliverable. ## References - docs.rs russh 0.64.1: server `Handler`, client `Handler`, `Config`s, `Handle`, `keys` module (sections above quote their inventories) - russh GitHub releases 0.64.0 / 0.64.1 (GHSA ids quoted above) -- alknet `main` `crates/alknet-core/src/auth/` — rough reference only, +- alknet `main` `crates/alknet-core/src/auth/`: rough reference only, explicitly not decision input (0.49-era API; verify nothing against it) @@ -265,10 +265,10 @@ build (`/tmp/opencode/alkssh-pin-check`, disposable): | Fact | Verified value | |---|---| -| Current published | **0.65.0** (2026-10-25) — see Version landscape above | +| Current published | **0.65.0** (2026-10-25); see Version landscape above | | Backend feature | `aws-lc-rs` (dashed feature name; also russh's own default alongside `flate2`, `rsa`) | -| Scratch build | `default-features = false, features = ["aws-lc-rs", "rsa"]` — clean, ~50 s, **no external provisioning** (prebuilt aws-lc bindings cover this platform; `aws-lc-rs 1.18.2` / `aws-lc-sys 0.46.0`) | -| Tree (the pin set) | `ssh-key =0.7.0-rc.11` (exact), `rsa =0.10.0-rc.18`, `pkcs1 =0.8.0-rc.4`, `getrandom 0.4.3`, `rand_core 0.10.1` — same RC set as the POCs' 0.64.1 tree | +| Scratch build | `default-features = false, features = ["aws-lc-rs", "rsa"]`: clean, ~50 s, **no external provisioning** (prebuilt aws-lc bindings cover this platform; `aws-lc-rs 1.18.2` / `aws-lc-sys 0.46.0`) | +| Tree (the pin set) | `ssh-key =0.7.0-rc.11` (exact), `rsa =0.10.0-rc.18`, `pkcs1 =0.8.0-rc.4`, `getrandom 0.4.3`, `rand_core 0.10.1`; same RC set as the POCs' 0.64.1 tree | | MSRV | 1.89 (both 0.64.1 and 0.65.0) | | License | Apache-2.0 only | diff --git a/docs/research/vision.md b/docs/research/vision.md index 7fa4ab2..424533b 100644 --- a/docs/research/vision.md +++ b/docs/research/vision.md @@ -6,8 +6,8 @@ Status: Draft (phase 0 research). alkssh is the alk family's SSH adapter: a thin wrapper around russh terminating the vanilla-ssh wire and bridging to/from alkcall channels -(family ADR-003). It is the alkhttp-style adapter for the SSH protocol -— the vanilla-ssh front door for vanilla clients, and the family's +(family ADR-003). It is the alkhttp-style adapter for the SSH protocol, the +vanilla-ssh front door for vanilla clients, and the family's vanilla-ssh client where a family app dials an upstream SSH server. The substance almost entirely exists in the family. What alkssh adds is @@ -25,7 +25,7 @@ The alkforge-relevant property is **exclusion**: an alkforge ssh endpoint built with only the `git` feature is structurally incapable of serving a shell, tunnels, or socks. Every mapping is a cargo feature; unfeatured channel opens decline fail-closed (family ADR-003 decision -3). This is why alkssh leads alkforge's phase 0 — the adapter is glue, +3). This is why alkssh leads alkforge's phase 0: the adapter is glue, not protocol invention. ## Guiding principles @@ -36,10 +36,10 @@ not protocol invention. party-upstream posture applies (file asks upstream; never silently absorb a workaround). 2. **A door in both directions, vanilla on the outside.** alkssh is a - door for vanilla clients (server direction: vanilla in, family out - — each channel served by a family seam entry; primary current + door for vanilla clients (server direction: vanilla in, family + out, each channel served by a family seam entry; primary current purpose: git) and a door to vanilla servers (client direction: - family in, vanilla out — the reverse-runner shape: a family hub + family in, vanilla out; the reverse-runner shape: a family hub dials vanilla sshd and the ops map family-side onto the same protocols, **alktty** for sessions, **alktunnels** for tunnels/-D legs, **alkgit** for upstream git, alkfs client later @@ -64,19 +64,19 @@ not protocol invention. conventions, and password auth are not the alk family's identity model. A verified key's fingerprint → `Identity` via alkcall's `IdentityProvider` is the only resolution path; secrets an alkssh - *client* holds come from alkvault-backed sources — vault-encrypted - at rest, treated as bearer-token-class secret material — never env + *client* holds come from alkvault-backed sources: vault-encrypted + at rest, treated as bearer-token-class secret material, never env vars (family ADR-002 decision 5). The vault is a secret store on this path, not a key generator or key custodian. ## Non-goals -- **Authoring SSH protocol machinery** — no in-family framing, cipher, +- **Authoring SSH protocol machinery**: no in-family framing, cipher, or KEX code, ever (russh upstream decides what SSH is). - **Being an sshd.** No local user accounts, no `authorized_keys` file semantics of the OpenSSH variety, no PAM-shaped anything. Deployment posture belongs to the embedding app. -- **Serving sftp** — deferred to alkfs (family ADR-002); the subsystem +- **Serving sftp**: deferred to alkfs (family ADR-002); the subsystem decline is the placeholder contract. - **A binary / CLI.** Downstream apps embed the crate; there is no `alkssh` executable in this crate. @@ -90,26 +90,25 @@ not protocol invention. These frame the research docs; each is a candidate open question for phase 1's `open-questions.md`: -1. **The `alk/ssh` ALPN's topology role** — alkcall ADR-004 reserves +1. **The `alk/ssh` ALPN's topology role**: alkcall ADR-004 reserves `alk/ssh` → `SshAdapter`, ADR-034's channels table gives it stream_types `[0, 1]` ("SSH multiplexes internally, including its own control"), and ADR-009 sketches a browser-WASM route where the ALPN transports an SSH stream to an `SshAdapter`. But family ADR-003 - describes alkssh terminating vanilla SSH at an edge. Whether - `alk/ssh` carries raw vanilla-SSH bytes between alkssh instances - (a relay leg), serves the browser route, or both — and how that - interacts with ADR-042's "the hub never runs a handler for - `alk/ssh`" relay ruling — is unresolved (see `mapping-table.md` - §topology). -2. **russh pin** — version, crypto-backend feature, and the + describes alkssh terminating vanilla SSH at an edge. It remains + unresolved whether `alk/ssh` carries raw vanilla-SSH bytes between + alkssh instances (a relay leg), serves the browser route, or both, + and how that interacts with ADR-042's "the hub never runs a handler + for `alk/ssh`" relay ruling (see `mapping-table.md` §topology). +2. **russh pin**: version, crypto-backend feature, and the RC-pinned transitive dependency situation (see `russh.md`). -3. **Feature names + graph** — the `session`/`tunnel`/`dynamic-forward`/`git`/`sftp` naming and their independence +3. **Feature names + graph**: the `session`/`tunnel`/`dynamic-forward`/`git`/`sftp` naming and their independence (no feature implies another; flat until architecture says otherwise). -4. **Auth dispositions** — which russh auth methods the server +4. **Auth dispositions**: which russh auth methods the server implements vs declines; the exact `auth_publickey` → fingerprint → identity contract; client-side vault-backed at-rest key handling (see `auth-bridge.md`). -5. **The mapping table details** — per channel type and per request, +5. **The mapping table details**: per channel type and per request, the family seam, the parameter mapping, and the decline contract (see `mapping-table.md`); POC validation per `pocs.md`. diff --git a/docs/sdd_process.md b/docs/sdd_process.md index 51b204a..1e35e89 100644 --- a/docs/sdd_process.md +++ b/docs/sdd_process.md @@ -18,7 +18,7 @@ leverages: 4. **Task-Driven**: Structured task graphs with dependency analysis 5. **Safe Exit**: Always have a way to unblock progress when stuck 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 cost-benefit framework in taskgraph's framework docs. @@ -37,7 +37,7 @@ problems need investigation. 4. Document learnings 5. Converge on recommended approach -**POC placement (two modes)** — POC code never merges to main; the +**POC placement (two modes)**: POC code never merges to main; the deliverable is the findings write-up: - **Branch mode** (POC builds on code in this repo): git branch off main, @@ -56,11 +56,11 @@ approaches **Process**: 1. Architect creates the architecture documentation structure: - - `docs/architecture/README.md` — index with doc table, ADR table, lifecycle - - `docs/architecture/overview.md` — package purpose, exports, dependencies - - `docs/architecture/.md` — one focused doc per component/area - - `docs/architecture/decisions/` — numbered ADR files (ADR-001, ADR-002, ...) - - `docs/architecture/open-questions.md` — centralized tracker with OQ-IDs + - `docs/architecture/README.md`: index with doc table, ADR table, lifecycle + - `docs/architecture/overview.md`: package purpose, exports, dependencies + - `docs/architecture/.md`: one focused doc per component/area + - `docs/architecture/decisions/`: numbered ADR files (ADR-001, ADR-002, ...) + - `docs/architecture/open-questions.md`: centralized tracker with OQ-IDs 2. All docs start in `Draft` status. Spec docs reference ADRs by number (not inline rationale) and OQs by number (not inline questions). 3. Architecture Review validates for ambiguities, risks, and structural issues @@ -146,16 +146,16 @@ WHAT, ADRs explain WHY, open questions track what's unresolved. - Extract decisions into numbered ADRs in `decisions/` directory - Centralize open questions in `open-questions.md` with OQ-IDs - 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 (about 500 lines); reference ADRs and OQs, don't inline them - Redirect exploration work to Research Specialist - Iterate based on review feedback **Deliverables**: -- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle -- `docs/architecture/.md` — focused spec documents -- `docs/architecture/decisions/` — numbered ADR files -- `docs/architecture/open-questions.md` — centralized OQ tracker +- `docs/architecture/README.md`: index with doc table, ADR table, lifecycle +- `docs/architecture/.md`: focused spec documents +- `docs/architecture/decisions/`: numbered ADR files +- `docs/architecture/open-questions.md`: centralized OQ tracker --- @@ -191,17 +191,17 @@ sessions. **Mode**: Primary (manages worktrees and agent sessions) **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 limited set (current, notify, status). No mode toggle required. **Tools**: -- `worktree({action, args})` — spawn, sessions, dashboard, message, abort, +- `worktree({action, args})`: spawn, sessions, dashboard, message, abort, cleanup - Bash (opencode CLI for session interaction) - 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) **Key Behaviors**: @@ -230,10 +230,10 @@ limited set (current, notify, status). No mode toggle required. **Tools**: - Read, Write, Edit, Glob, Grep, Bash -- `worktree({action: "notify", ...})` — report progress/blockers to coordinator -- `worktree({action: "current"})` — verify worktree assignment +- `worktree({action: "notify", ...})`: report progress/blockers to coordinator +- `worktree({action: "current"})`: verify worktree assignment - webSearch (documentation lookup) -- `memory` / `memory_compact` — context management (via @alkdev/open-memory, +- `memory` / `memory_compact`: context management (via @alkdev/open-memory, when available) **Key Behaviors**: @@ -340,8 +340,8 @@ mode: scratch project outside the repo) - Create minimal POCs to validate hypotheses - Branch mode: work in isolated research worktrees; standalone mode: work in the scratch project only -- Document findings and recommendations (findings are the deliverable — - POC source is disposable and never merges to main) +- Document findings and recommendations (findings are the deliverable; POC + source is disposable and never merges to main) - Timebox strictly - abandon if taking too long - Be honest about limitations and blockers @@ -404,7 +404,7 @@ Implement OAuth2 authentication with provider abstraction. ### Categorical Estimates 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`, `impact`, or `level` is a red flag indicating incomplete decomposition. See the cost-benefit framework in taskgraph's framework docs for the reasoning. @@ -432,7 +432,7 @@ cost-benefit framework in taskgraph's framework docs for the reasoning. | phase | A whole phase's milestone or gate slips (e.g. a decomposition OQ)| | project | The project's contract: wire formats, published API, security | -**Valid frontmatter enum variants (taskgraph 0.1.3)** — only these +**Valid frontmatter enum variants (taskgraph 0.1.3)**: only these literal values parse; anything else fails `taskgraph validate`: - `status`: `pending` | `in-progress` | `completed` | `failed` | `blocked` @@ -443,20 +443,21 @@ literal values parse; anything else fails `taskgraph validate`: Three gotchas worth stating because agents have hit all three: -1. `status: done` is **not** a value — the lifecycle word is +1. `status: done` is **not** a value; the lifecycle word is `completed` (and `in-progress`, not `in_progress` or `wip`). 2. A file in `tasks/` **without frontmatter is silently skipped** by - `taskgraph validate`, `list`, and every graph command — no error, - the file just doesn't exist as far as the graph is concerned. + `taskgraph validate`, `list`, and every graph command. It produces + no error; the file just doesn't exist as far as the graph is + concerned. Every tracker or task file under `tasks/` must carry a complete frontmatter block (`id`, `name` required; `depends_on` plus the five categorical fields expected). If you need a plain prose tracker, keep it under `docs/` instead. 3. `status` transitions run through the lifecycle only: `pending` → `in-progress` → `completed` (or `failed`/`blocked`). A finished - task never goes sideways to `done`/`finished` — the enum is closed. + task never goes sideways to `done`/`finished`; the enum is closed. -When in doubt, run `taskgraph validate` — it is the gate that CI-style +When in doubt, run `taskgraph validate`: it is the gate that CI-style checks and review passes will hit. ### Task Lifecycle @@ -472,15 +473,15 @@ checks and review passes will hit. ### 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 (`planning, decomposition, implementation, review, research`) already covers architecture activities: | 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. | -| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit — breaking a large architecture area into decideable units. | +| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable, namely 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. | | Architecture review pass | `review` | Direct fit. | | Research / POC before a decision | `research` | Direct fit. | | Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. | @@ -530,8 +531,8 @@ the deferral has two halves that must stay in sync: 2. **The machine-readable half** (in `tasks/architecture/`): an external-trigger tracker task represents the unblocking condition. It is tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and - `level: research` (it is not actionable work — it tracks whether the - external condition has arrived), and its `id` is referenced from the OQ's +`level: research` (it is not actionable work; it tracks whether the +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` tools can reason about it, and downstream work that depends on the decision can declare `depends_on: [architecture/oq-NN-...]`. @@ -540,7 +541,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 `deferred(scope)` to `open` (or directly to `resolved` if no architecture 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. ## Review Injection @@ -559,8 +560,8 @@ Use graph analysis to determine where reviews should happen: ### Current (open-coordinator plugin) The Coordinator uses the `worktree` tool from the open-coordinator opencode -plugin. It's a single tool with `{action, args}` dispatch — no separate -enable/toggle steps. Role is auto-detected from session state. +plugin. It's a single tool with `{action, args}` dispatch, so there are no +separate enable/toggle steps. Role is auto-detected from session state. ``` 1. Identify parallel work @@ -613,13 +614,13 @@ a worktree. When the open-memory plugin is available alongside open-coordinator, the coordinator gains: -- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent +- `memory({tool: "children", args: {sessionId: "..."}})`: view sub-agent 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 -- `memory({tool: "context"})` — check context window usage before long +- `memory({tool: "context"})`: check context window usage before long 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 `memory_compact()` to manage their context during long tasks. @@ -643,7 +644,7 @@ Once the hub is operational, coordination uses native operations: ``` 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. ## Document Structure @@ -710,7 +711,7 @@ Accepted | Proposed | Deprecated | Superseded (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. ### Open Questions Format @@ -726,7 +727,7 @@ they don't revert. If superseded, mark the old one and create a new one. - **Status**: open | resolved | deferred(scope) | partially resolved - **Priority**: high | medium | low - **Resolution**: (when resolved) -- **Blocked on**: (when deferred — concrete dependency that would unblock) +- **Blocked on**: (when deferred; the concrete dependency that would unblock) - **Cross-references**: OQ-NN, ADR-NNN ``` diff --git a/tasks/architecture/oq-01-relay-leg-consumer.md b/tasks/architecture/oq-01-relay-leg-consumer.md index 99fd746..69029b3 100644 --- a/tasks/architecture/oq-01-relay-leg-consumer.md +++ b/tasks/architecture/oq-01-relay-leg-consumer.md @@ -1,6 +1,6 @@ --- id: architecture/oq-01-relay-leg-consumer -name: "OQ-01 tracker — alk/ssh relay/browser-leg consumer" +name: "OQ-01 tracker: alk/ssh relay/browser-leg consumer" status: pending depends_on: [] scope: single @@ -12,11 +12,11 @@ tags: [deferred-oq, external-trigger] ## Description -External-trigger tracker for [OQ-01](docs/architecture/open-questions.md) — "does an `alk/ssh` relay/browser-leg consumer emerge?" (deferred(scope)). Not actionable work; tracks whether the unblocking condition has arrived. +External-trigger tracker for [OQ-01](docs/architecture/open-questions.md): "does an `alk/ssh` relay/browser-leg consumer emerge?" (deferred(scope)). Not actionable work; it tracks whether the unblocking condition has arrived. ## Work -Transition check only: when a concrete consumer use case exists — a browser-WASM route landing in an assembly (alkcall ADR-009 sketch) or a hub deployment needing SSH-in-relay — mark this task `completed` and move OQ-01 from `deferred(scope)` to `open` in `open-questions.md` (directly to `resolved` if no decision remains, which would be an ADR-001 amendment since the pin is one-way). +Transition check only: when a concrete consumer use case exists, either a browser-WASM route landing in an assembly (alkcall ADR-009 sketch) or a hub deployment needing SSH-in-relay, mark this task `completed` and move OQ-01 from `deferred(scope)` to `open` in `open-questions.md` (directly to `resolved` if no decision remains, which would be an ADR-001 amendment since the pin is one-way). ## Verification @@ -29,7 +29,7 @@ Transition check only: when a concrete consumer use case exists — a browser-WA ## Out of scope -Any actual relay/browser-leg design — that work would be new ADR/spec territory once the use case exists. +Any actual relay/browser-leg design; that work would be new ADR/spec territory once the use case exists. ## Summary diff --git a/tasks/architecture/oq-04-openssh-cert-auth.md b/tasks/architecture/oq-04-openssh-cert-auth.md index 1dc492e..efc8304 100644 --- a/tasks/architecture/oq-04-openssh-cert-auth.md +++ b/tasks/architecture/oq-04-openssh-cert-auth.md @@ -1,6 +1,6 @@ --- id: architecture/oq-04-openssh-cert-auth -name: "OQ-04 tracker — OpenSSH certificate auth use case" +name: "OQ-04 tracker: OpenSSH certificate auth use case" status: pending depends_on: [] scope: single @@ -12,7 +12,7 @@ tags: [deferred-oq, external-trigger] ## Description -External-trigger tracker for [OQ-04](docs/architecture/open-questions.md) — whether an OpenSSH-CA-trust use case ever arrives for `auth_openssh_certificate` (deferred(scope)). Not actionable work; decline is the standing disposition (ADR-004 territory). +External-trigger tracker for [OQ-04](docs/architecture/open-questions.md): does an OpenSSH-CA-trust use case ever arrive for `auth_openssh_certificate`? (deferred(scope)). Not actionable work; decline is the standing disposition (ADR-004 territory). ## Work diff --git a/tasks/architecture/oq-09-env-request-allowlist.md b/tasks/architecture/oq-09-env-request-allowlist.md index 20aa22d..6e14ba6 100644 --- a/tasks/architecture/oq-09-env-request-allowlist.md +++ b/tasks/architecture/oq-09-env-request-allowlist.md @@ -1,6 +1,6 @@ --- id: architecture/oq-09-env-request-allowlist -name: "OQ-09 tracker — env_request allowlist use case" +name: "OQ-09 tracker: env_request allowlist use case" status: pending depends_on: [] scope: single @@ -12,7 +12,7 @@ tags: [deferred-oq, external-trigger] ## Description -External-trigger tracker for [OQ-09](docs/architecture/open-questions.md) — whether a consuming assembly ever wants a wire-supplied env allowlist on session channels (deferred(scope)). Decline-by-default is standing posture. +External-trigger tracker for [OQ-09](docs/architecture/open-questions.md): whether a consuming assembly ever wants a wire-supplied env allowlist on session channels (deferred(scope)). Decline-by-default is standing posture. ## Work diff --git a/tasks/architecture/oq-13-alkfs-sftp.md b/tasks/architecture/oq-13-alkfs-sftp.md index f13b1ef..3000f9d 100644 --- a/tasks/architecture/oq-13-alkfs-sftp.md +++ b/tasks/architecture/oq-13-alkfs-sftp.md @@ -1,6 +1,6 @@ --- id: architecture/oq-13-alkfs-sftp -name: "OQ-13 tracker — alkfs landed (sftp implementation unblocks)" +name: "OQ-13 tracker: alkfs landed, sftp implementation unblocks" status: pending depends_on: [] scope: single @@ -12,7 +12,7 @@ tags: [deferred-oq, external-trigger] ## Description -External-trigger tracker for [OQ-13](docs/architecture/open-questions.md) — the `sftp` feature's implementation waits for alkfs (family ADR-002, paused for alkforge). The `sftp` feature ships declining (tested decline contract on both directions) until this unblocks. +External-trigger tracker for [OQ-13](docs/architecture/open-questions.md): the `sftp` feature's implementation waits for alkfs (family ADR-002, paused for alkforge). The `sftp` feature ships declining (tested decline contract on both directions) until this unblocks. ## Work diff --git a/tasks/implementation/review-wave-1.md b/tasks/implementation/review-wave-1.md index ee22f16..1af5d89 100644 --- a/tasks/implementation/review-wave-1.md +++ b/tasks/implementation/review-wave-1.md @@ -1,6 +1,6 @@ --- id: review-wave-1 -name: Wave 1 gate — the foundations review +name: "Wave 1 gate: the foundations review" status: completed depends_on: [w1-manifest, w1-error-model, w1-key-sources, w1-host-key-policy, w1-fingerprint, w1-config, w1-test-doubles] scope: narrow @@ -11,7 +11,7 @@ level: review ## Description -The wave-1 review gate — the cheap-place gate. The config types, the +The wave-1 review gate, the cheap-place gate of the plan. The config types, the error taxonomy, and the key-source seam are one-way doors in practice: every later task implements against them, and the published crate freezes them. A shape error found here costs a task revision; found @@ -21,24 +21,24 @@ across both directions. The gate reads the landed surface against the architecture line by line: -- ADR-002 — the pin as declared (russh 0.65, `aws-lc-rs` only, no +- ADR-002, the pin as declared (russh 0.65, `aws-lc-rs` only, no `flate2`/`rsa` in the tree, edition 2024, MSRV 1.89); the recorded `ssh-key` RC exception verified in `cargo tree`. -- ADR-003 — the feature graph shape (flat, independent, `default = +- ADR-003, the feature graph shape (flat, independent, `default = []`); the recorded interpretation (per-feature sibling deps land with their first consuming wave) confirmed or corrected. -- ADR-004 — the fingerprint format invariant (parity vectors green); +- ADR-004, the fingerprint format invariant (parity vectors green); the `max_auth_attempts` default posture in `Limits`. -- ADR-005 — the custody chain (decrypt-at-use, `Zeroizing` wrap, +- ADR-005, the custody chain (decrypt-at-use, `Zeroizing` wrap, distinct failure variants, `EncryptedData` opaque, host-key load path, plaintext never persists). -- OQ-05 — the policy seam (fail-closed default, no accept-all +- OQ-05, the policy seam (fail-closed default, no accept-all built-in, policy-blind crate). -- OQ-08/09 — the serve-config `Option`-field semantics (unset → +- OQ-08/09, the serve-config `Option`-field semantics (unset → decline, no crate default, no construction-time validation drift). -- The error model — no catch-alls; auth declines are dispositions, not +- The error model: no catch-alls; auth declines are dispositions, not errors. -- Docs honesty — module docs state what is deliberately not here yet +- Docs honesty, or that module docs state what is deliberately not here yet (the stack, the mappings, the client direction). Verdict: green (wave 2 decomposes, absorbing corrections) or findings @@ -66,23 +66,23 @@ booked (remediation before wave 2 decomposes). ### Gate reading (line by line, 2026-10-28) -- **ADR-002** ✓ — `Cargo.toml` carries `russh = { version = "0.65", +- **ADR-002** ✓, because `Cargo.toml` carries `russh = { version = "0.65", default-features = false, features = ["aws-lc-rs"] }`; edition 2024, `rust-version = "1.89"`; `cargo tree -e normal` confirms **no `rsa`, no `flate2`, no `ring`**, and the recorded `ssh-key =0.7.0-rc.11` RC exception riding the russh keys path is present exactly as ADR-002 records it. -- **ADR-003** ✓ — `session`/`git`/`tunnel`/`sftp` declared, flat, +- **ADR-003** ✓, since `session`/`git`/`tunnel`/`sftp` declared, flat, `default = []`, nothing implied; the per-feature sibling deps - (`alktty`/`alktunnels`/`alkgit`) are **not** in the manifest — the + (`alktty`/`alktunnels`/`alkgit`) are **not** in the manifest; the recorded interpretation (they land with their first consuming wave) confirmed as recorded. -- **ADR-004** ✓ — `src/fingerprint.rs` is the single fingerprint path; +- **ADR-004** ✓, since `src/fingerprint.rs` is the single fingerprint path; both parity suites (`tests/fingerprint_parity.rs`, `tests/doubles.rs` fixture rows) are green against the `ssh-keygen -lf`-recorded manifest; `Limits::max_auth_attempts` defaults to 10 with the decision-6 rationale in its doc. -- **ADR-005** ✓ — the custody chain is in code: one +- **ADR-005** ✓, as the custody chain is in code: one `vault.decrypt` → `Zeroizing` wrap at the adapter seam → one-time `decode_secret_key`; the vault's taxonomy reshaped to the five distinct variants (no re-export, no collapse); `EncryptedData` @@ -92,32 +92,33 @@ booked (remediation before wave 2 decomposes). no plaintext persistence anywhere. The known-gap (vault's plain `String` return) is documented as the ADR-005 decision-3 contract, not silently absorbed. -- **OQ-05** ✓ — `HostKeyPolicy` seam with `RejectAll` fail-closed +- **OQ-05** ✓: the `HostKeyPolicy` seam offers a `RejectAll` fail-closed default; **no accept-all built-in** (the permissive posture exists - only as the double's explicit `with_default(true)` choice in tests — - correct posture); the crate is policy-blind. -- **OQ-08/09** ✓ — `shell_argv`/`exec_wrapper_argv` are `Option` with + only as the double's explicit `with_default(true)` choice in tests, + which is the correct posture); the crate is policy-blind. +- **OQ-08/09** ✓: `shell_argv` and + `exec_wrapper_argv` are both `Option` with unset→decline semantics in the type docs; no construction-time validation, no config-error type (the recorded interpretation - confirmed — none was needed); `session_cwd`/`session_env` are + confirmed, and none was needed); `session_cwd`/`session_env` are assembly-supplied, never wire-derived; `channel_client` absent → tunnel rows decline even under the feature. -- **Error model** ✓ — five closed variants, no catch-all; the +- **Error model** ✓: five closed variants, no catch-all; the dispositions-not-errors contract (auth declines, wire declines, config validation) is written into `error.rs`'s module docs. -- **Docs honesty** ✓ — every module's docs state what is deliberately +- **Docs honesty** ✓: every module's docs state what is deliberately not here yet (russh `Config` construction and the dispatch are wave-2; dial wiring is wave-4; the grammar is wave-3). -### Interpretations (flagged for this gate) — all confirmed +### Interpretations (flagged for this gate), all confirmed -1. Sibling-dep timing — confirmed (see ADR-003 above). -2. Vault-double shape — confirmed: `TestVault` wraps alkvault 0.1's +1. Sibling-dep timing: confirmed (see ADR-003 above). +2. Vault-double shape, confirmed: `TestVault` wraps alkvault 0.1's own in-memory construction (`VaultServiceHandle::new()` + `unlock_new`) rather than re-implementing behind the source trait, so the real error taxonomy and GCM behavior ride every row (the decision is recorded in `tests/common/mod.rs`'s module docs). -3. Policy verdict type — confirmed: accept/reject `bool`, pinned to +3. Policy verdict type, confirmed: accept/reject `bool`, pinned to the russh 0.65 `check_server_key → Result` hook shape, with the `Err` channel held for wire/IO failures (recorded in `host_key.rs`'s module docs). @@ -127,21 +128,21 @@ booked (remediation before wave 2 decomposes). Small doc/polish findings; each is a one-to-few-line fix: - `cargo doc --no-deps` emitted 2 rustdoc warnings (ambiguous - `[`async_trait`]` intra-doc links — crate and attribute-macro share + `[`async_trait`]` intra-doc links, since the crate and the attribute-macro share the name) in `key_source.rs` and `host_key.rs` → resolved as plain code spans. The crates' verification set does not run rustdoc, so - these had gone unnoticed — worth remembering at wave 5's + these had gone unnoticed, which is worth remembering at wave 5's "public-API rustdoc warning-free" gate. - `key_source.rs` `HostKeySource` docs: stray `)` typo ("on host *pin*) trust"). - `lib.rs` crate docs: stale line ("the dependency surface and - feature graph land with implementation start") — now landed; + feature graph land with implementation start") is now landed; rewritten to state the landed/future split honestly. - Two redundant test lines removed (a discarded pre-probe `try_sign` in `key_source.rs`'s usability helper; a redundant `None` re-assignment in `config.rs`'s independent-flip row). -Nothing found rose to the level of a booked finding — no shape error +Nothing found rose to the level of a booked finding, and no shape error in the config types, the error taxonomy, or the key-source seam. ### Verification (all green) @@ -159,9 +160,9 @@ in the config types, the error taxonomy, or the key-source seam. ADR-002/003/004/005 and the OQ-05/08/09 resolutions, line by line; all three recorded interpretations (sibling-dep timing, vault-double shape, policy verdict type) were confirmed as recorded. Only minor -doc/polish findings surfaced — fixed inline (rustdoc link +doc/polish findings surfaced, and they were fixed inline (rustdoc link disambiguation, a doc typo, a stale crate-doc line, two redundant test lines), recorded above. No shape errors in the one-way-door surfaces. Wave 2 decomposition is unblocked; it should absorb the -rustdoc-hygiene lesson (run `cargo doc --no-deps` on gates) — no fix -tasks needed. \ No newline at end of file +rustdoc-hygiene lesson (run `cargo doc --no-deps` on gates), though no fix +tasks are needed. \ No newline at end of file diff --git a/tasks/implementation/review-wave-2.md b/tasks/implementation/review-wave-2.md index 21915a1..2b918cf 100644 --- a/tasks/implementation/review-wave-2.md +++ b/tasks/implementation/review-wave-2.md @@ -1,6 +1,6 @@ --- id: review-wave-2 -name: Wave 2 gate — the server-stack review +name: "Wave 2 gate: the server-stack review" status: completed depends_on: [w2-server-mount, w2-auth-bridge, w2-loopback-harness, w2-dispatch-decline-core, w2-spawn-return, w2-bounded-resources] scope: narrow @@ -13,39 +13,39 @@ level: review The wave-2 review gate. The server stack is the substrate every mapping composes into (wave 3 hangs three mappings and the sftp row on -it; wave 4 shares its foundations) — a shape error in the mount +it; wave 4 shares its foundations), so a shape error in the mount signature, the auth-bridge state, or the dispatch routing costs churn across all of them. The gate reads the landed stack against the architecture line by line: -- **ADR-001** — the mount contract: `SshAdapter::handle` takes a +- **ADR-001**, the mount contract: `SshAdapter::handle` takes a generic stream, mounts the full russh stack per stream, edge termination only; no ALPN/relay code exists; the per-stream limits posture holds. -- **ADR-002** — the Config construction (honest advertisement +- **ADR-002**, the Config construction (honest advertisement `{publickey}` exactly; the knob mapping; no `des`/`dsa` posture drift; the pin untouched). -- **ADR-003** — the feature gates as the dispatch's first `cfg` +- **ADR-003**, the feature gates as the dispatch's first `cfg` consumers: flat, independent, `default = []` declines everything; x11/auth-agent never feature-gated; honest advertisement per build. -- **ADR-004** — the dispositions in code (explicit declines for every +- **ADR-004**, the dispositions in code (explicit declines for every non-publickey method; offered-hook accept-and-delegate; ed25519-only explicit decline; fingerprint-only resolution; the identity rides the session). -- **ADR-006** — parse-only dispatch (no detect residue); decline-first- +- **ADR-006**, parse-only dispatch (no detect residue); decline-first- alloc-never structurally; **the spawn-and-return constraint pinned by test, not convention**. -- **`mapping.md`** — the skeleton decline-matrix rows green per build +- **`mapping.md`**, the skeleton decline-matrix rows green per build through the harness; the wire decline reasons vanilla-observable. -- **Bounded resources** — the cap enforced and recovering; the knobs +- **Bounded resources**, the cap enforced and recovering; the knobs behaviorally verified or recorded; no unbounded shape anywhere. -- **The harness** — the contract-test vehicle convention established; +- **The harness**, with the contract-test vehicle convention established; the wave-1 doubles exercised live. -- **Docs honesty** — module docs state what is deliberately not here +- **Docs honesty**: module docs state what is deliberately not here yet (the family seams, the client direction); the skeleton-stub behavior (declining routes awaiting wave 3) documented as such. -- **The wave-1 gate's lesson absorbed** — `cargo doc --no-deps` runs - clean on this gate too. +- **The wave-1 gate's lesson absorbed**, with `cargo doc --no-deps` + running clean on this gate too. Verdict: green (wave 3 decomposes, absorbing corrections) or findings booked (remediation before wave 3 decomposes). @@ -71,16 +71,16 @@ booked (remediation before wave 3 decomposes). ### Gate reading (line by line, 2026-10-28; commits b90e89b…196289f) -- **ADR-001** ✓ — `SshAdapter::handle` is the mount: generic +- **ADR-001** ✓, as `SshAdapter::handle` is the mount: generic `AsyncRead + AsyncWrite` stream in, the full russh stack per stream via `run_stream`, returned **run to completion** - (`Result<(), AdapterError>` — the decision recorded in the task's + (`Result<(), AdapterError>`, a decision recorded in the task's notes; no russh type rides the signature). No ALPN code, no relay leg, no `alk/ssh` string anywhere in `src/`. The per-stream limits posture holds: each mounted stream builds its own `SessionHandler` over the serve-config, so the cap/timeout/window budget is per-stream exactly as ADR-001's neutral consequence states. -- **ADR-002** ✓ — the `Config` construction maps `Limits` one-for-one +- **ADR-002** ✓, as the `Config` construction maps `Limits` one-for-one (`window_size` / `maximum_packet_size` / `channel_buffer_size` / `max_auth_attempts` verbatim; `inactivity_timeout` **always `Some`** with a test pinning the default postures and a second @@ -91,7 +91,7 @@ booked (remediation before wave 3 decomposes). `aws-lc-rs`, no `flate2`/`rsa`/`des`/`dsa`); the recorded deliberations (`server_id` stays russh-default; keepalive stays client-direction) are in the task's notes. -- **ADR-003** ✓ — the wave-2 dispatch core is the feature graph's +- **ADR-003** ✓, since the wave-2 dispatch core is the feature graph's first `cfg` consumer: flat, independent gates read via `cfg!` predicates (`session ∨ git` for session channels; `tunnel` for the three tunnel opens; `session` for pty/shell/pipe @@ -100,36 +100,36 @@ booked (remediation before wave 3 decomposes). forwarding decline unconditionally, never feature-gated; the `sftp` feature gates nothing and the subsystem row declines in every build (OQ-13 placeholder). -- **ADR-004** ✓ — all dispositions in code as explicit hook +- **ADR-004** ✓, with all dispositions in code as explicit hook implementations: `auth_publickey` (ed25519-only gate checked on **both** the signature algorithm and the key's algorithm before any resolution → fingerprint → `resolve_from_fingerprint` → accept-with-identity / reject fail-closed), the offered hook accept-and-delegates, and the four non-publickey methods each have an explicit reject body (never inherited). The bridge core - (`resolve_bridge`) takes no username parameter — OQ-03 holds + (`resolve_bridge`) takes no username parameter, so OQ-03 holds structurally; wire rows prove username-independence. Auth declines are `Auth::Reject` dispositions end to end. The identity lands in per-connection handler state (`SessionHandler::resolved_identity`); its *session-level landing* - is wave-3 assertion territory (flagged in the task's notes — code - verifiable only through `auth_succeeded` consumption), confirmed as + is wave-3 assertion territory (flagged in the task's notes, as code + is verifiable only through `auth_succeeded` consumption), confirmed as correctly deferred. -- **ADR-006** ✓ — parse-only dispatch with no detect residue (the +- **ADR-006** ✓, showing parse-only dispatch with no detect residue (the SOCKS-detect is gone; a request-less session channel serves - nothing by construction — no serving path exists outside the + nothing by construction, since no serving path exists outside the request hooks); decline-first-alloc-never verified structurally: every open-level decline answers before any accept; the only pre-decline resource on any request path is none (exec parses via a pure fn first), and the spawned serving paths' only allocation is the post-decision handle clone; spawn-and-return is **pinned by - test, not convention** — the `serve.rs` unit row (spawn returns + test, not convention**; the `serve.rs` unit row (spawn returns while the path is pending) plus the two wire rows (`tests/spawn_return.rs`: handler-returns-fast under follow-up traffic with the spawn still gated; exit-status strictly before close, timestamps proving the report outlived the handler's return). -- **mapping.md** ✓ — the skeleton decline-matrix rows are green per +- **mapping.md** ✓, with the skeleton decline-matrix rows green per build through the harness (`tests/dispatch_core.rs`: shared rows in every build; per-build modules cfg-pinned to exactly one feature combination so no @@ -139,7 +139,7 @@ booked (remediation before wave 3 decomposes). (`AdministrativelyProhibited` / the tunnel stub's `ConnectFailed`), request-level capability declines answer `channel_failure` with the channel provably still usable. -- **Bounded resources** ✓ — the in-crate cap is enforced at the +- **Bounded resources** ✓, with the in-crate cap enforced at the handler surface, recovering on close (wire row: cap reached → decline; close → open succeeds again, deterministic on the FIFO wire ordering; zero-cap row refuses everything without poisoning); @@ -152,49 +152,49 @@ booked (remediation before wave 3 decomposes). (wave-3 grammar's consumption, per the plan bullet). No unbounded shape anywhere: accepted-but-unserved session channels hold bounded buffers only (window/buffer knobs). -- **The harness** ✓ — the contract-test vehicle convention +- **The harness** ✓, with the contract-test vehicle convention established exactly as the task pinned it: tokio-duplex transport + `connect_stream` (decision recorded in the module docs, over TCP loopback), sibling-of-the-doubles placement under `tests/common/`, the `drive` vocabulary the wave-3 mappings inherit, and the wave-1 doubles exercised live through it (`MemIdentityProvider`, `MemHostKeySource`, `MemHostKeyPolicy` both verdicts, fixture - plumbing) — the acceptance row + plumbing), as the acceptance row (`in_process_pair_connects_and_authenticates_end_to_end`) plus the auth-bridge and dispatch rows all ride it. -- **Docs honesty** ✓ — the handler module docs state precisely what +- **Docs honesty** ✓, as the handler module docs state precisely what is deliberately not here (the family seams, the client direction, the store-and-take handoff, the wave-3 tunnel accept-flow), each seam stub documents its wave-3 replacement point, and the inherited-posture note (the bare `auth-agent` / `forwarded-streamlocal` channel types decline via russh's own - inline rejection at this pin — flagged as an upstream watch item, a + inline rejection at this pin, flagged as an upstream watch item since a pin bump that changed it would surface as accepted-then-dropped) is recorded rather than silently relied on. -- **The wave-1 gate's lesson absorbed** ✓ — `cargo doc --no-deps` +- **The wave-1 gate's lesson absorbed** ✓, as `cargo doc --no-deps` ran clean on this gate (0 warnings), and `taskgraph validate` passes. -### Interpretations (flagged for this gate) — all confirmed +### Interpretations (flagged for this gate), all confirmed -1. **Git-parseability probe is skeleton-level** — confirmed: +1. **Git-parseability probe is skeleton-level**, confirmed: `classify_exec` carries service-prefix + path-shape only, its module docs name the wave-3 replacement explicitly, and the probe rows pin exactly the POC-3 stage-A facts the skeleton needs (unknown service → pipe row; total over arbitrary input). 2. **Routing stubs decline at the family-seam boundary, documented as - skeleton** — confirmed: the stub bodies are the named wave-3 + skeleton**; confirmed: the stub bodies are the named wave-3 replacement points (`git_row_stub` … `tunnel_row_stub`), the serving-path decline shape (failure then close) is wire-pinned, and the tunnel stub's `ConnectFailed` reason matches ADR-006 decision 1's fail shape. -3. **Adapter error surface shaped in `w2-server-mount`** — confirmed: +3. **Adapter error surface shaped in `w2-server-mount`**, confirmed: `AdapterError` is the closed two-class taxonomy (`Construction(#[from] KeySourceError)` + `Wire(#[from] russh::Error)`) with the locked→no-server rule at construction and no catch-all; the wire decline reasons landed in the dispatch, not in the error taxonomy (the wave-1 reservation spent correctly). 4. **Harness transport and placement pinned in - `w2-loopback-harness`** — confirmed, recorded in its module docs. + `w2-loopback-harness`**, confirmed and recorded in its module docs. ### Minor issues found and fixed inline (this session) @@ -212,7 +212,7 @@ Doc-hygiene only, in the task files (no `src/` change warranted): - **No observability instrumentation on the decline paths.** The request-level decline class's "reason" is currently only the - routing decision documented per build — nothing server-side logs + routing decision documented per build, so nothing server-side logs which reason a given wire decline carried (`tracing` is in the manifest per the ADR-002 toolkit but unused in `src/` so far). Small follow-up task for the next wave's decomposition (alongside the @@ -228,7 +228,7 @@ Doc-hygiene only, in the task files (no `src/` change warranted): flow. No shape errors found in the mount signature, the auth-bridge state, -or the dispatch routing — the three churn-critical surfaces this gate +or the dispatch routing, the three churn-critical surfaces this gate exists to protect. ### Verification (all green) @@ -253,7 +253,7 @@ everything, x11/auth-agent never gated), ADR-004's dispositions as explicit hook code (ed25519-only fingerprint bridge, four explicit declines, username structurally excluded), ADR-006's constraints parse-only dispatch, decline-first-alloc-never structural, and -spawn-and-return pinned by test over the real wire — plus the bounded +spawn-and-return pinned by test over the real wire, plus the bounded in-crate channel cap enforced and recovering at the handler surface, the skeleton decline-matrix rows green per feature build through the loopback harness, and docs that state what is deliberately deferred. diff --git a/tasks/implementation/review-wave-3.md b/tasks/implementation/review-wave-3.md index 3936245..056316d 100644 --- a/tasks/implementation/review-wave-3.md +++ b/tasks/implementation/review-wave-3.md @@ -1,6 +1,6 @@ --- id: review-wave-3 -name: Wave 3 gate — the server-mappings review +name: "Wave 3 gate: the server-mappings review" status: completed depends_on: [w3-seam-config, w3-decline-tracing, w3-git-mapping, w3-tty-session, w3-tunnel-direct, w3-tunnel-listen] scope: narrow @@ -12,53 +12,53 @@ level: review ## Description The wave-3 review gate. The server mappings are the crate's spine -landed in wire-capable form — the wave-4 client direction reuses the +landed in wire-capable form, and the wave-4 client direction reuses the same grammar/rows pointed upstream, wave 5 builds the full matrix on these rows, and the published crate freezes this surface. The gate reads the landed mappings against the architecture line by line: -- **mapping.md line-by-line** — each of the spine's server rows +- **mapping.md line-by-line**, covering each of the spine's server rows (session/pty/shell/pipe; git exec; direct-tcpip; direct-streamlocal; forwarded-tcpip; the global listen rows; the request-level table) read against the landed code; the §exit derivation contract rules 1–6 verified in code + rows; the decline matrix green **per feature build** through the harness (including - the unset-field rows — feature on, handle unset). -- **ADR-006** — route-1 bridge (accept only after the fabric open + the unset-field rows, with the feature on and the handle unset). +- **ADR-006**, route-1 bridge (accept only after the fabric open succeeds; `ConnectFailed` on failure), exec-only git (no subsystem dispatch anywhere in the grammar), parse-only dispatch still structural (no request-less serving), spawn-and-return over the **real** serving paths (the wave-2 constraint re-pinned over mappings, not stubs). -- **ADR-003** — the per-build matrices; honest advertisement +- **ADR-003**, the per-build matrices; honest advertisement unchanged (an unfeatured type declines; the sftp feature still - gates nothing, its rows still decline in every build — OQ-13); + gates nothing, its rows still decline in every build, per OQ-13); x11/auth-agent never-feature rows untouched. -- **Auth.md / identity** — the identity ride observable at every seam +- **Auth.md / identity**, the identity ride observable at every seam (alktty/alkgit params; the channels connection for tunnels), `set_identity` once-only mechanics recorded; the parked wave-2 `auth_succeeded` item verified discharged; no username anywhere. -- **OQ resolutions in code** — OQ-08 (unset-declines on shell/wrapper +- **OQ resolutions in code**, namely OQ-08 (unset-declines on shell/wrapper rows), OQ-09 (env declined), OQ-10 (derivation rules + the PTY-path fidelity limit documented), OQ-11 (runtime-Unix gates compile-everywhere + clean off-Unix declines), OQ-13 (sftp rows unchanged). -- **Bounded resources** — no new unbounded shape: the cap across the +- **Bounded resources**, with no new unbounded shape: the cap across the fabric window (release-on-failure pinned), the listener bookkeeping bounded, the control-row surfaces bounded, the bridge adapters bounded. -- **Tracing** — the uniform event vocabulary across all landed +- **Tracing**, the uniform event vocabulary across all landed serve/decline paths; the safety posture held (no secrets, no username). -- **The smokes** — the three real-tool postures recorded honestly +- **The smokes**, with the three real-tool postures recorded honestly (required/conditionally-skipped with reason); nothing silently green. -- **Wave-2 gate's booked items** — every one discharged or explicitly +- **Wave-2 gate's booked items**: every one discharged or explicitly re-booked (decline tracing = landed; acceptor vocabulary = landed in `w3-tunnel-listen`; identity landing = discharged; the inherited-auth-agent-open watch item = unchanged or re-read at any pin touch). -- **Docs honesty** — module docs name what each replaced stub now +- **Docs honesty**: module docs name what each replaced stub now is, what remains wave-4/5 material, and any new upstream notes (portable-pty's PTY-path exit shape; the `take_call_connection` semantics reading). @@ -84,103 +84,102 @@ booked (remediation before wave 4 decomposes). - docs/plans/implementation.md (wave 3 section + this gate's role) - docs/architecture/mapping.md, auth.md, client.md (the mirror), overview.md, open-questions.md, ADRs 002/003/004/005/006 -- docs/research/poc-3-mapping-bakeoff.md (stages A/C/D — the evidence +- docs/research/poc-3-mapping-bakeoff.md (stages A/C/D, the evidence the contract tests re-land) ## Notes ### Gate reading (line by line, 2026-10-29; commits 270365e…3d0253d) -- **mapping.md line-by-line** ✓ — each spine row read against the +- **mapping.md line-by-line** ✓, each spine row read against the landed code: the session/pty/shell/pipe rows (translation bridge, OQ-08 argv conventions, pipe-less shell serves pipe-mode), the git exec row (full grammar, no-oracle validation, alkgit dispatch), the direct-tcpip / direct-streamlocal rows (route-1 bridge), the forwarded-tcpip row (echo args + bound port, teardown-at-birth on correlation miss), the global listen rows (verbatim resource + - bound-port out-param — one doc correction landed: §The listen dial + bound-port out-param; one doc correction landed there: §The listen dial now states the port appending, the code's `host:port` form), the request-level table, and the derivation contract rules 1–6 in `derive_exit` (incl. `i32::MIN` via `checked_abs` → 255, `core_dumped` never, rule 3's table entry unreachable by construction, the PTY-path rule-5 limit documented). The decline matrix is green per feature build through the harness (per-build - cfg-pinned modules; unset-field rows — feature on, handle unset — + cfg-pinned modules; the unset-field rows (feature on, handle unset) present in every featured build). -- **ADR-006** ✓ — route-1 bridge (accept only after the fabric open; +- **ADR-006** ✓, route-1 bridge (accept only after the fabric open; `ConnectFailed` on failure; the wave-2 stub retired into the real decline), exec-only git (no subsystem dispatch anywhere in the grammar; git service names decline), parse-only dispatch (no detect residue), spawn-and-return re-pinned over the real serving paths (the wave-2 mechanics rows still green; the inline listen dial is the one recorded exception, bounded by - `LISTEN_DIAL_TIMEOUT` — wire-necessity reasoning documented). -- **ADR-003** ✓ — per-build matrices; honest advertisement + `LISTEN_DIAL_TIMEOUT` with the wire-necessity reasoning documented). +- **ADR-003** ✓, per-build matrices; honest advertisement unchanged; the sftp feature still gates nothing and its rows decline in every build (OQ-13); x11/auth-agent never-features untouched; feature-to-dep isolation (`cargo tree` proven in `w3-seam-config`). -- **Auth/identity** ✓ — the resolved identity observed at every seam: +- **Auth/identity** ✓, the resolved identity observed at every seam: alktty (the ownership witness row), alkgit (the authorize/ deny row), tunnels (the CF-005/CF-006 establisher + ACL rows); `set_identity`/`take_call_connection` appears **nowhere in src/** - (the detach decision recorded, the upstream ask booked — see below + (the detach decision recorded, the upstream ask booked (see below for final disposition); the wave-2 `auth_succeeded` parked item discharged as ride-along store; no username anywhere (the safety-scan row). -- **OQ resolutions in code** ✓ — OQ-08 (unset-declines, both field +- **OQ resolutions in code** ✓, OQ-08 (unset-declines, both field classes, `wrapper ++ [cmd]` verbatim-append), OQ-09 (env declined unconditionally), OQ-10 (derivation rules + PTY-path limit documented), OQ-11 (runtime-Unix gates compile-everywhere with dial-call-witness-at-zero off-Unix rows), OQ-13 (sftp rows unchanged in every build). -- **Bounded resources** ✓ — the cap across the fabric window (admit +- **Bounded resources** ✓, with the cap across the fabric window (admit at the hook, release-before-reject on fabric failure, disjoint saturating release sites verified per row), the listener bookkeeping bounded by a dedicated `max_concurrent_channels`-ceiling counter, the route map bounded by construction (minted only at - accepted listens, removed at cancels + session teardown — the + accepted listens, removed at cancels + session teardown, and the `Drop` really removes from the shared router), the control-row surfaces bounded (`try_send`, never awaited on the packet loop), the bridge adapters bounded (mpsc 64, 64 KiB duplexes, 16 KiB read buffers, `MAX_CHUNK_LEN` frame gates). No unbounded shape found in any serving path. -- **Tracing** ✓ — the uniform event vocabulary at every +- **Tracing** ✓, the uniform event vocabulary at every decline/serve site (row + feature_build + channel_id + class + reason + exec_route; declines `debug`, the one `warn` the serving-failure arm), safety posture pinned by scan (no username, no fingerprint, no secret ever in an event). -- **The smokes** ✓ — git clone 2.43 / `ssh -L` + curl / `ssh -tt` +- **The smokes** ✓, git clone 2.43 / `ssh -L` + curl / `ssh -tt` pty / `ssh -R` + curl: all four landed and green in this environment; every one carries a check-gated skip-with-reason posture, nothing silently green. -- **Wave-2 booked items** ✓ — decline tracing landed +- **Wave-2 booked items** ✓, with decline tracing landed (`w3-decline-tracing`); the acceptor vocabulary landed (`w3-tunnel-listen`: harness accept hook + per-arrival cap); the deferred identity-landing assertion discharged (each seam rides the resolved identity, wire-witnessed); the inherited `auth-agent`/`forwarded-streamlocal`-open watch item unchanged (no pin touch this wave). -- **Docs honesty** ✓ — each replaced stub's module docs name what it +- **Docs honesty** ✓, as each replaced stub's module docs name what it now is and what remains wave-4/5; the `set_identity` reading, the resource-naming convention, the inline-dial bound, and portable-pty's PTY-path exit shape are all recorded in module docs (one overbroad Drop doc corrected inline, below). -### Interpretations (flagged at decomposition) — all confirmed +### Interpretations (flagged at decomposition), all confirmed -1. **Whole-`Option` struct per seam** (vs flat `Option` fields) — +1. **Whole-`Option` struct per seam** (vs flat `Option` fields), confirmed: each seam is consumed atomically by exactly one seam call; the flat single-field tunnel handle stays flat. Recorded in `w3-seam-config`. -2. **The server-side sftp bullet already discharged in wave 2** — - confirmed: the rows are in `tests/dispatch_core.rs` per build. -3. **The `-R` listen flow's biggest open closed without a fork** — +2. **The server-side sftp bullet already discharged in wave 2**, confirmed: the rows are in `tests/dispatch_core.rs` per build. +3. **The `-R` listen flow's biggest open closed without a fork**, confirmed: the topology closed over the published alkcall 0.10.1 / alktunnels 0.4.1 surfaces via the `TunnelListenSeam` course-correction; a scratch probe (published crates only) proved the flow before landing; nothing forked or absorbed. -4. **The wave-2 booked items' wave-3 homes** — confirmed as above. +4. **The wave-2 booked items' wave-3 homes**, confirmed as above. ### Minor issues found and fixed inline (this session) @@ -190,47 +189,47 @@ Source/doc fixes (all commit-tested; suite re-run green after): construction** (the gate's one real fix): alkcall's `OperationRegistry::register` silently *replaces* an existing op entry, contradicting the `install_forwarded_surface` docs' recorded - fail-closed claim — a second adapter over the same shared seam + fail-closed claim: a second adapter over the same shared seam registry would silently steal the first adapter's arrival routes. `install_forwarded_surface` now pre-checks the op (`registry.registration(OP_TUNNEL_FORWARDED)`) and refuses with `AdapterError::ListenRegistration`; new wire-level row `a_second_adapter_over_a_shared_listen_registry_fails_construction` pins both constructs/refuse. -- **channel_cap.rs** — the bridge-failure release doc said "after the +- **channel_cap.rs**, the bridge-failure release doc said "after the reject is answered"; the code (and the pinned test) release *before* the reject. Doc corrected. -- **handler.rs** — the `Drop` doc claimed "every spawned serving task +- **handler.rs**, the `Drop` doc claimed "every spawned serving task watches" the teardown signal; scoping corrected (the session rows watch; the git task ends through the duplex, bounded by alkgit's `session_wall_clock`). The cancel-form doc's recorded ordering (router-before-map) was inverted vs the code; corrected, with the post-cancel-race caveat recorded. -- **tunnel_map.rs** — the "never half-serves" bookkeeping claim now +- **tunnel_map.rs**, the "never half-serves" bookkeeping claim now records the one-arrival caveat: an arrival that cloned its route just before a cancel completes independently (never half-open; sshd parity). -- **tty_map.rs** — stale ADR cross-ref on the exit chunk (alktty +- **tty_map.rs**, a stale ADR cross-ref on the exit chunk (alktty ADR-005 → ADR-004 on the alknet ADR-055 lineage). -- **lib.rs** — the crate-level wave-state doc still said "wave 2 +- **lib.rs**, the crate-level wave-state doc still said "wave 2 decomposes after this gate"; updated to the landed state. -- **tests/dispatch_core.rs** — the stale +- **tests/dispatch_core.rs**, the stale `unknown_git_services_route_to_the_pipe_row` name/doc superseded by the w3 decision ("off the pipe row"); renamed, verdicts preserved - (the wire shape coincides — why it never failed). -- **handler.rs (git row)** — a pty-then-git-exec in a combined build + (the wire shape coincides, which is why it never failed). +- **handler.rs (git row)**, a pty-then-git-exec in a combined build left the stored pty entry until close; the git take now drops it (the pipe row's consumption shape). -- **mapping.md** — the §listen-dial resource wording corrected to the +- **mapping.md**, the §listen-dial resource wording corrected to the code's assembled form (address verbatim + tcp port appended; `0` stays `0`). -### Booked findings (not remediation this gate — wave-4 material) +### Booked findings (not remediation this gate, wave-4 material) 1. **The git row's status-before-close is honored by scheduling, not by construction** (the gate's only structural moderate): `serve_duplex_resolved` splits the channel stream internally and - its session halves drop when the serve returns — russh's + its session halves drop when the serve returns, so russh's `ChannelCloseOnDrop` rx-half emits a server→client `CHANNEL_CLOSE` *inside* the seam call, before `serve_git_row`'s explicit `exit_status_request`. Today the @@ -240,11 +239,11 @@ Source/doc fixes (all commit-tested; suite re-run green after): implicit close first and silently drop the report (russh no-ops `exit_status_request` for an already-closed channel). A fix wants a seam/shape change (split halves into the seam, or queue the - report while the stream is alive) — beyond this gate's inline + report while the stream is alive), beyond this gate's inline budget; book a wave-4 hardening item. (The tty rows are NOT in this class: their report precedes any close-contracting drop in one task.) -2. **The `channel-not-held` guard lacks wire test rows** — a +2. **The `channel-not-held` guard lacks wire test rows**, a documented decline class on all three rows (git/tty pipe/shell), constructible on the wire (a second exec on a served channel), but no row asserts it in any build; mapping.md's every-row @@ -255,14 +254,14 @@ Source/doc fixes (all commit-tested; suite re-run green after): the wire (only the teardown signal is); the Closed/Break no-exit-chunk close shape has no wire row; the tty stdout `write_all` carries no deadline (backpressure bounded, liveness - covered by inactivity timeout — record as a known limit only). + covered by inactivity timeout; record it as a known limit only). 4. **Defensive `ListenRouter` token-collision pre-check** (minor): `register` silently replaces on token collision; safety rests on the fabric's ADR-008 mint discipline. A fail-closed decline-the-listen-on-collision is a cheap wave-4 addition. 5. **Upstream asks to file** (booked with their owners, not this crate): the alktunnels/alkcall "non-detaching post-construction - identity seam" note (`w3-tunnel-direct`'s recorded decision — the + identity seam" note (`w3-tunnel-direct`'s recorded decision, under which the door keeps the no-detach posture until upstream proposes a seam), and mapping.md's recorded event-vocabulary nuance for the unreachable mismatch arms (`correlation-miss` naming). diff --git a/tasks/implementation/review-wave-4.md b/tasks/implementation/review-wave-4.md index 0ab82b0..e5d55a1 100644 --- a/tasks/implementation/review-wave-4.md +++ b/tasks/implementation/review-wave-4.md @@ -1,6 +1,6 @@ --- id: review-wave-4 -name: Wave 4 gate — the client-direction review +name: "Wave 4 gate: the client-direction review" status: completed depends_on: [w4-dial, w4-op-rows, w4-reverse-listen, w4-lifecycle, w4-git-exit-close, w4-channel-not-held-rows, w4-tty-coverage, w4-router-collision] scope: narrow @@ -12,49 +12,49 @@ level: review ## Description The wave-4 review gate. The client direction completes the -two-direction door — wave 5 builds the full matrix and the release on +two-direction door, and wave 5 builds the full matrix and the release on both directions, and the published crate freezes this surface. The gate reads the landed client direction against the architecture line by line: -- **client.md line-by-line** — §The dial surface (key source +- **client.md line-by-line**, reading it section by section: §The dial surface (key source decrypt-at-dial fail-closed; the policy at `check_server_key`; the bounded knobs with the keepalive 15 s / 3 posture), §Op rows (each - table row read against the landed code — the POC-3 §D.3 mechanics + table row read against the landed code, the POC-3 §D.3 mechanics pinned in code + rows), §The acceptor (served only under a registered feature's acceptor; unregistered/unfeatured declined - cleanly — never accept-and-drop; late-install across a live + cleanly, never accept-and-drop; late-install across a live connection), §Lifecycle primitives (explicitly cancellable handles; `Disconnect::ByApplication`; **no reconnect/restore policy in the - crate** — the boundary held). -- **ADR-003 symmetry** — the same features gate both directions; the + crate**, a boundary held). +- **ADR-003 symmetry**, where the same features gate both directions; the per-build matrices green client-side (the acceptor rows per build); honest advertisement unchanged; the sftp placeholder declines in every build both directions (OQ-13); x11/auth-agent never-features untouched. -- **ADR-005 / OQ-05 / OQ-07** — decrypt-at-dial with every +- **ADR-005 / OQ-05 / OQ-07**, decrypt-at-dial with every `KeySourceError` posture a no-dial outcome; the policy seam consumed at the hook (crate default connects to nothing; no accept-all built-in); the `Zeroizing` wrap posture intact. -- **The wave-3 gate's booked items 1–4** — every one discharged or +- **The wave-3 gate's booked items 1–4**, every one discharged or explicitly re-booked: the git status-before-close by construction (`w4-git-exit-close`), the channel-not-held wire rows (`w4-channel-not-held-rows`), the tty coverage minors (`w4-tty-coverage`), the router token-collision pre-check (`w4-router-collision`). Item 5 (the upstream asks) verified still - booked with their owners — not absorbed silently. -- **Bounded resources** — no new unbounded shape on the client + booked with their owners, not absorbed silently. +- **Bounded resources**, with no new unbounded shape on the client surfaces: the acceptor registry bounded, the listener bookkeeping bounded, the pumps bounded (the convention-9 read over every new serving/pumping path). -- **Tracing** — the uniform event vocabulary across the new +- **Tracing**, the uniform event vocabulary across the new client-side decline/serve paths; the safety posture held (no secrets, no key material, no username). -- **The smokes** — the client-direction real-tool postures recorded +- **The smokes**, with the client-direction real-tool postures recorded honestly (required/conditionally-skipped with reason); nothing silently green; anything named wave-5 e2e territory named explicitly. -- **Docs honesty** — module docs name what each new surface is, what +- **Docs honesty**: module docs name what each new surface is, what remains wave-5 material, and any new upstream notes (the russh client-hook shapes at this pin; the acceptor-registry shape decision; the DialConfig disposition). @@ -96,75 +96,75 @@ match `docs/plans/implementation.md`'s decomposition record; every task file carries its completion records and checked acceptance criteria. -- **client.md line-by-line** ✓ — read against the landed code: - §The dial surface (`client.rs` — the four-step fail-closed +- **client.md line-by-line** ✓, read against the landed code: + §The dial surface (`client.rs`, the four-step fail-closed sequence: decrypt-at-dial key load, the bounded russh `Config` with the keepalive 15 s / 3 posture and the always-set wall clock, the policy at `check_server_key`, the single publickey-only auth; the sanctioned `username` `DialConfig` amendment recorded honestly - — a required parameter, no crate default, never identity, never - logged); §Op rows (`client_ops.rs` — every table row present: the + (a required parameter, no crate default, never identity, never + logged); §Op rows, covering `client_ops.rs`, where every table row is present: the exec/pty/shell rows under `session`, the direct rows under `tunnel`, the git exec row under `git` over the shared grammar's construction arm, the sftp placeholder in every build; POC-3 - §D.3 mechanics pinned in code + rows — `exec()` not + §D.3 mechanics pinned in code plus rows (`exec()` not `request_exec`, `request_exec`, the `wait()`-drain Success/Failure, split-halves wait-pumps for the exit-consuming rows and straight `into_stream` for the exit-less, the fire-and-forget controls sent from the live pump, no queue consumption before any conversion); §The acceptor (`client_accept.rs` + the - `client_handler.rs` hooks — per-type fixed slots, refuse-never- + `client_handler.rs` hooks, being per-type fixed slots, refuse-never- replace, arrival-time reads, decline-not-buffer, late-install over the live connection, listen rows gated pre-wire, the two dedicated counters against the one ceiling); §Lifecycle primitives (`client_accept.rs` handles + `client.rs` §Lifecycle - posture — explicit cancels, `Disconnect::ByApplication` the only + posture, with explicit cancels, `Disconnect::ByApplication` the only clean path, drop-is-never-the-clean-path row-pinned both ways, the no-reconnect/restore-policy boundary stated in module docs; the crate owns no policy). client.md's landing blocks (acceptor/ lifecycle/op-row shapes recorded at landing) are in the doc. -- **ADR-003 symmetry** ✓ — the features gate both directions +- **ADR-003 symmetry** ✓, as the features gate both directions (`Cargo.toml` unchanged: `session`/`git`/`tunnel`/`sftp`, `default = []`); the client exports ride the same gates (`RemoteSession` under `session`, `TunnelChannel` + the acceptor surface + `ReverseListener` under `tunnel`, `GitPackSession` under `git`, the sftp row feature-free); the per-build matrices - are green (the default build's 16 binaries + per-feature/ + are green (the default build's 16 binaries plus per-feature/ `--all-features`; the unfeatured builds' fail-closed wire rows - ride `tests/client_dial.rs`'s decline-all row — the + ride `tests/client_dial.rs`'s decline-all row, and the `client_handler.rs` hooks are cfg-declined in every build); honest advertisement unchanged; x11/agent never-features untouched on both directions. -- **ADR-005 / OQ-05 / OQ-07** ✓ — decrypt-at-dial with every +- **ADR-005 / OQ-05 / OQ-07** ✓, decrypt-at-dial with every `KeySourceError` posture a no-dial outcome (`DialError::KeySource` preserved per-variant; `error.rs` taxonomy rows); the policy seam - consumed at the hook (`client_handler.rs::check_server_key` — the + consumed at the hook (`client_handler.rs::check_server_key`, where the fingerprint rides the crate's single path, `PresentedHostKey` carries host/port, the verdict passes through; `RejectAll` connects to nothing, no accept-all built-in); the wave-1 `Zeroizing` wrap posture intact (`key_source.rs`, untouched). -- **The wave-3 gate's booked items 1–4** ✓ — all discharged: - item 1 (`w4-git-exit-close`) — the split-halves seam duplex +- **The wave-3 gate's booked items 1–4** ✓, all discharged: + item 1 (`w4-git-exit-close`), the split-halves seam duplex (`ChannelHalveReader` over the owned close-free read half; no drop path ever messages the wire) with the by-construction property wire-pinned and the row falsifying the old shape deterministic-red-under-revert; item 2 - (`w4-channel-not-held-rows`) — the guard's wire rows on all + (`w4-channel-not-held-rows`), the guard's wire rows on all three served rows per build, vocabulary-asserting; item 3 - (`w4-tty-coverage`) — the `channel_close`-fired cancel path + (`w4-tty-coverage`), the `channel_close`-fired cancel path wire-witnessed, the Closed/Break no-exit-chunk shape row-pinned (Break's frame classification unit-rowed, wire-unreachable, recorded), the `write_all` no-deadline limit recorded in module - docs; item 4 (`w4-router-collision`) — the register-refuses + docs; item 4 (`w4-router-collision`), the register-refuses pre-check with the error-surface decision recorded honestly (the wire-time decline disposition, not a taxonomy variant that would kill the session). **Item 5 verified still booked with its - owners, not absorbed**: no `set_identity`/`take_call_connection` + owners and not absorbed**: no `set_identity`/`take_call_connection` anywhere in `src/` code (doc references only, recording the no-detach posture + the upstream ask), and mapping.md's correlation-miss event-vocabulary nuance records stand. -- **Bounded resources** ✓ (convention 9 read over every new - client serving/pumping path) — the acceptor registry bounded by +- **Bounded resources** ✓, with the convention-9 read over every new + client serving/pumping path showing the acceptor registry bounded by construction (two slots); the reverse-listen count and the accepted-open handoff count are two dedicated `ChannelCap` counters against the same `Limits::max_concurrent_channels` @@ -174,37 +174,37 @@ criteria. launch-drain cap, russh windows verbatim, the always-on wall clock); the git row's command/repo gates answer pre-wire. One gap found and fixed inline (below). -- **Tracing** ✓ — the client-side decline paths ride the uniform +- **Tracing** ✓, with the client-side decline paths riding the uniform vocabulary (`decline_open` with `no-acceptor` / `cap-exceeded` / `never-feature`; the skeleton's decline rows event-pinned); the - safety posture held — structurally no username/fingerprint/secret + safety posture held, as structurally no username/fingerprint/secret parameter exists on any event helper (the `&'static str` - vocabulary); the client op rows' typed surfaces carry outcomes + vocabulary); the client op rows' typed surfaces also carry outcomes without events (the recorded asymmetry decision). -- **The smokes** ✓ — three recorded honestly: the alkssh-client ↔ +- **The smokes** ✓, three recorded honestly: the alkssh-client ↔ alkssh-server dial smoke (`tests/client_dial.rs`), the client-exec ↔ server-session-rows smoke (`tests/client_ops.rs`, check-gated on `sh`), the full-circle reverse-listen smoke (`tests/tunnel_mapping.rs`); real-`sshd` e2e named wave-5 - territory explicitly in each task's records — nothing silently + territory explicitly in each task's records, with nothing silently green, nothing dropped. -- **Docs honesty** ✓ — module docs name each new surface and its +- **Docs honesty** ✓, as module docs name each new surface and its remaining wave-5 material (the fake-`sshd` posture, the DialConfig amendment, the russh client hook shapes at this pin, - the acceptor-registry shape decision, the recorded wire facts — + the acceptor-registry shape decision, and the recorded wire facts the port-0-only mint, the drop/is-closed semantics); one stale crate-level sentence fixed inline (below). ### Minor issues found and fixed inline (this session) -- **The accepted-open handoff counter's panic-unsafe release** (the - gate's one code fix): the counter released only after - `acceptor.accept(...)` returned — an assembly acceptor panicking - inside `accept` stranded one held slot for the connection's - lifetime (a bounded-resource leak path on the new cap). Fixed +- **The accepted-open handoff counter's panic-unsafe release**, the + gate's one code fix: the counter released only after + `acceptor.accept(...)` returned, so an assembly acceptor panicking + inside `accept` would strand one held slot for the connection's + lifetime, a bounded-resource leak path on the new cap. Fixed with a panic-safe guard (`HandoffCapGuard` in `src/client_accept.rs`): the release fires at the handoff task's - end, on completion *or unwind*; the recorded release-site wording + end, whether on completion or unwind; the recorded release-site wording updated in the module docs, the trait docs, and `client_handler.rs`'s two references; a unit row (`the_handoff_guard_releases_on_unwind`) pins the unwind release. @@ -214,23 +214,23 @@ criteria. `w4-lifecycle`; the same staleness class the wave-3 gate fixed inline). Corrected to the landed state. -### Interpretations (flagged at decomposition) — all confirmed +### Interpretations (flagged at decomposition), all confirmed 1. **The fail-closed client Handler skeleton in `w4-dial`, the - registry in `w4-reverse-listen`** — confirmed: the skeleton's + registry in `w4-reverse-listen`**, confirmed: the skeleton's decline-all default is row-pinned in every build, and the registry grows exactly the two forwarded hooks; the split kept both surfaces reviewable. -2. **The fake-`sshd` client-direction vehicle in `tests/common`** — +2. **The fake-`sshd` client-direction vehicle in `tests/common`**, confirmed: vanilla-parity, real loopback TCP, one dial per instance, and the harness's `ForwardedAcceptor` vocabulary was grown (not forked) into `client_accept.rs`'s real surface. -3. **No `DialConfig` course-correction anticipated** — confirmed, +3. **No `DialConfig` course-correction anticipated** was confirmed, with the one sanctioned exception recorded in-task (the required - `username` — the USERAUTH_REQUEST's structural requirement; the + `username`, the USERAUTH_REQUEST's structural requirement; the amendment follows the `w3-seam-config` pattern and is honestly documented). -4. **The hardening tasks ordered by single-writer needs** — +4. **The hardening tasks ordered by single-writer needs** were confirmed: `tests/git_mapping.rs` then `tests/tty_mapping.rs` sequencing held, no cross-task file conflicts in the landed history. @@ -259,9 +259,9 @@ and the lifecycle primitives with the drop/clean-close boundary row-pinned both ways and the no-policy posture stated. All three interpretations confirmed except as recorded (all favorable); the wave-3 booked items 1–4 verified discharged and item 5 verified -still booked upstream — nothing absorbed. The full verification set +still booked upstream, with nothing absorbed. The full verification set is green. Two minors fixed inline (the handoff counter's -panic-unsafe release — the one code fix, guard + unit row; a stale +panic-unsafe release, the one code fix with its guard and unit row; a stale crate-level wave-state doc sentence). No structural findings; wave 5 decomposition is unblocked and should absorb nothing beyond its own plan (the decline matrix, real-tool e2e, fuzzing, docs, the @@ -269,12 +269,12 @@ publish). ## Summary -**Verdict: green** (2026-10-29) — see Notes for the full gate +**Verdict: green** (2026-10-29); see Notes for the full gate reading. All eight wave-4 tasks verified against the client.md/ADR contract surfaces line by line; the wave-3 gate's booked items 1–4 discharged, item 5 verified still booked upstream; the full verification set + `taskgraph validate` green. Two inline fixes landed with the gate: the accepted-open handoff counter's -panic-safe release (`HandoffCapGuard` — the gate's one code fix) +panic-safe release (`HandoffCapGuard`, the gate's one code fix) and the stale crate-level wave-state doc sentence in `lib.rs`. Wave 5 decomposition is unblocked. \ No newline at end of file diff --git a/tasks/implementation/review-wave-5.md b/tasks/implementation/review-wave-5.md index 6d5e970..4145a59 100644 --- a/tasks/implementation/review-wave-5.md +++ b/tasks/implementation/review-wave-5.md @@ -1,6 +1,6 @@ --- id: review-wave-5 -name: Wave 5 gate — the release review +name: "Wave 5 gate: the release review" status: completed depends_on: [w5-decline-matrix, w5-e2e-server, w5-e2e-client, w5-fuzz-harness, w5-release-readiness] scope: narrow @@ -11,40 +11,40 @@ level: review ## Description -The wave-5 review gate — the release review that closes the plan +The wave-5 review gate, the release review that closes the plan (`docs/plans/implementation.md` §Wave 5: "then **the publish of -`alkssh 0.1`** — the gate review that closes the plan"). The gate +`alkssh 0.1`**, which is the gate review that closes the plan"). The gate stays a **pure review**; the publish is its own task (`w5-publish`) executing only over a green verdict. The gate reads: -- **The decline-matrix suite** — the coverage-audit table read +- **The decline-matrix suite**, reading the coverage-audit table against `mapping.md`'s matrix table cell by cell: every row × every build it can appear in × both directions pinned; the sftp placeholder rows and the never-features explicit in every build; the matrix runner in the verification list. -- **The e2e postures** — both directions' real-tool rows read: +- **The e2e postures**, reading both directions' real-tool rows: nothing silently green (every skip reasoned with the tool/version cause), every e2e finding discharged or explicitly booked with an - owner (never absorbed); the real-`sshd` client-direction rows + owner, never absorbed; the real-`sshd` client-direction rows verified against the reverse-runner shape. -- **The fuzz corpus gate** — the sidecar's layout read against the +- **The fuzz corpus gate**, reading the sidecar's layout against the alkgit convention (detached runner, committed seeds + no-op-diff generator, the shared-library corpus replay in the verification list); the campaign logs read (no unresolved crash/OOM/timeout); the exposure shape read (no non-`doc(hidden)` surface growth). -- **Release readiness** — rustdoc warning-free × builds; README/ +- **Release readiness**: rustdoc warning-free across builds; README/ CHANGELOG read for honesty; the packaged file list against the package policy; the manifest sanity; **the freeze inventory read - against the landed code** — the list of surfaces the publish makes + against the landed code**, checking the list of surfaces the publish makes one-way (the mapping contracts, the honest-advertisement posture, the decline-matrix behavior, the fingerprint format, the russh pin's wire posture) confirmed accurate, because `w5-publish` executes immediately after this gate. -- **The plan's closure conditions** — the scope-authority checklist +- **The plan's closure conditions**, verifying the scope-authority checklist (the complete two-direction door shipped; the assembly out; no binary; single crate) verified against the landed state; the upstream asks (the wave-3 gate's item 5 lineage) verified still - booked with their owners — nothing absorbed silently through the + booked with their owners, with nothing absorbed silently through the release. Verdict: green (`w5-publish` unblocked) or findings booked @@ -80,27 +80,27 @@ Verdict: green (`w5-publish` unblocked) or findings booked ## Notes **Verdict: green.** `w5-publish` is unblocked. Every gate read was -re-executed live at this commit (2026-10-30, post-`ab5236e`) — nothing +re-executed live at this commit (2026-10-30, post-`ab5236e`), and nothing was taken solely from the task records. No blocking issues; no code findings; nothing rose to booked-remediation level. Notes below. ### The matrix suite ✓ Every test name the coverage audit pins resolves in its binary (the -full inventory grep-verified this session — all 26 `dispatch_core` +full inventory grep-verified this session, with all 26 `dispatch_core` rows / all 8 `tunnel_mapping` + `git_mapping` + client-side rows named in the audit table exist), and `mapping.md` §Decline matrix traces cell by cell onto audit rows: the none build declines everything (`every_channel_open_declines_in_a_default_build` + the shared x11/agent/cancel/listen rows); the git build's declines -(pty/shell — the gap-closed pty row, plain exec, tunnel opens, +(pty/shell including the gap-closed pty row, then plain exec, tunnel opens, unreachable session requests, subsystems, unknown services) and its one serve (git-parseable exec); the session build's symmetric shape (git-parseable exec declines, wrapper-unset declines, seam-unset pty/shell); the tunnel build's serve/decline split incl. the seam-unset listen forms and the `ConnectFailed` fabric reject; the sftp slot's unchanged-underlying-build row (the subsystem row can't appear where -session channels decline at open — the audit row 8's reasoning holds; +session channels decline at open, so the audit row 8's reasoning holds; the client-side placeholder pins every build); the never-features pinned in every build both directions, with the `auth-agent` open's pin-bound-unreachable record read and accepted (the russh-0.65 @@ -108,25 +108,25 @@ pre-handler decline is part of the pin per ADR-002). The runner (`scripts/matrix.sh`, the script vehicle decided + recorded) is in AGENTS.md's verification list. -### Both e2e postures ✓ — nothing silently green +### Both e2e postures ✓, with nothing silently green `tests/e2e_server.rs` (12 rows) and `tests/e2e_client.rs` (7 rows) both read: every real-tool row is check-gated with -`support::skip(row, reason)` prints — tool absent/short, spawn +`support::skip(row, reason)` prints, covering a tool absent or short, spawn refusal, and (client fixture) never-listening with the server's `-e` -log tail — never a silent pass; every row is deadline-bounded end to +log tail, so never a silent pass; every row is deadline-bounded end to end; every decline row additionally pins the door-side decline event (the e2e_server rows). The task records' findings are all e2e-graph-local flake hardening (per-row workdirs, the monotonic -port allocator, the fabric settle, the event poll) — corroborated by +port allocator, the fabric settle, the event poll), corroborated by the 10-consecutive-run claims and this session's full-matrix run. The e2e_client fixture/gating decisions (tunnel+session gating for the stays-usable row, every-build rows for the feature-free ops) are recorded in the task and read honest. On this host every row ran -(OpenSSH 9.6p1, git 2.43, curl 8.5, sftp present) — the matrix log +(OpenSSH 9.6p1, git 2.43, curl 8.5, sftp present), and the matrix log carries the real-tool rows green, no skip prints fired. -### The fuzz sidecar ✓ — convention held, logs clean +### The fuzz sidecar ✓, with the convention held and logs clean Layout matches the alkgit convention (`fuzz/README.md`: thin `fuzz_target!` wrappers, the stable-clean `shared/` library, the @@ -134,15 +134,15 @@ detached runner, the nightly pin inside `fuzz/`, committed seeds + the deterministic generator, grown corpora/artifacts/coverage gitignored). `gen_fuzz_seeds.py` re-run this session was a no-op diff on the committed set. The corpus gate re-ran green (11/11, -exit 0). The four final campaign logs read clean — `oom/timeout/ +exit 0). The four final campaign logs read clean: `oom/timeout/ crash: 0/0/0`, `exiting: 0` (exec_parse 43M+ execs / fingerprint 12M+ / key_decode 5M+ / terminal_params 9M+); the two first-campaign crash logs (exec_parse exit 77, key_decode exit 77) are exactly the -recorded in-task findings — the harness oracle's double-`/` strip +recorded in-task findings, the harness oracle's double-`/` strip (harness-side, fixed) and the ssh-key PPK header echo (the real finding: `decode_key_arm` now carries a crate-owned fixed message; re-ran clean). The exposure shape matches the record: one -no-exposure target (fingerprint — fully public ingress) + three +no-exposure target (fingerprint, with its fully public ingress) plus three `#[doc(hidden)]` production-surface re-exports; the root crate's package excludes the sidecar (verified in the package list) and the root workspace excludes it (workspace + package excludes in @@ -151,40 +151,40 @@ root workspace excludes it (workspace + package excludes in ### Release readiness ✓ `cargo doc --no-deps` warning-free on the default build (matrix run) -**and** `--all-features` — re-run this session, exit 0, zero +**and** `--all-features`, re-run this session with exit 0 and zero warnings. README (feature graph + fail-closed posture + custody summary + the doctest-mirrored examples) and the CHANGELOG 0.1.0 founding entry both read honest and match the landed surface. The publish dry-run re-ran clean (76 files packaged, verification compile passed, upload aborted by dry run); `cargo package --list` -re-verified against the package policy — exactly `src/` (20), +re-verified against the package policy: exactly `src/` (20), `tests/` (+common +vectors), `docs/architecture/` (the full contract: the four spec docs + ADRs 001–006 + README + the tracker), `README`, `CHANGELOG`, both licenses, the manifests, and `scripts/matrix.sh`; none of the excluded surface (AGENTS.md, `.opencode/`, plans/research/reviews/sdd_process, tasks/, `fuzz/`) -appears. Manifest sanity: version 0.1.0 (the manifest value — -`w5-publish` verifies, no bump), edition 2024, `rust-version` 1.89, +appears. Manifest sanity: version 0.1.0 (the manifest value; +`w5-publish` verifies that no bump is needed), edition 2024, `rust-version` 1.89, license + both files, the pins exactly as ADR-002 records them (russh 0.65 `default-features = false` + `aws-lc-rs`; alkcall 0.10; alkvault 0.1; the optional siblings 0.8.1 / 0.1.0 / 0.4.1), the feature graph exactly ADR-003's flat shape, `default = []`. -### The freeze inventory ✓ — accurate against the landed code +### The freeze inventory ✓, accurate against the landed code -Item by item: (1) the mapping contracts — the route-1 bridge, +Item by item: (1) the mapping contracts, i.e. the route-1 bridge, exec-only grammar + verdicts, parse-only dispatch + decline-before-alloc, translation bridge + exit rules 1–6, the client -planes — as executed in `dispatch.rs`/`tty_map.rs`/`git_map.rs`/ +planes, as executed in `dispatch.rs`/`tty_map.rs`/`git_map.rs`/ `tunnel_map.rs`/`client_ops.rs` and line-by-line read green at the -wave-3/4 gates; (2) the honest-advertisement posture — `MethodSet` +wave-3/4 gates; (2) the honest-advertisement posture, where `MethodSet` exactly `{PublicKey}` at the adapter's `Config` construction (`adapter.rs:135`), verified by its own unit row; (3) the -decline-matrix behavior — the `OpenDeclineReason` vocabulary and +decline-matrix behavior, the `OpenDeclineReason` vocabulary and decline shapes as pinned by the matrix suite; (4) the fingerprint -format — `SHA256:` + unpadded base64 via `HashAlg::Sha256` +format, `SHA256:` plus unpadded base64 via `HashAlg::Sha256` (`fingerprint.rs:29`), the single path both the auth bridge and the -policy pin consume; (5) the russh pin's wire posture — 0.65.0 + +policy pin consume; (5) the russh pin's wire posture, 0.65.0 plus `aws-lc-rs`, `des`/`dsa` never, per `Cargo.toml`. The inventory as recorded in `w5-release-readiness.md` matches the landed code; the publish freezes exactly those surfaces. @@ -194,25 +194,25 @@ publish freezes exactly those surfaces. The complete two-direction door shipped (server stack + all four mapping rows + the decline placeholder; dial + op rows + the `-R` leg + lifecycle primitives); the assembly stays out (no -identity/ACL/fabric policy owned by the crate — seams only); no +identity/ACL/fabric policy owned by the crate, seams only); no binary (no `[[bin]]` target; the package ships one lib); single crate (single-member workspace, no tracks); nothing absorbed -silently — the upstream-ask inventory verified still booked with its +silently, with the upstream-ask inventory verified still booked with its owners: - wave-3 gate item 5 (two asks): the alktunnels/alkcall non-detaching post-construction identity seam (the door keeps the - no-detach posture — no `set_identity`-style surface appeared in + no-detach posture, and no `set_identity`-style surface appeared in `src/` through the release), and mapping.md's correlation-miss event-vocabulary nuance. -- The wave-5 fuzz finding's `ssh-key` PPK parse-echo ask — recorded +- The wave-5 fuzz finding's `ssh-key` PPK parse-echo ask, recorded in `w5-fuzz-harness.md` as booked with the russh/ssh-key owners; the actual tracker filing is owner-territory and stays listed here so it cannot be absorbed at the publish. ### Observations (non-blocking, no action required) -- The packaged file list includes `tests/vectors/alpha.key` — the +- The packaged file list includes `tests/vectors/alpha.key`, the committed ed25519 test-fixture private key. Intentional (wave 1's fixtures, public test material, no custody posture applies) and consistent across the family's sibling crates; noted here so the @@ -224,8 +224,8 @@ owners: ### Verification (re-run at this commit, all green, 2026-10-30) `scripts/matrix.sh`: `cargo test` × 6 builds (none / git / session / -tunnel / sftp-only / --all-features — 151 / 195 / 210 / 201 / 151 / -281 tests passed, 0 failed; all integration binaries + doctests +tunnel / sftp-only / --all-features, with 151 / 195 / 210 / 201 / +151 / 281 tests passed, 0 failed; all integration binaries + doctests included) · clippy (--all-targets -D warnings) ✓ · clippy (--all-features --all-targets -D warnings) ✓ · `cargo fmt --check` ✓ · `cargo doc --no-deps` ✓ (plus `--all-features` re-run ✓) · the @@ -236,13 +236,13 @@ completion statuses). ## Summary -**Verdict: green — `w5-publish` unblocked.** The wave-5 release gate +**Verdict: green, `w5-publish` unblocked.** The wave-5 release gate re-executed every read live at the gate commit: the coverage audit traced cell by cell onto `mapping.md`'s decline-matrix table with every pinned test resolving in its binary and the `sftp` no-op-feature fact + never-features verified in every build both directions; both e2e binaries read with nothing silently green (the -whole suite ran on this host — no skips fired); the fuzz sidecar +whole suite ran on this host, so no skips fired); the fuzz sidecar held the alkgit convention with the seeds no-op, the corpus gate green, and all four final campaigns clean (the two first-campaign crashes exactly the recorded fixed findings); release readiness @@ -253,6 +253,6 @@ become one-way at the publish; the plan's closure conditions verified (two-direction door, assembly out, no binary, single crate, upstream asks still booked). One real code fix was already discharged in-task (the `ssh-key` PPK echo → crate-owned decode -message). No blocking issues and no findings — this is the plan's +message). No blocking issues and no findings, and this is the plan's last review; `w5-publish` (tag `alkssh-v0.1.0`, the verification set, the publish, the plan closure) closes it. \ No newline at end of file diff --git a/tasks/implementation/w1-config.md b/tasks/implementation/w1-config.md index be4b85f..4de3cf2 100644 --- a/tasks/implementation/w1-config.md +++ b/tasks/implementation/w1-config.md @@ -1,6 +1,6 @@ --- id: w1-config -name: Wave 1 — the config surface (serve-config, dial-config, Limits) +name: "Wave 1: the config surface (serve-config, dial-config, Limits)" status: completed depends_on: [w1-manifest, w1-key-sources, w1-host-key-policy] scope: moderate @@ -11,37 +11,38 @@ level: implementation ## Description -Land the pure configuration types both directions implement against — +Land the pure configuration types both directions implement against, the shapes every later wave's constructors take: -- **Serve-config** (server direction, `mapping.md`/`overview.md`) — - the assembly-supplied serving configuration: the OQ-08 fields - (**shell argv** and **exec wrapper argv**, each `Option>` - — unset → its row declines with a clean error, never a fallback, no - crate default; the wrapper form is platform-neutral), the - `IdentityProvider` handle (`Arc` — the - assembly injects it; the crate never resolves identities itself), - the host-key source handle (the wave-1 key-source seam), the tunnel - `ChannelClient` handle (the route-1 bridge's family-fabric entry — - `Option`, absent → tunnel rows decline even under the feature), and - the `Limits`. `cwd`/`env` in `NegotiateRequest` are assembly-supplied - serve-config values, never wire-derived (OQ-09 posture). -- **Dial-config** (client direction, `client.md` §The dial surface) — - address, key source (the wave-1 seam — never static keys, never env +- **Serve-config** (server direction, `mapping.md`/`overview.md`), the + assembly-supplied serving configuration: the OQ-08 fields + (**shell argv** and **exec wrapper argv**, each `Option>`; + when unset, its row declines with a clean error, never a fallback, + no crate default; the wrapper form is platform-neutral), the + `IdentityProvider` handle (`Arc`, which the + assembly injects; the crate never resolves identities itself), the + host-key source handle (the wave-1 key-source seam), the tunnel + `ChannelClient` handle (the route-1 bridge's family-fabric entry, + held as an `Option`; when absent, tunnel rows decline even under + the feature), and the `Limits`. `cwd`/`env` in `NegotiateRequest` + are assembly-supplied serve-config values, never wire-derived + (OQ-09 posture). +- **Dial-config** (client direction, `client.md` §The dial surface): + address, key source (the wave-1 seam; never static keys, never env vars), the `HostKeyPolicy` handle, and the bounded-resource knobs: `keepalive_interval`/`keepalive_max` (15 s / 3 default posture per the reverse-runner evidence), `inactivity_timeout`, window/packet knobs. -- **`Limits`** — the bounded-resources budget model (`overview.md`'s +- **`Limits`**: the bounded-resources budget model (`overview.md`'s table): the in-crate max-concurrent-channels cap, exec-parse length caps (consumed by the wave-3 grammar), and the russh knob mapping - with documented default postures — `max_auth_attempts` default 10 - (sized for the counting `none`-probe, ADR-004 decision 6), - `window_size`/`maximum_packet_size`/`channel_buffer_size` set, - `inactivity_timeout` set (assembly-tunable). Every session carries + with documented default postures (`max_auth_attempts` default 10, + sized for the counting `none`-probe per ADR-004 decision 6; + `window_size`/`maximum_packet_size`/`channel_buffer_size` set; + `inactivity_timeout` set, assembly-tunable). Every session carries it; no unbounded loops, no unbounded buffers. -Types only — no russh `Config` construction (wave 2), no dialing +Types only: no russh `Config` construction (wave 2), no dialing (wave 4). The OQ-08 unset-declines semantics live in the type shape (`Option` fields + doc contracts), not in construction-time validation. @@ -71,7 +72,7 @@ validation. section) - docs/architecture/client.md §The dial surface - docs/architecture/open-questions.md (OQ-08, OQ-09 resolutions) -- docs/architecture/decisions/004-auth-dispositions.md (decision 6 — +- docs/architecture/decisions/004-auth-dispositions.md (decision 6: the `max_auth_attempts` default) ## Notes @@ -82,25 +83,25 @@ validation. > > - **`Limits` is the single budget model both directions carry** > (`Copy`, plain tuning data): the in-crate caps -> (`max_concurrent_channels` 64, `max_exec_command_len` 8192 — -> numbers chosen here, documented as assembly-tunable posture +> (`max_concurrent_channels` 64, `max_exec_command_len` 8192, with +> numbers chosen here and documented as assembly-tunable posture > constants) plus the russh-knob mapping (`max_auth_attempts` 10 > per ADR-004 decision 6; `window_size` 2 MiB / `maximum_packet_size` -> 32 KiB / `channel_buffer_size` 100 — russh's own defaults, kept; +> 32 KiB / `channel_buffer_size` 100, russh's own defaults kept; > `inactivity_timeout` 600 s carried as plain `Duration` so the -> "set (assembly-tunable)" posture is structural — wave 2/4 map it -> to russh's `Option` as always-`Some`; +> "set (assembly-tunable)" posture is structural, with wave 2/4 +> mapping it to russh's `Option` as always-`Some`; > `keepalive_interval`/`keepalive_max` Some(15 s)/3 riding inside as > the client-direction posture, satisfying the dial-config > criterion without a duplicate knob set). russh's rekey `Limits` > stay russh-enforced at their RFC-4253 defaults and are not > carried (documented; wave-2 material). -> - **Dial-config carries the knobs via its `Limits` field** — the +> - **Dial-config carries the knobs via its `Limits` field**: the > criterion's "keepalive/inactivity/window knobs with the > documented defaults" read as the budget model riding the config; > the accepted criterion on `Limits` itself pins that placement, > so a second knob set on `DialConfig` would have duplicated state. -> - **No `Default` for the two configs** — the required handles +> - **No `Default` for the two configs**: the required handles > (`Arc`, > `Arc`, `Arc`, > `Arc`) have no crate-defaultable shape @@ -111,12 +112,12 @@ validation. > + doc contracts**; no construction-time validation (the > `error.rs` no-config-error contract). Two request-time check > helpers exist (`shell_configured`/`exec_wrapper_configured`, -> plus `tunnel_configured`) for the wave-2 dispatch to call — +> plus `tunnel_configured`) for the wave-2 dispatch to call, > reading the `Option`, never validating. > - **`cwd`/`env` (OQ-09 posture) carried on `ServeConfig`** as > `Option` / `BTreeMap` (empty defaults; -> never wire-derived — doc contract). -> - **`channel_client` is not feature-gated** — alkcall is a base +> never wire-derived; doc contract). +> - **`channel_client` is not feature-gated**: alkcall is a base > dependency and a field presence is not a capability; the `tunnel` > feature gate applies at wave 2 on top (absent → decline even > under the feature; unfeatured → decline too). Doc contract @@ -124,7 +125,7 @@ validation. > - Verified the alkcall 0.10 surfaces in-source (registry): > `core::auth::IdentityProvider` is a sync, > `Send + Sync + 'static` trait (dyn-compatible with no -> async-trait help on alkssh's side — `ChannelClient` and the +> async-trait help on alkssh's side; `ChannelClient` and the > `NoopIdentityProvider` test fixture ride > `alkcall::channels::client` / `alkcall::core::auth`), and the > russh 0.65.0 knob names (`window_size`, `maximum_packet_size`, @@ -137,7 +138,8 @@ validation. > Config surface landed: `src/config.rs` with `Limits` (in-crate > caps: `max_concurrent_channels` 64, `max_exec_command_len` 8192; > russh knobs: `max_auth_attempts` 10, window 2 MiB, packet 32 KiB, -> buffer 100, inactivity 600 s, keepalive Some(15 s)/3 — every number +> buffer 100, inactivity 600 s, keepalive Some(15 s)/3, with every +> number > a named posture constant or documented ADR anchor), `ServeConfig` > (OQ-08 `shell_argv`/`exec_wrapper_argv` as unset-declines > `Option>`, OQ-09 `session_cwd`/`session_env`, diff --git a/tasks/implementation/w1-error-model.md b/tasks/implementation/w1-error-model.md index 9422c04..be64020 100644 --- a/tasks/implementation/w1-error-model.md +++ b/tasks/implementation/w1-error-model.md @@ -1,6 +1,6 @@ --- id: w1-error-model -name: Wave 1 — the error model (fail-closed taxonomy) +name: "Wave 1: the error model (fail-closed taxonomy)" status: completed depends_on: [w1-manifest] scope: narrow @@ -12,25 +12,25 @@ level: implementation ## Description Land the crate's error taxonomy in `src/error.rs` (one module per file, -re-exported from the crate root — the alktty pattern), `thiserror` +re-exported from the crate root, the alktty pattern), `thiserror` throughout, no catch-alls anywhere (AGENTS.md convention 2): -- **`KeySourceError`** — the ADR-005 decision-4 failure matrix as +- **`KeySourceError`**: the ADR-005 decision-4 failure matrix as distinct variants, the vault's error taxonomy reshaped by the adapter, **never collapsed**: `Locked` (vault locked → no dial, and no server: a host key that can't load prevents server construction), `Absent` (no blob at the requested path), - `Decryption` (wrong mnemonic / tampered ciphertext — the + `Decryption` (wrong mnemonic / tampered ciphertext: the refuse-at-crypto class), `UnknownKeyVersion` (the vault's refuse-before-crypto `InvalidPath` class), `Decode` (plaintext - arrived but is not decodable key material — the one-time + arrived but is not decodable key material: the one-time `decode_secret_key` failure). Doc comments carry the ADR semantics per variant, including the fail-closed consequence of each. -- Deliberately **not** error types: auth declines (dispositions — - `Auth::Reject`, ADR-004; never errors), wire decline reasons - (SSH-shaped responses — wave-2 material), config validation (the +- Deliberately **not** error types: auth declines (dispositions, the + `Auth::Reject` of ADR-004; never errors), wire decline reasons + (SSH-shaped responses, wave-2 material), config validation (the OQ-08 fields are `Option` and decline at request time, not - construction time — no config-error type ships unless the config + construction time; no config-error type ships unless the config task finds one needed, flagged for the gate). The exact variant payload shapes are the task's to pin (minimal per @@ -57,27 +57,27 @@ message is the honest content), flagged for the wave-1 gate. ## Notes > Payload shapes pinned (flagged for the wave-1 gate): `Locked` and -> `Absent` are payload-free — no honest content beyond the variant -> itself (the caller knows which source it requested); +> `Absent` are payload-free, with no honest content beyond the +> variant itself (the caller knows which source it requested); > `Decryption(String)` and `UnknownKeyVersion(String)` carry the > vault's opaque messages (`VaultServiceError::Encryption` / -> `InvalidPath` arrive as strings per POC-2 §3 — string is the honest -> content). `Decode` carries `String` rather than the typed +> `InvalidPath` arrive as strings per POC-2 §3, where a string is the +> honest content). `Decode` carries `String` rather than the typed > `ssh_key::Error` (`russh::keys::decode_secret_key`'s error type): > the ssh-key RC type is deliberately kept out of the published -> public API — when the RC settles the payload can be re-typed, but +> public API; when the RC settles the payload can be re-typed, but > freezing an RC error type now would be a worse one-way door. The > module doc comment records the deliberately-not-error classes > (auth declines = dispositions per ADR-004; wire decline reasons = -> wave-2; config validation = none unless w1-config finds a need — -> the OQ-08 fields decline at request time). No `From` +> wave-2; config validation = none unless w1-config finds a need, +> as the OQ-08 fields decline at request time). No `From` > blanket impl on purpose: alkvault's taxonomy has variants > (`Mnemonic`, `Derivation`, `AlreadyUnlocked`, `UnsupportedKeyType`) > that do not map onto the five without collapsing, and the ADR's > reshape-never-collapse rule kills the conversion; w1-key-sources -> maps explicitly at its seam. The module stays engine-blind — no +> maps explicitly at its seam. The module stays engine-blind: no > alkvault or russh types referenced at the error layer. Tests: an -> exhaustive `match` (no wildcard — the closed-variant invariant is +> exhaustive `match` (no wildcard, so the closed-variant invariant is > compile-time enforced) plus `Display` string assertions over all > five variants, `Debug` output, and `Clone` payload carrying. @@ -89,7 +89,7 @@ message is the honest content), flagged for the wave-1 gate. > fail-closed consequence (locked → no dial + no server; absent/decryption/ > unknown-version/decode → no dial; host-key path → no server > construction throughout). Re-exported from the crate root -> (`mod error; pub use error::KeySourceError;` — the alktty pattern). +> (`mod error; pub use error::KeySourceError;`, the alktty pattern). > No catch-all, no `Other/Unknown`, no blanket `From` from the vault's > taxonomy. Four unit tests (exhaustive match over the enum, > `Display`, `Debug`, `Clone`). Verified: `cargo test` (4/4 + doc diff --git a/tasks/implementation/w1-fingerprint.md b/tasks/implementation/w1-fingerprint.md index 7b55b8c..6421925 100644 --- a/tasks/implementation/w1-fingerprint.md +++ b/tasks/implementation/w1-fingerprint.md @@ -1,6 +1,6 @@ --- id: w1-fingerprint -name: Wave 1 — fingerprint computation (the contract invariant) +name: "Wave 1: fingerprint computation (the contract invariant)" status: completed depends_on: [w1-manifest] scope: narrow @@ -12,23 +12,23 @@ level: implementation ## Description Land the fingerprint computation the auth bridge (server) and the -host-key policy path (client) both consume — ADR-004 decision 1's -contract invariant in code: +host-key policy path (client) both consume; this is ADR-004 +decision 1's contract invariant in code: - Compute a key's fingerprint via the `ssh-key` re-export (`HashAlg::Sha256`): the `SHA256:` + **unpadded url-safe base64** - OpenSSH format. The string format is a **contract invariant** — + OpenSSH format. The string format is a **contract invariant**: registration strings from any standard issuer (`ssh-keygen -lf`, alkcall-side computation) must compare equal to what this crate computes; parity is proven, not assumed (POC-1 §1). - The function takes the russh/ssh-key public-key type and returns the string; it is the single fingerprint path in the crate (no - second formatting anywhere — a drift here breaks every identity - resolution silently). + second formatting anywhere, since a drift here breaks every + identity resolution silently). - Parity tests against independent-issuer vectors: static ed25519 test keys with their `ssh-keygen -lf`-recorded expected fingerprints (the POC-1 §1 checkpoint shape). The vectors are committed test - fixtures (throwaway test keys — no secret material). + fixtures (throwaway test keys; no secret material). ## Acceptance Criteria @@ -42,7 +42,7 @@ contract invariant in code: ## References -- docs/architecture/decisions/004-auth-dispositions.md (decision 1 — +- docs/architecture/decisions/004-auth-dispositions.md (decision 1: the format invariant) - docs/architecture/auth.md §The bridge (step 2) - docs/research/poc-1-auth-bridge.md §1 (the parity checkpoint) @@ -51,7 +51,7 @@ contract invariant in code: ## Notes > Design: `src/fingerprint.rs` exposes `fingerprint(&public_key) -> -> String` — the thin wrapper over the `ssh-key` re-export +> String`, the thin wrapper over the `ssh-key` re-export > (`key.fingerprint(HashAlg::Sha256).to_string()`; russh 0.65 > re-exports `ssh_key::{PublicKey, HashAlg}` via `russh::keys`), so > the *computation and formatting stay ssh-key's own code* and parity @@ -59,14 +59,14 @@ contract invariant in code: > `alkssh::fingerprint`; a grep over `src/` confirms no second > formatting path (doc mentions only). The russh 0.65 handler shape > feeds exactly this type: `VerifiedPublicKey.public_key: & -> ssh_key::PublicKey` — the auth bridge (wave 2, w1-host-key-policy) -> passes that field straight through. +> ssh_key::PublicKey`, with the auth bridge (wave 2, +> w1-host-key-policy) passing that field straight through. > > **Fact-check finding (doc correction landed):** the ADR-004 / > auth.md / task wording "unpadded **url-safe** base64" is a misnomer. > The ssh-key crate's `Fingerprint` Display emits `SHA256:` + > *unpadded standard* base64 (base64ct `Base64Unpadded` -> = the standard `+/` alphabet), and OpenSSH does the same — the +> = the standard `+/` alphabet), and OpenSSH does the same, as the > committed `ssh-keygen -lf` vectors contain `+` and `/`. The binding > invariant was never "url-safe"; it is byte-parity with standard > issuers, which POC-1 proved and the committed vectors now pin. @@ -85,7 +85,7 @@ contract invariant in code: ## Summary -> Landed `src/fingerprint.rs` — the crate's single fingerprint path +> Landed `src/fingerprint.rs`, the crate's single fingerprint path > (`alkssh::fingerprint`), delegating computation/formatting to the > ssh-key re-export behind russh 0.65's keys module with > `HashAlg::Sha256`. Parity proven, not assumed: 4 committed diff --git a/tasks/implementation/w1-host-key-policy.md b/tasks/implementation/w1-host-key-policy.md index c0085de..adc0e08 100644 --- a/tasks/implementation/w1-host-key-policy.md +++ b/tasks/implementation/w1-host-key-policy.md @@ -1,6 +1,6 @@ --- id: w1-host-key-policy -name: Wave 1 — the HostKeyPolicy seam (OQ-05, policy-blind) +name: "Wave 1: the HostKeyPolicy seam (OQ-05, policy-blind)" status: completed depends_on: [w1-manifest] scope: narrow @@ -12,27 +12,28 @@ level: implementation ## Description Land the client-side host-key policy seam (`auth.md` §Host-key policy, -OQ-05 resolution) — the trust-decision contract the dial surface calls: +OQ-05 resolution), the trust-decision contract the dial surface +calls: -- **The `HostKeyPolicy` trait** — the crate surfaces the presented host +- **The `HostKeyPolicy` trait**: the crate surfaces the presented host key (host, port, fingerprint) to an assembly-provided policy that decides accept/reject. The two OQ-05 cases are the *implementor's*: - case-1 pins (known beforehand — hard-decline on mismatch) and case-2 - policies (unknown beforehand — prompt-and-record, learn-and-record, + case-1 pins (known beforehand, hard-decline on mismatch) and case-2 + policies (unknown beforehand: prompt-and-record, learn-and-record, provider-metadata match, accept-and-continue for ephemeral fleets). The crate is **policy-blind**: it never decides trust, never hard-codes a trust model. - **The crate default is fail-closed**: reject unknown (russh's own reject-all default, kept as the built-in default policy). **No - accept-all built-in crate option** — a permissive case-2 policy is + accept-all built-in crate option**: a permissive case-2 policy is the assembly's to write. - The trait receives the fingerprint string (the wave-1 fingerprint computation is the caller's input path, wave-4 dial); the exact decision type (bool vs a richer verdict carrying a reason) is the task's to pin against the russh `check_server_key` hook shape at - this pin — flagged for the wave-1 gate. + this pin (flagged for the wave-1 gate). - The russh client-handler consumption (`check_server_key` wiring) is - wave-4 dial-surface material — this task lands the seam and its + wave-4 dial-surface material; this task lands the seam and its default only. ## Acceptance Criteria @@ -52,7 +53,7 @@ OQ-05 resolution) — the trust-decision contract the dial surface calls: - docs/architecture/auth.md §Host-key policy (OQ-05 resolution) - docs/architecture/client.md §The dial surface, §What the crate does not include (trust decisions) -- docs/architecture/decisions/005-at-rest-key-custody.md (decision 6 — +- docs/architecture/decisions/005-at-rest-key-custody.md (decision 6: the client host-*pin* is public config, never vault material) - docs/research/auth-bridge.md §Host keys and `check_server_key` @@ -62,11 +63,11 @@ OQ-05 resolution) — the trust-decision contract the dial surface calls: > `RejectAll`) + lib.rs re-exports. Design decisions pinned against > the russh 0.65 hook shape (`Handler::check_server_key -> > Result`, async via `impl Future`, default -> `Ok(false)` — verified in the russh 0.65.0 source): +> `Ok(false)`, verified in the russh 0.65.0 source): > > - **The verdict type is `bool`** (the flagged pin): the hook's bool > is the trust decision and its `Err` channel is the handler's -> wire/IO-failure territory — a trust decline has no typed reason +> wire/IO-failure territory; a trust decline has no typed reason > channel on this surface. A decline is a disposition, not an > error (`src/error.rs` contract); decline reasons are the > policy's own record (case-2 policies log their decisions), not a @@ -75,14 +76,14 @@ OQ-05 resolution) — the trust-decision contract the dial surface calls: > per auth.md). > - **The trait is async and dyn-compatible**: `#[async_trait]`, > `decide(&self, &PresentedHostKey) -> bool`, `Send + Sync + -> 'static` — the family injected-handle pattern (`Arc 'static`, the family injected-handle pattern (`Arc HostKeyPolicy>`, the alkcall `IdentityProvider`/`OperationEnv` > precedent), which w1-config's dial-config handle and > w1-test-doubles' inspectable double both require. State-holding > (learn-and-record) policies record by interior mutability > behind the shared `&self` handle. > - **New dep `async-trait = "0.1"`** (MIT OR Apache-2.0; the only -> way to get a dyn-compatible async trait at this toolchain — +> way to get a dyn-compatible async trait at this toolchain, as > native AFIT is not object-safe, and russh 0.65 removed its own > `async-trait` feature, per ADR-002's context). Precedented and > deduped: alkcall already carries `async-trait 0.1`, so the tree diff --git a/tasks/implementation/w1-key-sources.md b/tasks/implementation/w1-key-sources.md index 032b7a7..2554cf9 100644 --- a/tasks/implementation/w1-key-sources.md +++ b/tasks/implementation/w1-key-sources.md @@ -1,6 +1,6 @@ --- id: w1-key-sources -name: Wave 1 — vault-backed key sources (ADR-005 custody in code) +name: "Wave 1: vault-backed key sources (ADR-005 custody in code)" status: completed depends_on: [w1-manifest, w1-error-model] scope: moderate @@ -11,33 +11,33 @@ level: implementation ## Description -Land the ADR-005 custody model in code — the key-source seam both +Land the ADR-005 custody model in code: the key-source seam both directions consume (client dial keys, server host keys): -- **The source seam** — the adapter takes *key sources*, not static +- **The source seam**: the adapter takes *key sources*, not static keys (the alkhttp ADR-014 pattern): a source trait the assembly implements over an injected alkvault handle, `EncryptedData` opaque - to the crate (ADR-005 decision 5 — the crate never interprets + to the crate (ADR-005 decision 5: the crate never interprets `key_version` or record internals). -- **At-use lifecycle** (decision 2) — `vault.decrypt(blob)` → - **`Zeroizing` wrap at the adapter seam** (decision 3 — the +- **At-use lifecycle** (decision 2): `vault.decrypt(blob)` → + **`Zeroizing` wrap at the adapter seam** (decision 3, the known-gap contract; the vault-side improvement is upstream, not here) → one-time `decode_secret_key(plain, None)` → the decoded `PrivateKey` lives for the dial's lifetime. The vault is needed only at dial time; locking mid-session keeps live sessions streaming. -- **Host-key load path** (decision 6) — decrypt-at-construction +- **Host-key load path** (decision 6): decrypt-at-construction returning the `PrivateKey` set the server `Config::keys` will consume (the `Config` construction itself is wave 2; locked vault → the load fails → **no server is constructed**, POC-2 §5). -- **Failure mapping** — every vault failure mode maps to its distinct +- **Failure mapping**: every vault failure mode maps to its distinct `KeySourceError` variant (locked / absent / decryption / unknown version / decode); never collapsed, never a fallback dial. -- **Plaintext never persists** (decision 7) — no disk spill on this +- **Plaintext never persists** (decision 7): no disk spill on this path, ever; the `Zeroizing` wrap bounds the plaintext from the adapter seam onward. -Inner format is OpenSSH private-key PEM, unencrypted (decision 1) — -the source contract assumes it; the decode call is the single +Inner format is OpenSSH private-key PEM, unencrypted (decision 1); +the source contract assumes it, and the decode call is the single conversion. ## Acceptance Criteria @@ -78,31 +78,31 @@ conversion. > a *set* because `Config::keys` takes multiple). An assembly may > implement either trait itself; the crate ships the ADR-005 > implementations and the custody machinery. -> - **`VaultKeySource`** — decrypt-at-use over an injected clone of +> - **`VaultKeySource`**: decrypt-at-use over an injected clone of > the assembly's `VaultServiceHandle` + one injected > `Option` record (`None` → `Absent`, the POC-2 §1 > `KeySource { blob: Option }` shape). -> **`VaultHostKeySource`** — same model over a record set (empty set +> **`VaultHostKeySource`**: same model over a record set (empty set > → `Absent`). Both are decrypt-at-use chains > (`decrypt_and_decode`, shared): `vault.decrypt(blob)` → > **`Zeroizing` at the seam** → one-time > `decode_secret_key(plain, None)`. Blobs are never interpreted in -> `src/` — no `key_version` reads, no record internals. +> `src/`: no `key_version` reads, no record internals. > - **Failure mapping** (`map_vault_error`): `VaultLocked` → > `Locked`; `InvalidPath` → `UnknownKeyVersion`; `Encryption` → > `Decryption`. The vault's decrypt-path-unreachable variants > (`Derivation`/`Mnemonic`/`AlreadyUnlocked`/`UnsupportedKeyType`) > land in the `Decryption` refuse-at-crypto class with honest -> message text — the five-variant matrix stays closed, nothing +> message text; the five-variant matrix stays closed, nothing > collapses a real custody condition into success. > - **Tests** (12, all in-module with the real in-memory -> alkvault handle — `VaultServiceHandle::new()` is the in-memory +> alkvault handle: `VaultServiceHandle::new()` is the in-memory > construction, so no local vault double was needed): > round-trip (encrypt PEM → source → fingerprint-identical decodable > ed25519 key that signs), dyn-handle load, host-key set order, > load-works-after-lock (POC-2 §2 lifecycle), locked / absent / > tampered-ciphertext / wrong-mnemonic → `Decryption` (distinct -> from tampered only by message — both GCM-open) / version 0+1 → +> from tampered only by message, both GCM-open) / version 0+1 → > `UnknownKeyVersion` / non-key plaintext → `Decode`, locked > host-bootstrap refusal, empty host set, v2→v3 rotation-preserves > -identity through the source. Key fixtures: three committed @@ -112,12 +112,13 @@ conversion. > > Interpretation pinned for test-doubles: alkvault 0.1 *does* ship > the in-memory construction (`VaultServiceHandle`), so the double -> wraps the real handle — no local decrypt/decrypt double. +> wraps the real handle; no local decrypt/decrypt double. ## Summary > `src/key_source.rs` (lib re-exports `KeySource`, `HostKeySource`, -> `VaultKeySource`, `VaultHostKeySource`) — ADR-005 in code: the +> `VaultKeySource`, `VaultHostKeySource`), and ADR-005 is in code: +> the > injected-handle source seams both directions consume, the crate's > vault-backed implementations carrying decrypt-at-use (vault.decrypt > → `Zeroizing` → one-time `decode_secret_key`), opaque diff --git a/tasks/implementation/w1-manifest.md b/tasks/implementation/w1-manifest.md index f8a4bec..da8c1d1 100644 --- a/tasks/implementation/w1-manifest.md +++ b/tasks/implementation/w1-manifest.md @@ -1,6 +1,6 @@ --- id: w1-manifest -name: Wave 1 — manifest landing (russh pin + the feature graph) +name: "Wave 1: manifest landing (russh pin + the feature graph)" status: completed depends_on: [] scope: moderate @@ -11,18 +11,19 @@ level: implementation ## Description -Land the implementation dependency set and the ADR-003 feature graph — -the first commit of wave 1; everything else lands inside it. Per +Land the implementation dependency set and the ADR-003 feature graph: +the first commit of wave 1, with everything else landing inside it. +Per ADR-002 decision 6 ("manifest lands with the dependency") and `overview.md` §lifecycle note: - **`russh = "0.65"`** (caret stops at the minor), `default-features = - false, features = ["aws-lc-rs"]` — the family crypto posture + false, features = ["aws-lc-rs"]` per the family crypto posture (decision 2), **no `flate2`** (decision 3), **no `rsa`** (decision 4: - ed25519-only; the tree must drop `rsa`/`pkcs1` RC deps entirely — - `ssh-key =0.7.0-rc.11` riding the russh keys path is the recorded - exception). -- **`alkcall 0.10`** (identity/ACL types at the wire layer — + ed25519-only; the tree must drop `rsa`/`pkcs1` RC deps entirely, + with `ssh-key =0.7.0-rc.11` riding the russh keys path as the + recorded exception). +- **`alkcall 0.10`** (identity/ACL types at the wire layer: `IdentityProvider`, `Identity`, `AccessControl`), **`alkvault 0.1`** (the ADR-005 custody model). Both pinned by version, never sibling-checkout paths. @@ -31,21 +32,21 @@ ADR-002 decision 6 ("manifest lands with the dependency") and - **`edition = "2024"`, `rust-version = "1.89"`** (supersedes the current 1.88/2021 manifest). - **The ADR-003 feature graph declared from the first commit**: - `session`, `git`, `tunnel`, `sftp`, `default = []` — flat, - independent, no feature implies another. Features gate nothing yet - (the wave-2 decline core is their first consumer); the per-feature - sibling deps (`alktty` under `session`, `alktunnels` under `tunnel`, - `alkgit` under `git`) land with their first consuming wave, not as - dead manifest entries — recorded interpretation, flagged for the - wave-1 gate. + `session`, `git`, `tunnel`, `sftp`, `default = []`; the graph is + flat, independent, and no feature implies another. Features gate + nothing yet (the wave-2 decline core is their first consumer); the + per-feature sibling deps (`alktty` under `session`, `alktunnels` + under `tunnel`, `alkgit` under `git`) land with their first + consuming wave, not as dead manifest entries (recorded + interpretation, flagged for the wave-1 gate). - Update the manifest comments (the current "deliberately empty until implementation start" notes are superseded); keep comments to the non-obvious-constraint class only. Pre-flight: verify every pin exists on crates.io at the declared version (`russh 0.65`, `alkcall 0.10`, `alkvault 0.1`); if a -crate/version is not published, stop and escalate — a blocking issue, -not something to work around. +crate/version is not published, stop and escalate, since a missing +pin is a blocking issue, not something to work around. ## Acceptance Criteria @@ -66,24 +67,24 @@ not something to work around. - docs/architecture/decisions/002-russh-pin.md (decisions 1–6) - docs/architecture/decisions/003-feature-graph.md (decision 1, §2) - docs/architecture/overview.md §lifecycle note, §bounded-resources -- docs/research/russh.md (the 2026-10-26 re-verification section — +- docs/research/russh.md (the 2026-10-26 re-verification section: feature names, tree inventory, MSRV) ## Notes > Pre-flight executed: `cargo info` confirmed `russh 0.65.0`, -> `alkcall 0.10.0`, and `alkvault 0.1.0` all published on crates.io — -> no blocking issue. `alkcall` pinned as `"0.10"` (latest in-range is -> 0.10.1; the caret stops at the minor, matching the ADR-002-russh -> convention for family pins). Toolkit versions: `thiserror 2.0`, -> `tracing 0.1`, `parking_lot 0.12`, `zeroize 1.9`, +> `alkcall 0.10.0`, and `alkvault 0.1.0` all published on crates.io, +> so there is no blocking issue. `alkcall` pinned as `"0.10"` (latest +> in-range is 0.10.1; the caret stops at the minor, matching the +> ADR-002-russh convention for family pins). Toolkit versions: +> `thiserror 2.0`, `tracing 0.1`, `parking_lot 0.12`, `zeroize 1.9`, > `tokio 1.54` (as already pinned). `alktty`/`alktunnels`/`alkgit` -> sibling deps deliberately not added — they land with their first +> sibling deps deliberately not added, as they land with their first > consuming wave (recorded interpretation, flagged for the wave-1 > gate as the task states; no dead manifest entries). Manifest > comments trimmed to the non-obvious-constraint class: the -> russh/`default`-features posture (ADR-002 — must not be re-enabled -> by a future editor) and the fail-closed feature-graph note (ADR-003). +> russh/`default`-features posture (ADR-002, which must not be +> re-enabled by a future editor) and the fail-closed feature-graph note (ADR-003). ## Summary @@ -94,8 +95,8 @@ not something to work around. > `edition = "2024"`, `rust-version = "1.89"`; ADR-003 feature graph > (`session`/`git`/`tunnel`/`sftp`, `default = []`, flat and > independent). Verified: `cargo tree` contains no `rsa`, `pkcs1`, or -> `flate2` — only the recorded `ssh-key 0.7.0-rc.11` exception riding -> the russh keys path; `cargo test`, `cargo test --all-features`, +> `flate2` (only the recorded `ssh-key 0.7.0-rc.11` exception riding +> the russh keys path); `cargo test`, `cargo test --all-features`, > `cargo clippy --all-targets -- -D warnings` (default and > all-features), and `cargo fmt --check` all pass. `taskgraph > validate` clean. \ No newline at end of file diff --git a/tasks/implementation/w1-test-doubles.md b/tasks/implementation/w1-test-doubles.md index 1b86f8d..8f92d3d 100644 --- a/tasks/implementation/w1-test-doubles.md +++ b/tasks/implementation/w1-test-doubles.md @@ -1,6 +1,6 @@ --- id: w1-test-doubles -name: Wave 1 — test doubles (the in-memory seams) +name: "Wave 1: test doubles (the in-memory seams)" status: completed depends_on: [w1-error-model, w1-key-sources, w1-host-key-policy] scope: narrow @@ -15,21 +15,21 @@ Establish the test-double convention (adapted from the sibling crates) and land the in-memory seams every later wave's contract tests run against: -- **In-memory `IdentityProvider`** — registered fingerprints → +- **In-memory `IdentityProvider`**: registered fingerprints → `Identity` resolutions over alkcall's trait; the double the auth bridge and dispatch tests resolve against. -- **In-memory vault seam** — the encrypt/decrypt round-trip plus the +- **In-memory vault seam**: the encrypt/decrypt round-trip plus the failure postures (locked / absent / tampered) behind the same seam the key sources consume, so the custody tests run without a live vault service. If alkvault 0.1 ships an in-memory construction, wrap - it; otherwise a local test double behind the source trait — the - task pins which, flagged for the wave-1 gate. -- **In-memory `HostKeyPolicy`** — a configurable record/verdict double + it; otherwise a local test double behind the source trait (the + task pins which, flagged for the wave-1 gate). +- **In-memory `HostKeyPolicy`**: a configurable record/verdict double (accept/reject per host, decision log inspectable) for the dial and acceptor tests. -- **ed25519 test-key fixtures** — throwaway test keys (generated at - test time or committed static PEMs — the task pins which; committed - keys are throwaway fixtures, not secrets) with recorded +- **ed25519 test-key fixtures**: throwaway test keys (generated at + test time or committed static PEMs, with the task pinning which; + committed keys are throwaway fixtures, not secrets) with recorded `ssh-keygen -lf` fingerprints, shared with the fingerprint parity tests and every later auth/mapping test. @@ -60,12 +60,12 @@ non-test code. - docs/research/poc-2-vault-keys.md (the failure postures the vault double reproduces) - The alkgit test-double convention (tasks/implementation/core/ - w1-test-doubles.md — reading reference, never copied code) + w1-test-doubles.md; reading reference, never copied code) ## Notes - **Vault-double shape (recorded, flagged for the wave-1 gate):** alkvault - 0.1 ships an in-memory construction — `VaultServiceHandle::new()` is an + 0.1 ships an in-memory construction: `VaultServiceHandle::new()` is an in-process vault that `unlock_new` arms in-memory. The double is a **wrapper** (`TestVault` in `tests/common/mod.rs`) around that construction, not a local re-impl behind the source trait: the real @@ -74,36 +74,39 @@ non-test code. `VaultHostKeySource` seams, and the POC-2 §3 postures (locked / absent / tampered / wrong seed) ride the real seams. - **Fixture decision (recorded):** committed static OpenSSH-PEM test - keys, minted by `ssh-keygen` at vector-generation time — not generated + keys, minted by `ssh-keygen` at vector-generation time, not generated at test time (determinism, no tooling dependency). Committed private halves are throwaway fixtures by the task's own posture, documented as such in `tests/vectors/fingerprints.txt` and in the common module docs. - **Location decision (recorded):** the doubles live only under - `tests/common/mod.rs`, reached with `mod common;` — the alkgit - convention, adapted. Nothing doubles-related is reachable from + `tests/common/mod.rs`, reached with `mod common;` (the alkgit + convention, adapted). Nothing doubles-related is reachable from `lib.rs`; no feature gates them. `src/`-unit tests that need the fixture material read the committed *files* via `include_str!` - (`src/key_source.rs` tests), never a copy — the single fixture set is + (`src/key_source.rs` tests), never a copy; the single fixture set + is shared by the src-side and tests/-side suites. dead_code is allowed module-wide in `tests/common/` (each test binary compiles the whole shared module and never exercises all of it). - **The fixture set was unified and re-minted (flagged for the gate):** - the previous state had two overlapping throwaway sets — public-only - vectors under `tests/vectors/` (fingerprint parity) and inline - private-PEM constants in `src/key_source.rs` — and the inline PEM + the previous state had two overlapping throwaway sets (public-only + vectors under `tests/vectors/`, used for fingerprint parity, and + inline private-PEM constants in `src/key_source.rs`), and the inline + PEM constants, while decodable by russh, were **rejected by OpenSSH itself** (`ssh-keygen` → "error in libcrypto"): unusable as cross-issuer fixtures. All four keys (`alpha`/`bravo`/`charlie`/ `delta`) were freshly minted with OpenSSH_9.6p1's ssh-keygen, their fingerprints recorded byte-verbatim in `tests/vectors/fingerprints.txt`, - and the two sets unified into one fixture table (`FIXTURE_KEYS`) — - committed `.key` + `.pub` files, served to the fingerprint parity + and the two sets unified into one fixture table, `FIXTURE_KEYS` + (committed `.key` + `.pub` files, served to the fingerprint parity tests, the vault round-trip, and every later auth/mapping test. The old vector-only set was retired (its keys had no private halves). - The row/stamp discipline is established in `tests/common/mod.rs`'s module docs (adapted from the sibling crates): rows state properties and carry greppable `Contract stamp:` items citing the pinning - ADR § — `grep -rn "Contract stamp:" tests/` is the inventory (11 + ADR §, and `grep -rn "Contract stamp:" tests/` is the inventory + (11 stamps across the 11 landed rows). - No `src/` non-test changes: `src/key_source.rs` changed only inside its `#[cfg(test)]` module (fixture `include_str!`s + one parity row); @@ -113,12 +116,13 @@ non-test code. The wave-1 test-double convention landed: `tests/common/mod.rs` carries `MemIdentityProvider` (registered fingerprints → alkcall `Identity` -resolutions; token path resolves none — ADR-004's fingerprint-only -disposition), `TestVault` (a wrapper over alkvault 0.1's shipped -in-memory construction — the pinned resolution of the flagged +resolutions; the token path resolves none (ADR-004's +fingerprint-only disposition)), `TestVault` (a wrapper over alkvault 0.1's shipped +in-memory construction, the pinned resolution of the flagged vault-double question), `MemHostKeyPolicy` (exact `(host, fingerprint)` -rules + explicit default verdict — fail-closed by default, permissive -only by explicit choice — with a fully inspectable decision log), and +rules plus an explicit default verdict; fail-closed by default and +permissive only by explicit choice, with a fully inspectable decision +log), and the unified ed25519 fixture table (`FIXTURE_KEYS`: four committed `ssh-keygen`-minted OpenSSH-PEM key pairs with recorded `ssh-keygen -lf` fingerprints in `tests/vectors/fingerprints.txt`). The diff --git a/tasks/implementation/w2-auth-bridge.md b/tasks/implementation/w2-auth-bridge.md index 0ea0992..ebbe4d1 100644 --- a/tasks/implementation/w2-auth-bridge.md +++ b/tasks/implementation/w2-auth-bridge.md @@ -1,6 +1,6 @@ --- id: w2-auth-bridge -name: Wave 2 — the auth bridge (dispositional handler surface) +name: "Wave 2: the auth bridge (dispositional handler surface)" status: completed depends_on: [w2-server-mount, w1-fingerprint, w1-test-doubles] scope: moderate @@ -11,32 +11,32 @@ level: implementation ## Description -Fill the Handler skeleton's auth hooks with the ADR-004 dispositions — +Fill the Handler skeleton's auth hooks with the ADR-004 dispositions: the fingerprint → identity bridge, fail-closed on every path: - **`auth_publickey`** (fires post-verification; the key arrives as `VerifiedPublicKey.public_key` with the signature `.algorithm` exposed): non-ed25519 algorithm → **explicit reject** (ADR-002 - decision 4 — the explicit-over-inherited pattern, so the posture + decision 4, the explicit-over-inherited pattern, so the posture survives a pin bump); ed25519 → fingerprint via the crate's single fingerprint path (`src/fingerprint.rs`, the ADR-004 contract invariant) → `IdentityProvider::resolve_from_fingerprint` → `Some(identity)` → `Auth::Accept` with the resolved identity stored in the per-connection handler state (it rides the session for the - wave-3 seam propagation — `auth_succeeded` is the ride-along hook); + wave-3 seam propagation; `auth_succeeded` is the ride-along hook); `None` → reject fail-closed. The resolved-identity-`None` path never creates a weakly authorized session. -- **`auth_publickey_offered`** — accept-and-delegate (the russh +- **`auth_publickey_offered`**: accept-and-delegate (the russh default shape at this pin; keep it explicit so the counting stays - honest — the hook does not consume an attempt, POC-1 verified). -- **Explicit declines** — `auth_none`, `auth_password`, + honest (the hook does not consume an attempt, POC-1 verified). +- **Explicit declines**: `auth_none`, `auth_password`, `auth_keyboard_interactive`, `auth_openssh_certificate` each get an explicit `Auth::reject()` implementation (family contract, not - inherited defaults — a pin bump could silently change them). -- **The username carries no semantics** (OQ-03) — resolution is + inherited defaults, which a pin bump could silently change). +- **The username carries no semantics** (OQ-03): resolution is fingerprint-only; the user string is never an identity input. -Auth declines are *dispositions* (`Auth::Reject`), not errors — the +Auth declines are *dispositions* (`Auth::Reject`), not errors, so the error model stays key-source-only. Tests run against the wave-1 doubles (`MemIdentityProvider` + the ed25519 fixture keys) at the handler level; the loopback harness proves the bridge end to end over @@ -65,7 +65,7 @@ the wire in `w2-loopback-harness`. dispositions, decisions 1–5) - docs/architecture/auth.md §Server direction (the bridge steps; the 0.65.0 `VerifiedPublicKey` shape) -- docs/architecture/decisions/002-russh-pin.md (decision 4 — +- docs/architecture/decisions/002-russh-pin.md (decision 4; ed25519-only, the explicit-decline rationale) - docs/plans/implementation.md §Wave 2 (the auth-bridge bullet) - russh 0.65.0 in-source: `Auth`, `VerifiedPublicKey`, @@ -77,37 +77,37 @@ Implementation record (decisions made at this task's discretion): - **The bridge core is structured out of the hook on purpose.** `VerifiedPublicKey` and `PublicKeyOffer` are `#[non_exhaustive]` - with no external constructor — only russh builds them (and only + with no external constructor; only russh builds them (and only post-verification), so the *full* `auth_publickey`/`auth_publickey_offered` hooks cannot be invoked directly from a test. The bridge is therefore - `SessionHandler::resolve_bridge(&public_key, &signature_algorithm)` — - the ed25519-only gate → the single fingerprint path → - `resolve_from_fingerprint` — a plain method the contract tests drive + `SessionHandler::resolve_bridge(&public_key, &signature_algorithm)`, + spanning the ed25519-only gate → the single fingerprint path → + `resolve_from_fingerprint`; a plain method the contract tests drive with ordinary key material, and the hook is one line over it (call, store on `Some`, `Auth::reject()` on `None`). The non-constructible hooks are exercised as full hooks over the wire instead (see below). - **Two test vehicles, both recorded.** Wire rows (the real - `SshAdapter` over a tokio-duplex pair + russh's own client — the - `w2-server-mount` wire-smoke precedent) drive the full + `SshAdapter` over a tokio-duplex pair + russh's own client (the + `w2-server-mount` wire-smoke precedent)) drive the full `auth_publickey` hook end to end (accept, fail-closed reject, username independence, `auth_none`/`auth_password`/ `auth_openssh_certificate` declines over the wire). Direct-hook rows against `SessionHandler` (over the wave-1 doubles) drive every hook with constructible arguments plus `resolve_bridge`. The transport - choice and harness vocabulary remain `w2-loopback-harness`'s to pin — - these rows are its thin precursor, not a competing vehicle. + choice and harness vocabulary remain `w2-loopback-harness`'s to + pin; these rows are its thin precursor, not a competing vehicle. - **`SessionHandler` visibility widened as `w2-server-mount`'s note anticipated**: the struct is `pub` inside its still-private module, - re-exported `#[doc(hidden)]` from the crate root — constructible for - the contract tests, hidden from the docs; the documented public API + re-exported `#[doc(hidden)]` from the crate root (constructible for + the contract tests, hidden from the docs); the documented public API stays exactly `SshAdapter` + the config/error/host/key-source types. - **Non-ed25519 decline testing at this pin**: no wire row can carry a - non-ed25519 key — `rsa`/`pkcs1` are dropped from the dependency tree + non-ed25519 key: `rsa`/`pkcs1` are dropped from the dependency tree (ADR-002 decision 4's verification) and ssh-key ships without ecdsa/dsa/rsa features, so no other key type even parses in-tree. The gate is therefore tested on the bridge core with constructible stand-in signature algorithms (`SkEd25519`, - `SkEcdsaSha2NistP256`, `Other(name@domain)`) — a registered + `SkEcdsaSha2NistP256`, `Other(name@domain)`); a registered fingerprint under a non-ed25519 algorithm still declines, proving the gate precedes resolution. The gate checks **both** algorithm facts (the signature `.algorithm` from the hook and the key's @@ -116,11 +116,11 @@ Implementation record (decisions made at this task's discretion): bump. - **`auth_publickey_offered`'s accept-and-delegate is explicit but not directly hook-testable at this pin** (non-exhaustive argument; the - russh client sends no `is_real=0` probe — OpenSSH-style clients do, + russh client sends no `is_real=0` probe; OpenSSH-style clients do, and POC-1 + russh's own `publickey_probes_do_not_burn_auth_attempts` test remain the counting-honesty evidence). The wire rows exercise the signed-request path (`is_real=1`) that passes through it. -- **`auth_succeeded` stays unimplemented (the russh default)** — the +- **`auth_succeeded` stays unimplemented (the russh default)**: the identity rides in per-connection handler state (`SessionHandler::resolved_identity()`, the fail-closed `None` paths never store); wave 3's seam propagation consumes it there and @@ -129,7 +129,7 @@ Implementation record (decisions made at this task's discretion): wave-3's assertion point. - **Certificate fixture added** (`tests/vectors/`): `ca.key`/`ca.pub` (throwaway CA, committed on the same fixture rationale as every - other private half) and `alpha-cert.pub` — the alpha user + other private half) and `alpha-cert.pub`, the alpha user certificate signed by it (serial 1, key id `alkssh-fixture-alpha`, principal `git`, validity 2026–2036). Both fingerprints recorded in `fingerprints.txt`. `alpha-cert.pub` drives the @@ -139,7 +139,7 @@ Implementation record (decisions made at this task's discretion): russh's pre-checks). - **`MemHostKeySource` double added to `tests/common`** (the wave-1 doubles' module, still the only doubles location): an - `absent()` shape (load refuses `Absent` — fail-closed if anything + `absent()` shape (load refuses `Absent`, fail-closed if anything ever loads it) and a `from_fixtures()` shape for the wire rows' real adapter construction (which wants a non-empty host-key set). @@ -148,22 +148,23 @@ Implementation record (decisions made at this task's discretion): The ADR-004 dispositions are landed in `src/handler.rs`: `auth_publickey` is the fingerprint → `resolve_from_fingerprint` → accept-with-identity / reject fail-closed bridge (non-ed25519 keys -declined *explicitly* by the bridge-core gate before any resolution — -ADR-002 decision 4's explicit-over-inherited pattern), with the +declined *explicitly* by the bridge-core gate before any resolution, +per ADR-002 decision 4's explicit-over-inherited pattern), with the resolved identity stored in per-connection handler state (`SessionHandler::resolved_identity()`, read by wave 3); `auth_publickey_offered` is an explicit accept-and-delegate; `auth_none`/`auth_password`/`auth_keyboard_interactive`/ `auth_openssh_certificate` are four explicit family-contract declines; the username is structurally excluded from resolution (no username -parameter exists on the bridge core — OQ-03). Every decline is a +parameter exists on the bridge core; OQ-03). Every decline is a `Auth::Reject` disposition, never an error. -Testing: `tests/auth_bridge.rs` — 7 wire rows over the real adapter +Testing: `tests/auth_bridge.rs`, with 7 wire rows over the real +adapter mount (registered-accept, unknown-reject with the `{publickey}` advertisement surviving, four-username independence, revoke-between- attempts, `auth_none`, `auth_password`, and the OpenSSH-certificate -decline — all via russh's own client over a duplex pair against the +decline, all via russh's own client over a duplex pair against the committed cert fixture) + 7 direct rows against `SessionHandler` and `resolve_bridge` over the wave-1 doubles (all four non-publickey hooks reject explicitly with no identity state left behind; registered / @@ -182,5 +183,5 @@ precursor of the `w2-loopback-harness` vehicle (which pins the transport choice); flagged for the wave-2 gate: the resolved-identity *landing* is code-verifiable only at wave 3 (the hook's russh-owned argument shape is non-exhaustive and the handler -lives inside `run_stream`) — the store rides the same resolved value -the wire rows exercise. \ No newline at end of file +lives inside `run_stream`), so the store rides the same resolved +value the wire rows exercise. \ No newline at end of file diff --git a/tasks/implementation/w2-bounded-resources.md b/tasks/implementation/w2-bounded-resources.md index c825510..71b727d 100644 --- a/tasks/implementation/w2-bounded-resources.md +++ b/tasks/implementation/w2-bounded-resources.md @@ -1,6 +1,6 @@ --- id: w2-bounded-resources -name: Wave 2 — bounded resources (the in-crate channel cap at the handler surface) +name: "Wave 2: bounded resources (the in-crate channel cap at the handler surface)" status: completed depends_on: [w2-spawn-return] scope: narrow @@ -12,11 +12,11 @@ level: implementation ## Description Enforce the in-crate bounded-resources posture at the handler surface -(`overview.md` §Bounded-resources policy; AGENTS.md convention 9 — -SSH servers are internet-facing; unbounded loops and unbounded buffers -are bugs): +(`overview.md` §Bounded-resources policy; AGENTS.md convention 9: +SSH servers are internet-facing, and unbounded loops and unbounded +buffers are bugs): -- **The in-crate concurrent-channel cap** — a count of active channels +- **The in-crate concurrent-channel cap**, a count of active channels enforced at every `channel_open_*` hook: opens beyond `Limits::max_concurrent_channels` are declined fail-closed (a clean refuse, never queued, never a half-open channel). The count @@ -25,16 +25,16 @@ are bugs): - **The russh knobs are already wired** (`w2-server-mount` mapped `inactivity_timeout` always-`Some`, `max_auth_attempts`, `window_size`, `maximum_packet_size`, `channel_buffer_size` from - `Limits`) — this task verifies them behaviorally where cheap + `Limits`); this task verifies them behaviorally where cheap (e.g., the auth-attempt ceiling disconnects an exceeding client through the harness) and records anything not cheaply observable as covered by the config-mapping tests. - **Per-length exec-parse limits stay reserved** for the wave-3 grammar's consumption (`Limits::max_exec_command_len` is carried, - not yet enforced — the grammar owns it). + not yet enforced; the grammar owns it). The per-identity policy layer (when the alkcall bridge carries it) is -additional and arrives with the bridge itself (wave 3's tunnel leg) — +additional and arrives with the bridge itself (wave 3's tunnel leg), not from this task. ## Acceptance Criteria @@ -56,7 +56,7 @@ not from this task. table) - docs/architecture/mapping.md §Data-plane / limits - docs/plans/implementation.md §Wave 2 (the bounded-resources bullet) -- src/config.rs (`Limits` — the budget model and its documented +- src/config.rs (`Limits`, the budget model and its documented postures) ## Notes @@ -65,16 +65,18 @@ not from this task. `src/channel_cap.rs` (`ChannelCap`): the per-session active-channel count with `admit` (check+increment at accept sites) / `has_room` (pure check) / `release` (saturating decrement). The only writer is - the connection's russh packet loop — every hook runs inline with + the connection's russh packet loop, where every hook runs inline + with exclusive `&mut self` (ADR-006 decision 4 puts serving paths on spawned tasks, but their closes cycle back through the packet loop - as `CHANNEL_CLOSE`, firing the hook) — so the `parking_lot` + as `CHANNEL_CLOSE`, firing the hook), so the `parking_lot` convention's short-held *shared* state does not apply: no lock exists because no shared mutation does. Saturating `release` means a stray decrement cannot manufacture capacity. - **Gate ordering** (handler.rs): feature gate first, then the cap. Session-channel opens admit-and-count exactly at the accept point. - The tunnel rows check the cap *before* the seam stub — the stub + The tunnel rows check the cap *before* the seam stub, because the + stub declines without opening a channel, so a counted admission it never opens would leak against the cap forever (decline-first-alloc-never applies to the budget, not just capability). Wave 3's @@ -84,25 +86,26 @@ not from this task. seam's `ConnectFailed` (pinned by a row, not by convention). x11 declines in every build and never consumes a count. - **Decline reason**: `dispatch::cap_exceeded_open_decline()` → - `AdministrativelyProhibited` — the cap is the assembly's configured + `AdministrativelyProhibited`: the cap is the assembly's configured policy ceiling; OpenSSH's own `MaxSessions` answers the same code, so vanilla clients see the vanilla-shaped policy refusal (POC-1 §5). - **Decrement invariant**: every fired `channel_close` corresponds to - exactly one previously-admitted open — russh only routes + exactly one previously-admitted open, since russh only routes `CHANNEL_CLOSE` for channels it considers established (a declined open never established one) and consumes the close at most once (the channel is removed from russh's map before the hook). - **Recovery observability (russh-pin fact, recorded in the test doc)**: a *client-initiated* channel close is not observable - client-side at this pin — russh removes a locally-closed channel + client-side at this pin, because russh removes a locally-closed + channel from its client map before the server's `CHANNEL_CLOSE` arrives back, so `Channel::wait()` never delivers the close (the harness's `await_close` observes only *server-initiated* closes, which is what the spawn-return/dispatch rows use). The recovery row therefore rests on wire ordering (deterministic): close and open ride the client loop's single FIFO event queue, are written in that order, - and the server's packet loop processes them in order — the counted - release precedes the recovery admission; had the release not + and the server's packet loop processes them in order, so the + counted release precedes the recovery admission; had the release not happened, the recovery open declines and the row fails. - **`max_auth_attempts` verified behaviorally**: ceiling `2` (tunable) → two within-ceiling failures stay ordinary @@ -110,7 +113,7 @@ not from this task. attempt past the ceiling is answered with the server's `Disconnect::NoMoreAuthMethodsAvailable`, whose client-side shape is russh's synthesized `Failure` with an *empty* remaining-methods set - (no reply came; the advertisement is gone) — the session ends + (no reply came; the advertisement is gone), and the session ends cleanly server-side (row: `tests/bounded_resources.rs` `auth_attempt_ceiling_disconnects_an_exceeding_client`). - **The size/window/clock knobs recorded as config-mapping-covered**: @@ -124,21 +127,21 @@ not from this task. the wire buys no new information (russh reads the `Config` fields it owns); not cheaply behavioral, so recorded, per the task's split. - **`max_exec_command_len` stays carried, not enforced** (wave 3's - grammar owns it) — unchanged from `src/config.rs`/`src/dispatch.rs` + grammar owns it), unchanged from `src/config.rs`/`src/dispatch.rs` documentation; nothing here consumes it. - **Verification**: `cargo test` (default), and `--features session` / `git` / `tunnel` / `--all-features` (the feature-gated cap rows run in the builds whose opens can be accepted), `cargo clippy --all-targets -- -D warnings` (+ `--all-features`), - `cargo fmt --check`, `cargo doc --no-deps` — all green. + `cargo fmt --check`, `cargo doc --no-deps`: all green. ## Summary Landed the in-crate concurrent-channel cap at the handler surface: new private `channel_cap` module (count + admit/release semantics, -unit-tested), wired into `SessionHandler`'s every open hook — the +unit-tested), wired into `SessionHandler`'s every open hook: the session accept path admits-and-counts at the accept, the tunnel rows -cap-gate before the seam stub, x11 is unaffected — and decremented +cap-gate before the seam stub, x11 is unaffected. Counts decrement through the new `channel_close` hook (single release point, single writer). Cap-exceeded opens decline fail-closed with `AdministrativelyProhibited` (`dispatch::cap_exceeded_open_decline`, diff --git a/tasks/implementation/w2-dispatch-decline-core.md b/tasks/implementation/w2-dispatch-decline-core.md index df7b4df..d18f670 100644 --- a/tasks/implementation/w2-dispatch-decline-core.md +++ b/tasks/implementation/w2-dispatch-decline-core.md @@ -1,6 +1,6 @@ --- id: w2-dispatch-decline-core -name: Wave 2 — parse-only dispatch + the fail-closed decline core +name: "Wave 2: parse-only dispatch + the fail-closed decline core" status: completed depends_on: [w2-loopback-harness] scope: moderate @@ -12,36 +12,40 @@ level: implementation ## Description Land the dispatch skeleton and the fail-closed decline core (ADR-006 -as amended, ADR-003) — the routing decisions and the declines; the +as amended, ADR-003), which is to say the routing decisions and the +declines; the family-seam serving paths are wave-3 material and arrive as clean declining stubs this task shapes the routes for. - **Channel-open gates** (the `channel_open_*` hooks): `session` channels open only where a session-serving feature exists - (`session` ∨ `git` — the parse path lives inside session channels); + (`session` ∨ `git`, since the parse path lives inside session + channels); `direct-tcpip` / `forwarded-tcpip` / `direct-streamlocal` gate on the `tunnel` feature; **`x11` and `auth-agent` always decline** - (never feature-gated — ADR-003 §5); every other/unimplemented + (never feature-gated, per ADR-003 §5); every other/unimplemented channel type declines. Unfeatured → decline at open with a clean - wire reason — never a half-open channel. + wire reason; never a half-open channel. - **Parse-only session dispatch** (ADR-006 point 3 as amended): - `exec_request` parses first — a git-parseable command routes to the + `exec_request` parses first: a git-parseable command routes to the git row, anything else to the session pipe row; `pty_request` / `shell_request` route to the session rows. **The git-parseability probe here is skeleton-level** (service prefix + path shape); the full grammar (quoted/unquoted paths, service-only → decline-with-reason, per-length limits) is wave-3's git task, which replaces this probe. Parsing happens before dispatch and before any - resource allocation — **decline first, alloc never** (a leaked + resource allocation: **decline first, alloc never** (a leaked channel alloc is a capability leak; the POC-3 stage-A rule). -- **Request-level declines**: `subsystem_request` — the `sftp` name +- **Request-level declines**: `subsystem_request`, where the `sftp` + name declines-with-reason (the OQ-13 placeholder, folded here server side); git service names decline-with-reason (a subsystem request carries only a name, never a repo id); everything else declines. - `env_request` declines unconditionally (OQ-09 — no allowlist ships). + `env_request` declines unconditionally (OQ-09; no allowlist ships). - **Routing stubs**: the git row and the session rows (pty/shell, pipe-exec) route to stubs that decline cleanly at the family-seam - boundary — the seams land in wave 3; the stub shape is documented + boundary, since the seams land in wave 3; the stub shape is + documented as skeleton behavior so the routing decisions are contract-tested now, not after the mappings exist. - **Wire decline reasons are shaped here** (the wave-1 error-model @@ -51,7 +55,7 @@ declining stubs this task shapes the routes for. hangs). - **Contract tests through the loopback harness, per feature build**: the skeleton rows of the decline matrix (`mapping.md` §Decline - matrix) — none-build declines everything; git-build routes + matrix), where none-build declines everything; git-build routes git-parseable exec (stub at the seam) and declines pty/shell and plain exec; session-build declines git-parseable exec (the git parse is its gate) and serves its rows only to the stub; tunnel @@ -82,7 +86,7 @@ declining stubs this task shapes the routes for. ## References - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - (points 2–3; the amendment — parse-only) + (points 2–3; the amendment is parse-only) - docs/architecture/decisions/003-feature-graph.md (the feature graph; §5 never-features) - docs/architecture/mapping.md §Session-channel dispatch, §Request-level @@ -93,10 +97,10 @@ declining stubs this task shapes the routes for. ## Notes -- `src/dispatch.rs` (new): the parse-only dispatch core — the +- `src/dispatch.rs` (new): the parse-only dispatch core with the `ExecRoute` decision (`Git` / `Pipe`), the skeleton exec-parse probe (`classify_exec`: exact `git-upload-pack`/`git-receive-pack` - service prefix + space + path-shaped argument — leading `'` or + service prefix + space + path-shaped argument: leading `'` or `/`; everything else → the pipe row), and the two wire-decline reason shapers (`unfeatured_open_decline` → `AdministrativelyProhibited`, `tunnel_seam_stub_decline` → @@ -111,24 +115,24 @@ declining stubs this task shapes the routes for. parses first then routes on the feature graph; `pty`/`shell` route under `session`; `subsystem_request` declines every name (sftp = the OQ-13 placeholder, `git-*` = no repository id in a name-only - request — ADR-006 decision 2); `env_request` declines + request per ADR-006 decision 2); `env_request` declines unconditionally (OQ-09); `x11_request`/`agent_request` decline (ADR-003 §5); the four listen requests decline (the flow is wave 3; never advertise a listen that cannot serve). Decline-first- alloc-never holds structurally: no hook allocates before its decline; a confirmed session open accepts and drops the channel - handle (nothing is held this wave — the store-and-take handoff is + handle (nothing is held this wave; the store-and-take handoff is wave-3 mapping material, and the routing decisions around it are already pinned by the seam stubs). - **Wire decline reasons (the wave-1 error-model reservation, now - spent):** open-level declines carry channel-open failure codes — + spent):** open-level declines carry channel-open failure codes; unfeatured and never-feature rows answer `AdministrativelyProhibited` (1) is the honest class: without the feature there is no capability to distinguish; the tunnel seam stub answers `ConnectFailed` (2), the explicit reject shape ADR-006 decision 1 pins for failed fabric opens (accept only after the fabric open succeeds; the skeleton never opens the fabric). Request-level declines carry - `channel_failure` — the SSH request vocabulary has no failure text, + `channel_failure`: the SSH request vocabulary has no failure text, so the "reason" is the routing decision, documented per build. Within request declines, two shapes: a *capability* decline answers failure and leaves the channel usable (POC-1 §5 posture, proven @@ -139,11 +143,11 @@ declining stubs this task shapes the routes for. global requests auto-reply from the handler's `bool`; every other request hook must call `session.channel_failure` itself. The bare `auth-agent@openssh.com` (and `forwarded-streamlocal@openssh.com`) - channel *types* have no handler hook — russh rejects those opens + channel *types* have no handler hook, so russh rejects those opens inline (administratively prohibited); that is the decline the never-feature contract needs but it is an inherited posture flagged in the handler docs (pin-bump change would appear as - accepted-then-dropped — upstream note). `forwarded-tcpip` has the + accepted-then-dropped, an upstream note). `forwarded-tcpip` has the same gate/stub code shape as its tcp sibling but no client-side open helper (the server opens it toward the client), so its wire rows are wave-3 acceptor material. @@ -170,23 +174,23 @@ declining stubs this task shapes the routes for. Landed the parse-only dispatch + the fail-closed decline core (`w2-dispatch-decline-core`) in three files plus the harness: -- **`src/dispatch.rs`** — the `ExecRoute` decision, the skeleton +- **`src/dispatch.rs`**: the `ExecRoute` decision, the skeleton exec-parse probe (service prefix + path shape; wave-3 grammar replaces), the wire-reason shapers (`AdministrativelyProhibited` for unfeatured/never-feature opens, `ConnectFailed` for the tunnel seam stub), 7 probe unit rows. -- **`src/handler.rs`** — the `channel_open_*` gates (session ∨ git; +- **`src/handler.rs`**: the `channel_open_*` gates (session ∨ git; tunnel; x11/auth-agent never), the parse-only `exec_request` dispatch, the session-row routes, the request-level declines (subsystem all-names with the sftp/git named classes, env, x11, agent), the four listen-request declines, and the named seam stubs (`git_row_stub`, `pipe_row_stub`, `pty_row_stub`, - `shell_row_stub`, `tunnel_row_stub`) — the wave-3 replacement - points, each documented with its family seam. Module docs now + `shell_row_stub`, `tunnel_row_stub`), which are the wave-3 + replacement points, each documented with its family seam. Module docs now carry the decline-core contract (gates, decline classes, wire reasons, decline-first-alloc-never, the parse-only posture, the inherited-auth-agent-open upstream note). -- **`tests/dispatch_core.rs`** — the skeleton decline-matrix rows per +- **`tests/dispatch_core.rs`**: the skeleton decline-matrix rows per feature build, 26 rows total, each with a `Contract stamp:`: shared rows in every build (x11 open, `tcpip-forward` decline, x11/agent requests where sessions open); none-build @@ -197,9 +201,9 @@ Landed the parse-only dispatch + the fail-closed decline core declines as gate, stub rows failure+close, capability declines, tunnel unfeatured); tunnel-only (session opens decline, featured tunnel rows → `ConnectFailed` stubs); all-features (routes hold - with the full graph — git row, pipe row, pty/shell stubs, + with the full graph: git row, pipe row, pty/shell stubs, `ConnectFailed` stubs, capability declines). -- **`tests/common/harness.rs`** — the declined-vocabulary helpers +- **`tests/common/harness.rs`**: the declined-vocabulary helpers (tunnel/x11 opens, pty/env/x11/agent requests, listen requests), module doc updated to record the discharge of the `w2-loopback-harness` note. diff --git a/tasks/implementation/w2-loopback-harness.md b/tasks/implementation/w2-loopback-harness.md index 87c5dd2..8c9cde4 100644 --- a/tasks/implementation/w2-loopback-harness.md +++ b/tasks/implementation/w2-loopback-harness.md @@ -1,6 +1,6 @@ --- id: w2-loopback-harness -name: Wave 2 — the loopback harness (in-process client/server test pair) +name: "Wave 2: the loopback harness (in-process client/server test pair)" status: completed depends_on: [w2-auth-bridge, w1-test-doubles] scope: moderate @@ -12,31 +12,31 @@ level: implementation ## Description Land the in-process client/server test pair every later contract test -runs through — the decline matrix, every mapping's contract tests +runs through: the decline matrix, every mapping's contract tests (wave 3), and the client-direction smokes all drive the crate through this harness. In-memory doubles from wave 1; real-tool smokes ride the mapping waves (wave 3+) and the e2e wave (wave 5). -- **Server side** — the `SshAdapter` from `w2-server-mount` over an +- **Server side**: the `SshAdapter` from `w2-server-mount` over an in-process transport. The transport choice (tokio duplex pair vs - 127.0.0.1:0 TCP loopback) is pinned here — both work with + 127.0.0.1:0 TCP loopback) is pinned here; both work with `run_stream`/russh's client connect; decide on determinism and portability and record the choice. -- **Client side** — a russh client driven directly (the wave-4 dial +- **Client side**: a russh client driven directly (the wave-4 dial surface does not exist yet; the harness builds the russh client config from the doubles): in-memory key from the ed25519 fixtures (`FIXTURE_KEYS`), the `MemHostKeyPolicy` double with the permissive - test posture (`with_default(true)` — the accept-all posture exists + test posture (`with_default(true)`; the accept-all posture exists only as a test choice, never a crate built-in), the keepalive posture from `Limits`. -- **Harness helpers** — the vocabulary the contract tests speak: +- **Harness helpers** (the vocabulary the contract tests speak): authenticate with a fixture key (and observe auth failure shapes), open a channel type (and observe the open-failure reason), send a session request (and observe success/failure), await exit-status / - close. Helpers stay thin — the russh client API is the vocabulary; + close. Helpers stay thin: the russh client API is the vocabulary; the harness removes boilerplate, not semantics. -- **Convention** — lives with the wave-1 doubles under `tests/common/` - (or a sibling harness module — same convention, decide and record); +- **Convention**: lives with the wave-1 doubles under `tests/common/` + (or a sibling harness module, same convention, decide and record); documented as *the* contract-test vehicle for the crate. The harness's first consumer is its own acceptance test: a full @@ -74,23 +74,23 @@ the wave-1 doubles work live through the wave-2 stack. docs):** tokio duplex pair (64 KiB/direction) + russh's `connect_stream`. russh's real codec/kex/transport run over the pair verbatim; the one gap versus real sockets (no OS-socket semantics) is - covered more cheaply by the real-tool smokes of wave 3+ — TCP + covered more cheaply by the real-tool smokes of wave 3+, since TCP loopback's listener lifecycle and timing flakiness buy nothing the harness needs. Deterministic, no port, no collision. - **Placement decision (recorded, same docs):** `tests/common/harness.rs`, a sibling module of the wave-1 doubles reached with the same - `mod common;` convention — test surface, never a public API export. + `mod common;` convention; test surface, never a public API export. Unlike the doubles it is a *vehicle* convention: later waves call `common::harness::drive` rather than re-deriving a mount/connect pair. - **russh 0.65 behavior surfaced by the harness (a dispatch-core input):** each un-implemented server `channel_open_*` hook drops its `ChannelOpenHandle` → the client observes - `ChannelOpenFailure::AdministrativelyProhibited` — the inherited + `ChannelOpenFailure::AdministrativelyProhibited`, the inherited fail-closed decline on the current skeleton stack. The harness's authenticated-session row matches the reason generically (`Error::ChannelOpenFailure(_)`), so `w2-dispatch-decline-core`'s reason rows cannot break the vehicle. -- **Scope posture:** the harness is a test-vehicle task — no `src/` +- **Scope posture:** the harness is a test-vehicle task, so no `src/` change. A russh-server behavior note rides the row docs only. ## Summary @@ -105,22 +105,22 @@ no-port portability) and **sibling-of-the-doubles placement** under Contents: -- **`HarnessClientHandler`** — the russh client handler bridging the +- **`HarnessClientHandler`**: the russh client handler bridging the `MemHostKeyPolicy` double into `check_server_key`, computing the decision input ([`PresentedHostKey`]) exactly as the wave-4 dial wiring will: endpoints labeled `LOOPBACK_HOST` / `LOOPBACK_PORT` (the no-port marker `0`), the fingerprint on the crate's single fingerprint path. -- **`client_config`** — the keepalive/wall-clock posture from `Limits` +- **`client_config`**: the keepalive/wall-clock posture from `Limits` (interval/max verbatim, inactivity always set). -- **Server-side assemblers** — `serve_config` / `adapter` from the +- **Server-side assemblers**: `serve_config` / `adapter` from the wave-1 doubles. -- **`drive` → `LoopbackSession`** — the mount-spawn + connect pair, +- **`drive` → `LoopbackSession`**: the mount-spawn + connect pair, with clean-disconnect `unmount`. -- **Auth helpers** — `authenticate_publickey` (fixture plumbing +- **Auth helpers**: `authenticate_publickey` (fixture plumbing removed, `AuthResult` verbatim) and `remaining_methods` (the advertisement half of the failure shape). -- **Channel helpers** — `open_session_channel` (open-failure reason +- **Channel helpers**: `open_session_channel` (open-failure reason observed in the `Err`), `request_exec` / `request_shell` / `request_subsystem`, and the message observations: `next_message`, `await_exit_status`, `await_close`. @@ -137,10 +137,10 @@ and the `Limits` → client-config posture row. Live-exercised doubles: `MemIdentityProvider`, `MemHostKeySource`, `MemHostKeyPolicy` (both verdicts). `TestVault` stays on the custody rows (fixture keys are unencrypted by contract). The session-request -and exit-status/close helpers are exercised by construction this wave -— channel opens don't confirm on the decline-only skeleton; their live -rows arrive with `w2-dispatch-decline-core`. +and exit-status/close helpers are exercised by construction this +wave, because channel opens don't confirm on the decline-only +skeleton; their live rows arrive with `w2-dispatch-decline-core`. Verification: `cargo test` (81 passed across 6 binaries), -`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` — all +`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`; all green. \ No newline at end of file diff --git a/tasks/implementation/w2-server-mount.md b/tasks/implementation/w2-server-mount.md index c3d1e93..b5e38a7 100644 --- a/tasks/implementation/w2-server-mount.md +++ b/tasks/implementation/w2-server-mount.md @@ -1,6 +1,6 @@ --- id: w2-server-mount -name: Wave 2 — SshAdapter::handle mount + russh Config construction +name: "Wave 2: SshAdapter::handle mount + russh Config construction" status: completed depends_on: [review-wave-1, w1-config, w1-key-sources, w1-error-model] scope: moderate @@ -11,49 +11,51 @@ level: implementation ## Description -Land the door frame: the `SshAdapter` type — the ADR-001 mount point — +Land the door frame: the `SshAdapter` type, the ADR-001 mount point, and the russh server `Config` construction from the wave-1 `ServeConfig`. This is the substrate every mapping composes into; the auth bridge and dispatch fill the Handler this task skeletons. -- **`SshAdapter`** — holds the `ServeConfig`; construction +- **`SshAdapter`**: holds the `ServeConfig`; construction (`SshAdapter::new`) loads the host keys decrypt-at-construction via the injected `HostKeySource` (ADR-005 decision 6): a locked / absent / decryption-failed / unknown-version load means **no server - is constructed** — the error surfaces as a distinct adapter + is constructed**, and the error surfaces as a distinct adapter construction error wrapping the `KeySourceError` variant (never a half-configured adapter, never a keyless server that fails later). -- **russh `Config` construction** (ADR-002 surface notes) — built from +- **russh `Config` construction** (ADR-002 surface notes), built from the serve-config: `methods` = `{publickey}` **exactly** - (`MethodSet` from `[MethodKind::Publickey]` — honest advertisement, + (`MethodSet` from `[MethodKind::Publickey]`: honest advertisement, ADR-004 decision 4); `keys` from the loaded host-key set; `window_size` / `maximum_packet_size` / `channel_buffer_size` / `max_auth_attempts` mapped verbatim from `Limits`; `inactivity_timeout` mapped **always-`Some`** (the bounded-resources - posture — the crate never constructs a session with no wall clock); + posture; the crate never constructs a session with no wall clock); russh's rekey `Limits` stay russh-enforced at their RFC-4253 - defaults (recorded in the wave-1 config notes — keep that + defaults (recorded in the wave-1 config notes; keep that recording). `server_id` stays russh's default posture unless the task finds a family reason otherwise (decide and record). -- **The mount** — `SshAdapter::handle(stream)`: a generic stream in, +- **The mount**: `SshAdapter::handle(stream)` with a generic stream + in, the full russh server stack per stream via `russh::server::run_stream` - (edge termination only — no ALPN code, no relay leg; the `alk/ssh` - pin is a ledger entry, ADR-001 — nothing here speaks it). The exact + (edge termination only, with no ALPN code and no relay leg; the + `alk/ssh` pin is a ledger entry, ADR-001, and nothing here speaks + it). The exact return shape (awaited `RunningSession`, an internal spawn + join handle, …) is decided here against the spawn-and-return posture - (ADR-006 decision 4) and the assembly's mounting needs — decide and + (ADR-006 decision 4) and the assembly's mounting needs: decide and record; it is semver-visible once consumers exist. -- **The Handler skeleton** — the per-connection `russh::server::Handler` - impl with the adapter's `type Error` (`From`, thiserror - — the wave-2-shaped error surface the wave-1 error-model note - reserved); every hook left at its fail-closed russh default +- **The Handler skeleton**: the per-connection `russh::server::Handler` + impl with the adapter's `type Error` (`From` via + thiserror, the wave-2-shaped error surface the wave-1 error-model + note reserved); every hook left at its fail-closed russh default (decline/reject). Later wave-2 tasks fill the auth hooks (`w2-auth-bridge`) and the channel/request dispatch (`w2-dispatch-decline-core`). No mapping seams, no feature-gated serving (wave 3); no client dial -(wave 4). The feature gates stay untouched — nothing consumes them -yet (the dispatch task is their first `cfg` consumer). +(wave 4). The feature gates stay untouched, since nothing consumes +them yet (the dispatch task is their first `cfg` consumer). ## Acceptance Criteria @@ -80,7 +82,7 @@ yet (the dispatch task is their first `cfg` consumer). contract: `SshAdapter::handle`, raw vanilla-SSH stream in) - docs/architecture/decisions/002-russh-pin.md (the Config surface notes; honest advertisement) -- docs/architecture/decisions/004-auth-dispositions.md (decision 4 — +- docs/architecture/decisions/004-auth-dispositions.md (decision 4: `methods = {publickey}`) - docs/architecture/decisions/005-at-rest-key-custody.md (decrypt-at-construction; locked → no server) @@ -94,10 +96,10 @@ yet (the dispatch task is their first `cfg` consumer). Implementation record (decisions made at this task's discretion, the "decide and record" items): -- **The mount's return shape — `handle` runs to completion** +- **The mount's return shape (`handle` runs to completion)** (`Result<(), AdapterError>`): `handle` is an async per-stream call that awaits `run_stream`'s `RunningSession` through the session's - end — the alkhttp door precedent (`HttpAdapter`/`ProtocolHandler:: + end, matching the alkhttp door precedent (`HttpAdapter`/`ProtocolHandler:: handle` is spawned per connection by the assembly's accept loop; alkhttp/src/server/adapter.rs spawns it in tests the same way). ADR-006 decision 4 is satisfied inside russh itself: `run_stream` @@ -111,7 +113,7 @@ Implementation record (decisions made at this task's discretion, the parameterizes); the second hides the failure boundary behind a detached task while still not matching the accept-loop shape any better. The chosen shape's signature is `Result<(), AdapterError>` - — the smallest honest surface; it is semver-visible and recorded + `, the smallest honest surface; it is semver-visible and recorded here. - **`server_id` stays russh's default** (`SSH-2.0-russh_0.65.0`, the `Config::default()` posture). No family reason found to change it: @@ -124,18 +126,19 @@ Implementation record (decisions made at this task's discretion, the advertisement; ADR-002 decision 4's "an assembly registers ed25519 fingerprints only" governs what the assembly sources. Flagged for the wave-2 gate: if host-key algorithm filtering ever becomes - wire-contract territory (currently it is not — the key source + wire-contract territory (currently it is not, as the key source contract is silent), it would be an ADR amendment, not this task. - **Empty key set = `KeySourceError::Absent` even from a custom source**: `SshAdapter::new` additionally refuses an `Ok(vec![])` - load as `Construction(Absent)` — `Config::keys` empty would serve + load as `Construction(Absent)`, since an empty `Config::keys` would + serve no host key honestly, and the `key_source.rs` wave-1 contract already documents the empty set as the absent shape. - **Keepalive stays russh-default on the server direction** (`None` / 3): the `Limits` keepalive knobs are documented client-direction posture (reverse-runner evidence); no server-side keepalive posture exists and none is invented here. -- **`SessionHandler` is `pub(crate)`** — the skeleton's type never +- **`SessionHandler` is `pub(crate)`**: the skeleton's type never appears in a public signature (thanks to the run-to-completion return shape), so the public API this task exposes is exactly `SshAdapter` + `AdapterError`; `w2-auth-bridge` can widen visibility @@ -166,7 +169,8 @@ stands entirely at russh's fail-closed defaults, with `AdapterError` (`Construction(#[from] KeySourceError)` + `Wire(#[from] russh::Error)`) as its `type Error`. -Verification: 12 new tests — advertisement-exactly-publickey, knob +Verification: 12 new tests covering advertisement-exactly-publickey, +knob mapping (tuned + documented-default postures), decrypt-at-construction key-set parity, construction failure matrix (locked / absent / empty / tampered / wrong-vault / unknown-version / decode, variant preserved), diff --git a/tasks/implementation/w2-spawn-return.md b/tasks/implementation/w2-spawn-return.md index ef1152a..94fe9e0 100644 --- a/tasks/implementation/w2-spawn-return.md +++ b/tasks/implementation/w2-spawn-return.md @@ -1,6 +1,6 @@ --- id: w2-spawn-return -name: Wave 2 — spawn-and-return plumbing + exit/status reporting mechanics +name: "Wave 2: spawn-and-return plumbing + exit/status reporting mechanics" status: completed depends_on: [w2-dispatch-decline-core] scope: narrow @@ -13,22 +13,23 @@ level: implementation Land the structural constraint in code (ADR-006 decision 4): russh at this pin runs server handler callbacks **inline inside the session's -packet loop** — a blocking handler suspends all packet routing for the +packet loop**, so a blocking handler suspends all packet routing connection and deadlocks against the peer's immediate follow-up bytes (POC-3 proved by construction with receive-pack). Therefore: - **Every serving path is `tokio::spawn`ed; handlers return fast.** The dispatch's routing stubs become spawned serving paths (still - declining stubs — the seams are wave 3), so the pattern is in place + declining stubs, since the seams are wave 3), so the pattern is in + place before any real mapping hangs on it. -- **Exit/status reporting mechanics** — reporting from spawned tasks +- **Exit/status reporting mechanics**: reporting from spawned tasks rides the cloned `Session::handle()`: `exit-status` sent **before** channel close, from the spawned task. The *derivation contract's rules* (which code → which SSH exit shape) are wave-3 session-mapping material (`mapping.md` rules 1–6); this task lands - the mechanics — the handle clone, the exit-status send, the - ordering-before-close — proven end to end. -- **The proof** — a stub serving path that spawns, completes + the mechanics (the handle clone, the exit-status send, the + ordering-before-close), proven end to end. +- **The proof**: a stub serving path that spawns, completes asynchronously (after the handler has returned), and reports `exit-status(N)` + close through the cloned handle; the loopback harness client observes the exit-status arriving after the handler @@ -37,7 +38,7 @@ connection and deadlocks against the peer's immediate follow-up bytes constraint as a test, not a convention. This is a load-bearing correctness constraint of the whole spine, not -an implementation choice — the wave-3 mappings inherit the pattern +an implementation choice; the wave-3 mappings inherit the pattern mechanically from the stubs this task leaves behind. ## Acceptance Criteria @@ -57,7 +58,7 @@ mechanically from the stubs this task leaves behind. ## References - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - (decision 4 — the structural constraint) + (decision 4, the structural constraint) - docs/architecture/mapping.md §The structural constraint: spawn-and-return; §exit_status row (sent before close, from the spawned task) @@ -68,68 +69,69 @@ mechanically from the stubs this task leaves behind. ## Notes - `src/serve.rs` (new): the spawn-and-return plumbing as *mechanics*, - not convention — `spawn_serving_path(serving future)` (the one + not convention: `spawn_serving_path(serving future)` (the one choke point for the ADR-006 decision-4 spawn; nothing it takes is ever awaited inline) and `report_exit_status_then_close(&Handle, channel, N)` (the cloned-handle report with the `mapping.md` `exit_status` row's ordering baked in: exit-status **before** - close). Both re-exported `#[doc(hidden)]` from the crate root — the - `SessionHandler` §Test-surface pattern — as the contract-test + close). Both are re-exported `#[doc(hidden)]` from the crate root, + the `SessionHandler` §Test-surface pattern, as the contract-test surface the mechanics rows compose over the real wire. A unit row - (`spawn_returns_while_the_serving_path_is_still_pending` — + (`spawn_returns_while_the_serving_path_is_still_pending`, latch-gated future, spawn call returns first) pins spawn-then-return directly. The derivation contract's *rules* (which exit code → which SSH exit shape) stay wave-3 `mapping.md` material; no rule logic landed here. - `src/handler.rs`: the four session-row seam stubs (`git_row_stub`, `pipe_row_stub`, `pty_row_stub`, `shell_row_stub`) are now spawned - serving paths — `spawn_seam_stub` clones `session.handle()`, + serving paths: `spawn_seam_stub` clones `session.handle()`, `seam_stub_decline(handle, channel)` runs the failure-then-close decline on the spawned task, and the hooks return without awaiting anything. A compile-level fact coded around: the row stubs are - plain `fn`s (not `async`) — an `async fn` holding `&Session` across + plain `fn`s (not `async`), because an `async fn` holding `&Session` + across the spawn makes the handler future non-`Send` (russh requires `+ Send` on handler futures; `Session` is not `Sync`), so the clone is taken synchronously and only the handle moves into the spawned future. Wire shapes are unchanged (all `w2-dispatch-decline-core` rows pass unmodified with the spawned decline); the tunnel seam stub - and every open-level/capability decline stay inline by class — they - answer the open request *before* any channel is owned, so nothing + and every open-level/capability decline stay inline by class, as + they answer the open request *before* any channel is owned, so nothing spawns yet (documented in the new handler module-doc section §The spawn-and-return structural constraint; wave 3's accept-only-after-fabric-open flow is where the tunnel row gains its spawn). - `tests/spawn_return.rs` (new): the end-to-end mechanics proof over - the real wire — the russh server stack (duplex transport, harness - client/harness transport posture) mounted over a minimal proof + the real wire, using the russh server stack (duplex transport, + harness client/harness transport posture) mounted over a minimal proof handler that is *not* the crate's `SessionHandler` (the auth and dispatch hooks have their own suites; the proof subject is the mechanics) and that serves execs by composing the exported plumbing: `spawn_serving_path` of a release-gated serving future reporting `exit-status(7)` + close through the cloned handle. Two rows, each with a `Contract stamp:`: - - `handler_returns_fast_while_the_spawned_serving_path_is_pending` — + - `handler_returns_fast_while_the_spawned_serving_path_is_pending`, the structural constraint as a wire test: after the exec hook has provably returned (a bounded wait on the handler-side timestamp; the client's exec returns when the request is on the wire, not when the hook ran) and with the serving path still gated, the client's *follow-up traffic* (a second session channel + second - exec) is routed and dispatched normally — POC-3's follow-up-bytes - deadlock shape inverted — and the still-gated no-report state is + exec) is routed and dispatched normally, with POC-3's follow-up-bytes + deadlock shape inverted, and the still-gated no-report state is asserted before the release. - `spawned_stub_reports_exit_status_before_close_after_the_handler_returned` - — the client observes `ExitStatus{7}` strictly before `Close` + with the client observing `ExitStatus{7}` strictly before `Close` (message-by-message, no draining that could hide ordering), and the recorded timestamps prove the exit-status left *after* the handler had already returned (the literal criterion). - **The proof-handler design note:** the gate is `tokio::sync::watch`- - based (no lost-wakeup race — a late subscriber sees the release, + based (no lost-wakeup race; a late subscriber sees the release, which a `Notify`-with-`notify_waiters` shape could miss against multiple spawned paths); the handler-side timestamps ride `parking_lot::Mutex`. - Feature independence: the mechanics are feature-independent (the helpers exist in every build and the proof rows run in every build - they were verified under — default, `git`, `session`, `tunnel`, + they were verified under (default, `git`, `session`, `tunnel`, `sftp`, `--all-features`); no feature-gated code was added, so no new decline-path coverage obligations arise. @@ -138,21 +140,22 @@ mechanically from the stubs this task leaves behind. Landed the spawn-and-return plumbing + exit/status reporting mechanics (`w2-spawn-return`) in three files: -- **`src/serve.rs`** — `cargo test`-pinned mechanics: the +- **`src/serve.rs`** with `cargo test`-pinned mechanics: the `spawn_serving_path` choke point (the ADR-006 decision-4 rule, in code, never awaited inline) and `report_exit_status_then_close` (exit-status before close through the cloned `Session::handle()`), both `#[doc(hidden)]` contract-test re-exports; a unit row pins spawn-returns-while-pending directly. -- **`src/handler.rs`** — the four session-row seam stubs became - spawned serving paths (handle clone taken synchronously — the +- **`src/handler.rs`**: the four session-row seam stubs became + spawned serving paths (handle clone taken synchronously, given the non-`Send` shape that forcing `async fn` stubs would produce is coded around and recorded); failure-then-close wire shapes unchanged (all dispatch-core rows pass unmodified); open-level declines and the tunnel seam stub stay inline by class, documented; a new module-doc section carries the constraint and the wave-3 inherit-the-pattern note. -- **`tests/spawn_return.rs`** — the end-to-end mechanics proof: two +- **`tests/spawn_return.rs`**: the end-to-end mechanics proof, with + two `Contract stamp:` rows over the real wire (handler-returns-fast under follow-up traffic with the spawn still pending; exit-status before close, timestamps proving the report outlived the handler's diff --git a/tasks/implementation/w3-decline-tracing.md b/tasks/implementation/w3-decline-tracing.md index 7b96fb9..deeb901 100644 --- a/tasks/implementation/w3-decline-tracing.md +++ b/tasks/implementation/w3-decline-tracing.md @@ -1,6 +1,6 @@ --- id: w3-decline-tracing -name: Wave 3 — decline-path tracing (the wave-2 gate's booked finding) +name: "Wave 3: decline-path tracing (the wave-2 gate's booked finding)" status: completed depends_on: [w3-seam-config] scope: narrow @@ -13,35 +13,35 @@ level: implementation Land the wave-2 review gate's booked finding: observability on the decline paths. `tracing` rides the manifest per the ADR-002 toolkit but -so far nothing in `src/` emits — a request-level decline's "reason" is -today only the routing decision documented per build. The real serving +so far nothing in `src/` emits, so a request-level decline's +"reason" is today only the routing decision documented per build. The real serving paths land in this wave's mapping tasks, where serve/decline tracing -matters — so the event **vocabulary** lands first and the mappings -inherit it. +matters; therefore the event **vocabulary** lands first and the +mappings inherit it. -- **The family-convention check first** — before choosing the event +- **The family-convention check first**: before choosing the event shape, read the family's established tracing posture (alktty / alkgit / alktunnels / alkcall `src/`: levels, event/field naming, - what gets emitted on error and decline paths) and adopt it — no - alkssh-private dialect; record what was read and chosen. -- **The event vocabulary** — the deciding context on every decline: - row + feature build + channel id (+ the decline class — open-level - vs request-level vs serving-path, and where applicable the failure - shape carried). The mapping tasks then emit at their sites. -- **Instrument what exists now** — the open-level declines + what gets emitted on error and decline paths) and adopt it (no + alkssh-private dialect); record what was read and chosen. +- **The event vocabulary**, with the deciding context on every + decline being row + feature build + channel id (plus the decline + class: open-level vs request-level vs serving-path, and where + applicable the failure shape carried). The mapping tasks then emit at their sites. +- **Instrument what exists now**: the open-level declines (unfeatured / never-feature / cap-exceeded), the request-level capability declines (`subsystem`/`env`/`x11`/`agent`, the unfeatured-parse routes), the seam-stub serving declines, and the - serve-path skeleton (spawned path started / ended, failure) — the - stubs' events carry into the mapping tasks' replaced paths. + serve-path skeleton (spawned path started / ended, failure), with + the stubs' events carrying into the mapping tasks' replaced paths. - **Safety posture** (conventions 6, OQ-03): no secret material ever enters an event, never the username; identity/fingerprint exposure on decline events is decided here against the family posture and - recorded (auth.md: log exposure is deployment policy — the crate's - events must be safe-by-default at every level). + recorded (auth.md: log exposure is deployment policy, and the + crate's events must be safe-by-default at every level). -Small, cross-cutting unit — one pass over the landed handler/dispatch -sites with the vocabulary, so the three mapping branches never land +A single small cross-cutting pass over the landed +handler/dispatch sites with the vocabulary, so the three mapping branches never land inconsistent events. ## Acceptance Criteria @@ -54,7 +54,7 @@ inconsistent events. - [x] The safety posture recorded (no secrets, no username; what identity detail may appear decided) - [x] A test pins the event presence at the decline sites (or the - doc-test convention the family uses — decide + record) + doc-test convention the family uses, decided and recorded) - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo doc --no-deps` pass @@ -66,7 +66,8 @@ inconsistent events. classes), §Decline matrix - docs/architecture/auth.md §Dispositions (log exposure posture), docs/architecture/decisions/002-russh-pin.md (the toolkit) -- sibling `src/` tracing usage — the convention read this task adopts +- sibling `src/` tracing usage, the convention this task reads and + adopts ## Notes @@ -85,8 +86,8 @@ the family's `src/`: (`listen = …`, `error = %e`, `reason = …`); levels `debug` for structural observations, `warn` for recoverable failures, `error` for abnormal pump terminations. -- **alkcall** (`protocol/*`, `channels/*`): the widest use — the same - fields convention (`channel_id`, `request_id`, `error = %e`), +- **alkcall** (`protocol/*`, `channels/*`): the widest use, with the + same fields convention (`channel_id`, `request_id`, `error = %e`), component-prefixed messages (`"channel client: …"`, `"open wrapper: …"`), `debug`/`warn` only. - **alkhttp** (the closest structural analog): `warn`/`error` with @@ -100,50 +101,52 @@ free `tracing` fn calls, message-last lowercase with the ### The event vocabulary (landed in `src/observe.rs`) -- **`feature_build`** — the compile-time feature set as a stable +- **`feature_build`**, the compile-time feature set as a stable string (`"none"`, `"git"`, `"session+git"`, …; features join in the order `session, git, tunnel, sftp`), so a captured event is self-describing per build; `sftp` ships in the string even though it gates no route (it is part of what the assembly deployed). -- **`row`** — the greppable wire row: the channel type for open-level +- **`row`**, the greppable wire row: the channel type for open-level (`"session"`, `"direct-tcpip"`, `"x11"`, …), the request name for request-level (`"pty_request"`, `"tcpip_forward"`, …), the handler hook name for auth (`"auth_publickey"`, `"auth_none"`, …). -- **`decline_class`** — `"open"` | `"request"` | `"serving"` | +- **`decline_class`**, `"open"` | `"request"` | `"serving"` | `"auth"`: the two wire decline classes (`mapping.md` §Wire decline reasons), plus the serving-path class (this wave the seam stub's decline *is* the serving path; the mappings emit the same class from their real spawned paths) and the auth class (the ADR-004 - disposition declines — added by this task: the auth hooks are + disposition declines, added by this task: the auth hooks are decline sites too, and `auth.md`'s dispositions are the crate's auth-decline contract). -- **`reason`** — the decline cause, mirroring the architecture's own +- **`reason`**, the decline cause mirroring the architecture's own classifications: `"unfeatured"`, `"never-feature"` (ADR-003 §5), `"cap-exceeded"`, `"connect-failed"` (tunnel stub / ADR-006 decision 1), `"seam-stub"` (session-row stubs), `"unimplemented"` - (subsystem rows — OQ-13 — and the wave-3 listen forms), - `"declined-by-design"` (env — OQ-09), and on the auth class + (the subsystem rows under OQ-13 and the wave-3 listen forms), + `"declined-by-design"` (env, OQ-09), and on the auth class `"method-unimplemented"` / `"cert-unsupported"` / `"non-ed25519"` / `"fingerprint-unresolved"`. -- **`channel_id`** — the wire channel id (u32), on every decline of a - channel (notably: the event's id is *russh's server-side* id — - `ChannelId` is not publicly constructible, so the helpers take the +- **`channel_id`**, the wire channel id (u32), on every decline of a + channel (notably: the event's id is *russh's server-side* id, + because `ChannelId` is not publicly constructible, so the helpers take the raw u32 via `ChannelId::number()`); the auth class and the global `-R` listen forms carry none (a separate `decline_global_request` - helper — global requests have no channel). -- **`exec_route`** — only on `exec_request`-row events: the parse + helper, since global requests have no channel). +- **`exec_route`**, only on `exec_request`-row events: the parse route (`"git"` | `"pipe"`), so a git row's decline is distinguishable from a pipe row's in combined builds. A `None` route field is not recorded at all by `tracing` (discovered by the unit rows; recorded in the module docs). The `message` field rides first - (the `event!` macro's shape — the unit rows pin the field order). + (the `event!` macro's shape, the unit rows pinning the field + order). Levels: every decline and the lifecycle's started/ended emit at -`debug` — a decline is the door's normal, honest posture (vanilla +`debug`, since a decline is the door's normal, honest posture +(vanilla clients probe unfeatured rows routinely), matching the family's structural-observation level. Only [`serving_path_failed`] is `warn` (a serving path that could not complete its wire reporting is a real -failure — the family's recoverable-failure level). +failure, which is the family's recoverable-failure level). ### The serving-path skeleton @@ -152,40 +155,42 @@ lifecycle events hang off the spawned task's body in `handler.rs`'s `seam_stub_decline`: started (the spawned task's first action, so "started" is the task's honest start, not the hook's spawn call) → the serving-path decline (class `serving`, reason `seam-stub`) -→ ended — or failed (`warn`, with the russh protocol error) when a +→ ended, or failed (`warn`, with the russh protocol error) when a report send could not complete. The wave-3 mapping branches (`w3-git-mapping`, `w3-tty-session`, `w3-tunnel-direct`) inherit the shaped rows by emitting the same helpers from their real serving tasks. -### The safety posture (conventions 6, OQ-03 — recorded) +### The safety posture (conventions 6 and OQ-03, recorded) **No secret material ever enters an event; never the username; the fingerprint stays out too.** Structurally enforced, not recalled: the vocabulary helpers take only `&'static str` values and the wire -channel id — no parameter a username, command string, env name/value, +channel id, leaving no parameter a username, command string, env +name/value, repo path, key, or fingerprint could ride. The `reason` codes carry the decline's *cause class*, and the deciding context is the row + -build + channel — deliberately *not* the identity behind the wire +build + channel, deliberately *not* the identity behind the wire attempt. This is the recording of auth.md §Dispositions' standing rule at the crate level: log exposure is deployment policy, so the -crate's events are safe-by-default at every level — the fingerprint +crate's events are safe-by-default at every level, with the +fingerprint (and any identity detail) is a deployer-side computation over its own logs, never crate-emitted. If a future decline site needs request-argument detail (e.g. the git grammar's decline reasons in `w3-git-mapping`), it must add an explicitly-vocabulary'd field and -record its safety reasoning — dynamic strings do not ride +record its safety reasoning, since dynamic strings do not ride `src/observe.rs` by default. The wire rows pin the posture by scanning every captured event: no username string, no `SHA256:` fingerprint, no `user*`-named field ever appears. ### The test convention (decided + recorded) -The family has no event-capture precedent to adopt — alktunnels' +The family has no event-capture precedent to adopt: alktunnels' `tracing-subscriber` dev-dep drives fmt output, not assertions. The convention these rows pin: **a hand-rolled minimal `tracing::Subscriber`** capturing events into a shared buffer, -installed once as the global default (`tests/observe.rs`) — the -server side runs on spawned tasks, so a thread-local +installed once as the global default (`tests/observe.rs`), because +the server side runs on spawned tasks, so a thread-local `with_default` capture cannot see them, and a global capture needs no new dependency. Two levels of pinning: @@ -195,16 +200,17 @@ new dependency. Two levels of pinning: lifecycle messages, the `warn` failure arm, and the cfg-derived `feature_build` string. - Wire-level (`tests/observe.rs`, the loopback harness drives the - real wire; capture installed up front via the mount helpers — a - lazily-installed global loses the earliest emissions): presence by - field-matching at every decline site across the builds — x11 open, + real wire; capture installed up front via the mount helpers, since + a lazily-installed global loses the earliest emissions): presence by + field-matching at every decline site across the builds: x11 open, `-R` listen (global), the fingerprint-unresolved and auth_none - dispositions (driven on fresh mounts — a second auth exchange on an + dispositions (driven on fresh mounts, since a second auth exchange + on an already-authenticated session is not a supported russh surface, as the probe discovered), the unfeatured session/pipe/pty rows, the git seam-stub's full lifecycle, the tunnel stub's `connect-failed`, the cap-exceeded row (zero-cap limits must ride the *server* config - — `serve_config`, not just the client's `Limits`), and the + (`serve_config`), not just the client's `Limits`), and the username/fingerprint safety scan. Build-specific rows follow the `dispatch_core.rs` per-build cfg convention and were verified in the default, `git`, `session`, `tunnel`, and `--all-features` diff --git a/tasks/implementation/w3-git-mapping.md b/tasks/implementation/w3-git-mapping.md index 798d179..064c66d 100644 --- a/tasks/implementation/w3-git-mapping.md +++ b/tasks/implementation/w3-git-mapping.md @@ -1,6 +1,6 @@ --- id: w3-git-mapping -name: Wave 3 — the git mapping (exec grammar + alkgit dispatch) +name: "Wave 3: the git mapping (exec grammar + alkgit dispatch)" status: completed depends_on: [w3-decline-tracing] scope: moderate @@ -11,28 +11,29 @@ level: implementation ## Description -Land the git branch of the spine (ADR-006 decision 2, exec-only) — the -alkforge primary purpose: the full exec grammar replaces the wave-2 +Land the git branch of the spine (ADR-006 decision 2, exec-only), +which is the alkforge primary purpose: the full exec grammar replaces the wave-2 skeleton probe, and the git row becomes a real dispatch to the published alkgit seam. Everything POC-3 stage A proved, re-landed as contract. -- **The full grammar** (replaces `dispatch::classify_exec` — the +- **The full grammar** (replaces `dispatch::classify_exec`, the wave-2 interpretation's named replacement; its 7 probe unit rows - migrate): `git-{upload,receive}-pack ''` — quoted/unquoted + migrate): `git-{upload,receive}-pack ''` handles quoted/unquoted paths; strip the quotes and the leading `/` (POC-3 stage A rows: what real OpenSSH git sends); unknown services (`git-send-pack`, `git-upload-archive`) → decline-with-reason, the - honest surface; service-only (no repo) → decline-with-reason — - POC-3 §A: SSH exec must carry the repo; per-length limits consumed - at last — `Limits::max_exec_command_len` enforced **before** any + honest surface; service-only (no repo) → decline-with-reason (POC-3 + §A: SSH exec must carry the repo); per-length limits consumed + at last, with `Limits::max_exec_command_len` enforced **before** + any parse or allocation (decline-first, alloc-never; the wave-2 carried-not-enforced reservation). Decide the wire shapes per decline class (open/reply vocabulary, whether "unknown service" and "not git at all" share a reason shape) and record. - **Repo path → repo_id, no existence oracle**: the parse validates via `alkgit::validate_repo_id` (1–2 segments, charset, ≤ 100 bytes) - and hands the id through — alkssh never resolves; the + and hands the id through, alkssh never resolving; the **collapsed generic denial before any protocol byte** is `serve_duplex_resolved`'s `SubstrateError::AccessDenied` posture, contract-pinned here: over the SSH channel, no pack/ref byte @@ -40,30 +41,30 @@ contract. test row). Decide the `.git`-suffix normalization against the alkgit registry contract (what vanilla git sends vs `validate_repo_id`'s grammar) and record. -- **The dispatch** — the git row becomes real: the taken channel - (the store-and-take handoff lands **here first** — the accepted +- **The dispatch**: the git row becomes real, and the taken channel + (the store-and-take handoff lands **here first**; the accepted channel is stored at the open, `channels.remove(&id)` → `into_stream()` in the spawned serving task; the session task reuses the store) → `alkgit::serve_duplex_resolved(repo_id, identity, service, duplex, limits, registry, refs, packgen, - ingest, push_options_negotiated, pipeline)` — the serve-config's - `git` seam handle (`w3-seam-config`), the resolved identity riding + ingest, push_options_negotiated, pipeline)`, namely the + serve-config's `git` seam handle (`w3-seam-config`), the resolved identity riding the `identity` param. Process-mode dialect: alkgit speaks first - (the V0 advertisement, no service prefix) — nothing alkssh-side; - verify the dialect holds over the channel duplex with a byte-level + (the V0 advertisement, no service prefix), and nothing is + alkssh-side. Verify the dialect holds over the channel duplex with a byte-level row. - **Exit-status both directions** (POC-3 §A.5): clean serve end → `exit-status(0)`... decide the code mapping (`SubstrateError` - outcomes → the code — POC evidence: denied → the client observed - `ExitStatus{1}`; vanilla git exits 0 on success) — sent before + outcomes → the code; POC evidence: denied → the client observed + `ExitStatus{1}`; vanilla git exits 0 on success), sent before close, from the spawned task via the cloned `Session::handle()` (the wave-2 mechanics reused). Record the decision. -- **Identity** — the parked wave-2 deferred item discharges here: +- **Identity**: the parked wave-2 deferred item discharges here, and the resolved identity (the bridge's `auth_succeeded` ride-along) - reaches the seam — assert it live (the authorize-path/witness row + reaches the seam, so assert it live (the authorize-path/witness row the harness can observe; decide the cheapest row) so the visible-surface invariant is observable through the git row. -- **Contract rows through the harness, per build** — the +- **Contract rows through the harness, per build**, with the `tests/dispatch_core.rs` skeleton rows upgraded: git-only serves the git row through the doubles (clone/push semantics over the in-mem composition), declines plain exec/pty/shell (parse-then-fail, @@ -72,12 +73,12 @@ contract. routing holds. The declined rows keep their wire shapes (`channel_failure`, channel usable / serving-path failure+close per class). -- **The real-tool smoke** — one `git clone` (2.43+) through the +- **The real-tool smoke**: one `git clone` (2.43+) through the harness against hand-rolled in-mem alkgit doubles (alkgit's `Mem*` - are test-private — alkssh hand-rolls the minimal quartet per + are test-private, so alkssh hand-rolls the minimal quartet per `alkgit/src/backend.rs:164-254`'s noop shapes; never sibling-checkout paths, never copies of the doubles' code). Decide the tooling - posture (require git in the environment vs conditional-skip — + posture (require git in the environment vs conditional-skip, skipped-with-reason at most, never silently green) and record. ## Acceptance Criteria @@ -106,10 +107,10 @@ contract. ## References - docs/architecture/mapping.md (the git channel-type row; §Session- - channel dispatch — decline-first-alloc-never; §the structural - constraint) + channel dispatch and its decline-first-alloc-never rule; §the + structural constraint) - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - (decision 2 — exec-only; decision 4 — spawn-and-return) + (decision 2, exec-only; decision 4, spawn-and-return) - docs/architecture/decisions/004-auth-dispositions.md (the identity ride), docs/architecture/auth.md §Server direction (step 4) - docs/research/poc-3-mapping-bakeoff.md §Stage A (the grammar rows, @@ -126,7 +127,8 @@ contract. ### Decisions recorded (implementation, 2026-10-28) - **Course correction to `w3-seam-config`**: `GitSeam` gains - `limits: alkgit::Limits` — `serve_duplex_resolved` requires the + `limits: alkgit::Limits`, since `serve_duplex_resolved` requires + the alkgit ADR-009 session budget, which the landed handle had no field for. alkgit's own contract shapes this ("constructed by the door/adapter from assembler config"), so the budget rides the seam @@ -135,34 +137,35 @@ contract. docs. - **The grammar** (`dispatch.rs`, the probe's rows migrated): exact serving-service token + single separating space + path-shaped - argument (the wave-2 probe's pinned verdicts preserved verbatim — + argument (the wave-2 probe's pinned verdicts preserved verbatim: `git-upload-pack rel` and double-space arguments are *not git*); quoted shape is fully `'`-wrapped (unterminated → pipe, the probe's non-path-shape verdict); leading `/` stripped after quotes; **one trailing `.git` suffix stripped** (the vanilla-clone idiom; the alkgit registry grammar excludes `.`, so the door normalizes the idiom rather than asking registries for dotted keys; an empty - remainder — `/.git` — is *kept* and the git row's validation + remainder (`/.git`) is *kept* and the git row's validation declines it, never a silent emptying). Validation: `alkgit::validate_repo_id` at the git row (`git_map.rs`, - feature-gated — the parse core stays feature-free); a shape-invalid + feature-gated, while the parse core stays feature-free); a + shape-invalid id declines `invalid-repo-id` alkssh-side; a shape-valid id is handed through untouched and **alkssh never resolves** (resolution, authorize, and the collapsed generic denial live at the seam). Unknown services (`git-send-pack`, `git-upload-archive`) and service-only (including an empty argument) route to - `ExecRoute::Declined` — build-independent, off the pipe row, per + `ExecRoute::Declined` (build-independent, off the pipe row, per POC-3 §2's honest-surface verdict. - **Wire shapes per decline class** (recorded): SSH request replies carry no text, so the reason lives only in the event vocabulary; the git declinables (`unknown-service` / `service-only` / `invalid-repo-id` / `exec-oversize`) share the request-level reply - shape with parse-then-fail — `channel_failure`, channel alive + shape with parse-then-fail: `channel_failure`, channel alive (POC-3's decline shape, POC-1 §5 posture); the reply vocabulary is the decision ("unknown service" vs "not git at all" differ in - nothing observable on the wire — only the event). Serving-path + nothing observable on the wire, only the event). Serving-path declines (the row owned the channel) keep failure+close: - `seam-unconfigured` (feature on, seam unset — the unset-declines + `seam-unconfigured` (feature on, seam unset, the unset-declines contract; keeps the wave-2 decline-matrix rows' shape in builds without a configured seam), plus the unreachable-in-practice guards `no-identity` and `channel-not-held`. The *serve's own* @@ -174,45 +177,46 @@ contract. - **Exit mapping** (POC-3 §A.5, decided): `Ok(())` → exit-status(0) (vanilla git exits 0 on success; the clean-end wire row exercises the flush-only request's `Ok(None)` → `Ok(())` shape); every - `SubstrateError` → exit-status(1) — `AccessDenied` per the POC's + `SubstrateError` → exit-status(1): `AccessDenied` per the POC's wire evidence, `Io`/`Protocol` the same (the service ran and failed), the machinery-misuse variants defensively 1 (they never arise from the serving entry). `255` never fabricated (OpenSSH's could-not-start semantic this door never claims). Status before close, from the spawned task via the cloned handle; a failed report send is the vocabulary's one `warn` (the wave-2 stub mechanics). A - serve `Err` is *not* a decline event — the substrate already mapped - its own error onto the wire. + serve `Err` is *not* a decline event, as the substrate already + mapped its own error onto the wire. - **Store-and-take handoff**: `SessionHandler.channels` (plain - `std` HashMap, single-writer — every mutation on the packet loop's - task). Stored at the confirmed open (accept then insert); taken by + `std` HashMap, single-writer, with every mutation on the packet + loop's task). Stored at the confirmed open (accept then insert); taken by the git row (`remove` → `into_stream()` in the spawned serving - task); released by `channel_close` (the cap is released there too — + task); released by `channel_close` (the cap is released there too: one hook, both budgets). The session rows still hold until their wave-3 tasks take them. A routed exec answers `channel_success` - inline (the POC-3 §A.5 shape — the request routed; the serving - outcome reports through exit-status). + inline (the POC-3 §A.5 shape, where the request routed and the + serving outcome reports through exit-status). - **Identity** (`w2-auth-bridge`'s parked ride-along, discharged): the resolved identity is cloned into the serving task and passed on `serve_duplex_resolved`'s identity param. The cheapest live witness - row: **alkgit's `authorize` evaluation** — two otherwise-identical + row: **alkgit's `authorize` evaluation**, giving two + otherwise-identical rows (private repo granted to the riding id → the advertisement flows; the same repo ungranted → the denial band + exit 1); no direct identity introspection needed, and the deny row doubles as the authorize-path witness. `None` can never occur post-auth and declines serving-path. - **Push semantics through the doubles**: the packless CAS path - (alkgit ADR-020 §1) is the pack-free receive-pack row — commands + + (alkgit ADR-020 §1) is the pack-free receive-pack row, with commands + flush (caps NUL-attached on the first command line, `report-status` requested), no pack; the wire layer applies via `GitRefs::apply_updates` and writes the report (`unpack ok` / - `ok `). The duplex discipline *drains the raw tail packless* - — the row had to signal client-side EOF after the report flush - (observed; the real client-eof shape), which is also why vanilla + `ok `). The duplex discipline *drains the raw tail packless*, + which is why the row had to signal client-side EOF after the report + flush (observed, the real client-eof shape) and also why vanilla delete-pushes end cleanly. `GitPackIngest::prepare` never fires - packless — asserted. + packless, as asserted. - **Per-build matrix**: unchanged rows in `tests/dispatch_core.rs` - (their observables hold — the config-driven decline keeps the + (their observables hold, since the config-driven decline keeps the failure-then-close shape in seam-less builds; unknown services keep failure-then-alive), docs/stamps updated for the new decision set; the git row's serving + grammar-decline rows live in the new @@ -220,11 +224,11 @@ contract. feature isolation). `tests/observe.rs`'s git-row event row updated to the `seam-unconfigured` reason. - **Real-tool smoke posture** (recorded): the clone smoke (a real - `git clone` of an unborn-HEAD repo over a TCP-mounted adapter — the - same loopback server stack, the accept loop as the assembly's + `git clone` of an unborn-HEAD repo over a TCP-mounted adapter, on + the same loopback server stack, the accept loop as the assembly's mount) **requires git ≥ 2.43 and ssh in the environment**; - missing/older tooling is skipped *with a printed reason* — - skipped-with-reason at most, never silently green. The empty-repo + missing/older tooling is skipped *with a printed reason* + (skipped-with-reason at most, never silently green). The empty-repo clone exercises the full chain (exec parse → dispatch → the seam advertisement → ls-refs → exit-status(0)) with zero pack bytes: the unborn-HEAD line is what real git 2.43's clone consumed (POC-3's @@ -233,4 +237,4 @@ contract. --features git --test git_mapping`, `cargo test --features session`, `cargo test --all-features`, `cargo clippy --all-targets --all- features -- -D warnings` ×2 (default + all-features), `cargo fmt - --check`, `cargo doc --no-deps` — all green. \ No newline at end of file + --check`, and `cargo doc --no-deps`: all green. \ No newline at end of file diff --git a/tasks/implementation/w3-seam-config.md b/tasks/implementation/w3-seam-config.md index 151d968..8fba959 100644 --- a/tasks/implementation/w3-seam-config.md +++ b/tasks/implementation/w3-seam-config.md @@ -1,6 +1,6 @@ --- id: w3-seam-config -name: Wave 3 — serve-config seam handles (alktty/alkgit per-feature) +name: "Wave 3: serve-config seam handles (alktty/alkgit per-feature)" status: completed depends_on: [review-wave-2, w1-config] scope: narrow @@ -13,7 +13,7 @@ level: implementation Extend [`crate::ServeConfig`] with the per-feature family-seam handles the wave-3 mappings consume. This is wave 3's first *course -correction* to the wave-1 config surface — the assembly-supplied +correction* to the wave-1 config surface: the assembly-supplied composition inputs were never carried (mapping.md's seam-entries table names them inside the seam signatures; the wave-1 config bullet pre-dates that reading), and the mappings cannot call @@ -25,17 +25,17 @@ un-set-declines posture structural and the review surface unified: rows decline even with the feature on (the decline matrix's "an unset field declines its row though the feature is on" note, config-driven). Contents, per alktty 0.8.1's actual - `drive_session[_pre_negotiated]` signature — decide the exact final + `drive_session[_pre_negotiated]` signature; decide the exact final shape (flat `Option` fields vs an `Option`-struct per seam; record): - the alktty backends map (`Arc>>` — assembled by the assembly; alkssh never + Arc>>`, assembled by the assembly; alkssh never touches a real backend, the `local` shapes stay alktty-side); - - the backend **selector** — the `NegotiateRequest.backend` string + - the backend **selector**, the `NegotiateRequest.backend` string alkssh writes (assembly-supplied, never wire-derived; decide and record); - the ownership provider `Option>` (an alkcall type — - no new dep; pass-through to `drive_session_pre_negotiated`, + alkcall::core::ownership::OwnershipProvider>>` (an alkcall type, + so no new dep; pass-through to `drive_session_pre_negotiated`, family ACL layer riding along). - **The `git` seam handle** (feature-gated), unset → the git rows decline even with the feature on. Contents, per alkgit 0.1.0's @@ -45,15 +45,16 @@ un-set-declines posture structural and the review surface unified: and `push_options_negotiated: bool` (the assembler's config flag; false in the v1 default). - **Manifest** (the wave-1 rule: per-feature sibling deps land with - their first consuming wave — the config fields consume them first, - so they are not dead entries): optional deps + their first consuming wave, and the config fields consume them + first, so they are not dead entries): optional deps `alktty = "0.8.1"`, `alkgit = "0.1.0"` wired feature-to-dep (`session = ["dep:alktty"]`, `git = ["dep:alkgit"]`) so a git-build never compiles alktty and vice versa; verify the pins against crates.io before committing (verified at decomposition: 0.8.1 / 0.1.0, both over the shared `alkcall 0.10` req → 0.10.1; MSRVs 1.88 - ≤ our 1.89; MIT OR Apache-2.0). **alktunnels is NOT landed here** — - the config carries no alktunnels types beyond the already-landed + ≤ our 1.89; MIT OR Apache-2.0). **alktunnels is NOT landed here**, + since the config carries no alktunnels types beyond the + already-landed `Arc`; its optional dep lands with its first consuming code (`w3-tunnel-direct`). @@ -98,7 +99,7 @@ each seam handle carries). - `Cargo.toml`: optional `alktty = "0.8.1"` / `alkgit = "0.1.0"` (both `default-features = false`), wired `session = ["dep:alktty"]`, `git = ["dep:alkgit"]`. alktty's `local` - feature stays off — alkssh never touches a real backend; the `local` + feature stays off, since alkssh never touches a real backend; the `local` shapes stay alktty-side. Feature-graph comment updated (wave-3 seam-config is the deps' first consumer; alktunnels still lands with `w3-tunnel-direct`). @@ -114,18 +115,20 @@ each seam handle carries). `GitSeam{registry, refs, packgen, ingest, push_options_negotiated, pipeline}` held as `cfg`-gated `Option` fields (`session_seam` / `git_seam`), each `Clone` (all payload types `Arc` -or plain data — the spawned serving tasks clone the seam), each with a +or plain data, as the spawned serving tasks clone the seam), each with a hand-rolled `Debug`. **Flat `Option` fields were rejected**: each seam is consumed atomically by exactly one seam call (`drive_session[_pre_negotiated]` / `serve_duplex_resolved`), so a struct admits no half-supplied seam states (backends without a selector; three traits without the fourth) that would all map to the -same all-or-nothing decline — the unset-declines contract reads as one +same all-or-nothing decline, which is why the unset-declines contract +reads as one `Option` per seam, "unset → the owned rows decline even though the feature is on" (the decline matrix's config-driven note), checked at request time by the mappings, never at construction. The already-landed tunnel handle stays the flat -`Option>` — a single-field seam needs no struct. +`Option>`, since a single-field seam needs no +struct. Naming: struct names carry the crate (the consuming mappings will see it in the field docs); the fields carry the row vocabulary (`session_seam` / `git_seam`, matching the decline matrix's row names). @@ -134,16 +137,16 @@ it in the field docs); the fields carry the row vocabulary - `alktty 0.8.1`: published 2026-10-22, not yanked, `MIT OR Apache-2.0`, `rust_version = "1.88"` ≤ our 1.89; - `alkcall ^0.10.1` — shares our `alkcall = "0.10"` resolution + `alkcall ^0.10.1`, which shares our `alkcall = "0.10"` resolution (0.10.1), so no alkcall fork in the lockfile. - `alkgit 0.1.0`: `MIT OR Apache-2.0`, `rust_version = "1.88"`; - `alkcall ^0.10` — same shared resolution. + `alkcall ^0.10`, the same shared resolution. - Signatures confirmed against the published crates (the registry sources under `~/.cargo/registry/src/`): alktty's `drive_session[_pre_negotiated]` (adapter.rs:205-241) takes `(writer, reader, [req,] backends: Arc>>, ownership: Option>, - identity: Option)` — the identity is the per-connection + identity: Option)`, where the identity is the per-connection resolved one, not config; alkgit's `serve_duplex_resolved` (duplex.rs:1412) takes the four traits + `push_options_negotiated: bool` + `pipeline: Arc`. @@ -154,7 +157,7 @@ it in the field docs); the fields carry the row vocabulary ### One course correction absorbed: `ObjectId` is not re-exported by alkgit `GitPackGen::generate`/`common_haves` name `Vec` -and alkgit's lib root does not re-export `ObjectId` — a backend-trait +and alkgit's lib root does not re-export `ObjectId`; a backend-trait doubled in tests has to name the type, so `gix-hash = "0.28"` (matching alkgit's `^0.28` req; the same crate already in the lockfile) lands as a **`[dev-dependencies]` entry only**, exercised exclusively by the @@ -163,16 +166,15 @@ feature-to-dep isolation below; the dev graph is universal by design (tokio and friends are too). Flagged for `w3-git-mapping`: the hand-rolled in-mem quartet will lean on this dev-dep for `ObjectId` (same as this task's noop doubles, modeled on alkgit's own -`backend.rs:164-254` noop shapes — own code, not copied). +`backend.rs:164-254` noop shapes; own code, not copied). -### Dependency isolation — verified structurally, not by assertion +### Dependency isolation, verified structurally not by assertion `cargo tree -e features --features git`: zero `alktty` occurrences; `cargo tree -e features --features session`: zero `alkgit` occurrences; `--all-features`: both present. Combined with the cfg gates (all `alktty`/`alkgit` type references live inside -`#[cfg(feature = "session")]` / `#[cfg(feature = "git")]` blocks — -imports, fields, accessors, Debug, constructor, tests), the "a +`#[cfg(feature = "session")]` / `#[cfg(feature = "git")]` blocks (imports, fields, accessors, Debug, constructor, tests), the "a git-build never compiles alktty and vice versa" invariant is compile-proven for both consumption sides. @@ -181,7 +183,8 @@ compile-proven for both consumption sides. The wave-3 mappings own the request-time decline checks; this task lands the config-level contract rows (constructor posture, `Option` configured() semantics, seam-vs-OQ-08-field independence, Debug -posture — 8 unit rows across the two feature modules) and leaves the +posture, making 8 unit rows across the two feature modules) and +leaves the wave-2 harness rows untouched (featured-but-unset already declines at the row the stubs own; the real serving paths make those rows meaningful in `w3-git-mapping` / `w3-tty-session`, whose seam diff --git a/tasks/implementation/w3-tty-session.md b/tasks/implementation/w3-tty-session.md index 78ddf8d..4e785fa 100644 --- a/tasks/implementation/w3-tty-session.md +++ b/tasks/implementation/w3-tty-session.md @@ -1,6 +1,6 @@ --- id: w3-tty-session -name: Wave 3 — session → alktty (translation bridge + exit derivation) +name: "Wave 3: session → alktty (translation bridge + exit derivation)" status: completed depends_on: [w3-git-mapping] scope: moderate @@ -11,86 +11,86 @@ level: implementation ## Description -Land the session branch — the biggest single-row surface of the spine +Land the session branch, the biggest single-row surface of the spine (mapping.md's `session` rows): pty/shell/pipe-exec become a real serving path through the published alktty seam, as a **translation bridge, not a byte pump** (POC-3 §D.1). Everything POC-3 stage D proved, re-landed as contract: -- **The per-channel state + the store-and-take reuse** — the channel +- **The per-channel state + the store-and-take reuse**: the channel store `w3-git-mapping` lands (`channels.remove(&id)` → `into_stream()` in the spawned serving task) gains the session - rows' per-channel state: the PTY params a `pty_request` delivered + rows' per-channel state, the PTY params a `pty_request` delivered (consumed by the later `shell_request`/`exec_request` on the same - channel — `ssh -t host "cmd"` sends pty-then-exec and must serve - PTY-mode; a `shell_request` without a preceding pty serves - **pipe-mode** `tty = None` — decide whether such a shell serves or - declines and record). -- **Negotiation, built locally, never wire-derived** (OQ-08 — the + channel, since `ssh -t host "cmd"` sends pty-then-exec and must + serve in PTY-mode; a `shell_request` without a preceding pty serves + **pipe-mode** `tty = None`, and whether such a shell serves or + declines is for the task to decide and record). +- **Negotiation, built locally, never wire-derived** (OQ-08: the serve-config is the whole truth): `pty_request` args → `TerminalParamsWire{term, cols, rows, pixel_w/h, modes}` (SSH - ttymodes → the reserved modes JSON — alktty OQ-44's reservation, - deferred there); `shell_request` → `NegotiateRequest{carriage: + ttymodes → the reserved modes JSON, which is alktty OQ-44's + reservation, deferred there); `shell_request` → `NegotiateRequest{carriage: "raw", cmd: shell_argv (the serve-config shell, verbatim), cwd: session_cwd, env: session_env, backend: the seam selector}` → **`drive_session_pre_negotiated(writer, reader, req, backends, ownership, identity)`** spawned; `exec_request` (pipe row) → `cmd = - wrapper ++ [cmd]` (the OQ-08 argv append — vanilla quoting + wrapper ++ [cmd]` (the OQ-08 argv append with vanilla quoting semantics; **never naively split**), `tty = None` pipe mode. **Unset-declines** (OQ-08): `shell_argv` unset → `shell_request` - declines; `exec_wrapper_argv` unset → the pipe row declines — never - a fallback. Decide the wire shape per row (capability decline — - channel stays usable — vs the serving-path failure+close shape) and + declines; `exec_wrapper_argv` unset → the pipe row declines (never + a fallback). Decide the wire shape per row (capability decline, + channel stays usable, vs the serving-path failure+close shape) and record. -- **The translation bridge** — two small adapters around a duplex the +- **The translation bridge**, two small adapters around a duplex the serving task drives (the POC-3 §D.1 shape): SSH `data`/EOF → - `Chunk::stdin` (decide the EOF translation — the zero-length - stdin-chunk sentinel vs alktty's `ControlMessage::Eof` CTRL_IN — + `Chunk::stdin` (decide the EOF translation: the zero-length + stdin-chunk sentinel vs alktty's `ControlMessage::Eof` CTRL_IN; verify what alktty's ChunkReader/backend actually consumes and record); stdout chunks → SSH `data`; stderr chunks → - `extended_data(1)` (PTY mode merges upstream — the backend's - stderr stream is `None` there, so the translation is universal, no + `extended_data(1)` (PTY mode merges upstream, where the backend's + stderr stream is `None`, so the translation is universal with no mode-switch); the exit chunk (`STREAM_CTRL_OUT`) → the derivation contract. No unbounded buffering in either adapter (convention 9). -- **Control rows** — `window_change_request` → `ControlMessage:: +- **Control rows**: `window_change_request` → `ControlMessage:: Resize` CTRL_IN; `signal` → `ControlMessage::Signal` (alktty's - uppercase-name vocabulary; name accepted verbatim — decide the - decline shape for names alktty's backend semantics reject and + uppercase-name vocabulary, with the name accepted verbatim; decide + the decline shape for names alktty's backend semantics reject and record). Mechanics: the requests arrive on the packet loop after - the serving task owns the wire — reach them into the spawned bridge - through a per-channel control surface (bounded mpsc — decide + - record; a silent unbounded proxy is a bounded-resources bug). + the serving task owns the wire, so reach them into the spawned + bridge through a per-channel control surface (a bounded mpsc; decide + plus record; a silent unbounded proxy is a bounded-resources bug). - **The exit derivation contract** (mapping.md rules 1–6, normative): the alktty exit chunk (`{"type":"exit","code":i32}`, exit-chunk-is-last) is the sole fidelity input, never widened: `code ≥ 0` → `exit-status(N)`; negative `−N`, N ≥ 2 → `exit-signal(name)` via the **server-side Unix signum→name table** (crate-local; not derived from alktty's `signal_from_name` inverse - unless that is public and sound — decide the table's form and + unless that is public and sound; decide the table's form and record; `core_dumped` **never asserted**, the bool stays false); - `−1` → `exit-status(255)`, never a signal claim (rule 3 — the - SIGHUP collision accepted symmetrically, per the mapping.md + `−1` → `exit-status(255)`, never a signal claim (rule 3, the + SIGHUP collision accepted symmetrically per the mapping.md rationale); non-Unix adds no other negatives (rule 4); the PTY-path rule-5 fidelity limit documented in the module docs (signal- - terminated PTY sessions arrive `exit-status(1)` — portable-pty's + terminated PTY sessions arrive `exit-status(1)`, portable-pty's exit surface, upstream); unmapped negatives → 255 (rule 6). Sent **before** close, from the spawned task. -- **`env_request` stays declined** (OQ-09 — unchanged from wave 2); +- **`env_request` stays declined** (OQ-09, unchanged from wave 2); `session_cwd`/`session_env` ride `NegotiateRequest` only. -- **Contract rows through the harness, per build** — the wave-2 stub - rows (`pty_row_stub`/`shell_row_stub`/ - `pipe_row_stub`) replaced; the decline matrix's unset-field rows — - feature on + handle unset → the rows decline; featured+set serve - through the alkssh-side **TtyBackend shim double** (alktty's - `MockBackend`/`testing.rs` are `pub(crate)` + `cfg(test)` — never - importable; alkssh hand-rolls the shim: a backend that records - `TtyParams`, emits scripted stdout/stderr/exit chunks and consumes - stdin/resizes/signals — per the `TtyHandle` field shapes). The +- **Contract rows through the harness, per build**, with the wave-2 + stub rows (`pty_row_stub`/`shell_row_stub`/ + `pipe_row_stub`) replaced; the decline matrix's unset-field rows + (feature on + handle unset → the rows decline; featured+set serves + through the alkssh-side **TtyBackend shim double**, since alktty's + `MockBackend`/`testing.rs` are `pub(crate)` + `cfg(test)` and so + never importable; alkssh hand-rolls the shim, a backend that + records `TtyParams`, emits scripted stdout/stderr/exit chunks and + consumes stdin/resizes/signals, per the `TtyHandle` field shapes). The identity-landing item (the parked wave-2 note) asserted through the row that observes the identity at `drive_session_pre_negotiated`. -- **The real-tool smoke** — one OpenSSH pty session through the +- **The real-tool smoke**: one OpenSSH pty session through the harness against the shim (or against a real PTY via a dev-dep - `alktty` `local` composition — decide which posture and record; the + `alktty` `local` composition; decide which posture and record; the real bash/`stty size` fidelity was POC stage D's, wave 5 re-lands it as e2e): the smoke owns the wire rows a plain rustsh-client row can't pin (echo paths, `stty size`-grade fidelity are wave-5's @@ -112,7 +112,7 @@ proved, re-landed as contract: per-channel surface) - [x] The derivation contract rules 1–6 in code with the signum→name table; `−1`→255; `core_dumped` never; PTY-path rule-5 documented; - exit-status before close from the spawned task — pinned by rows + exit-status before close from the spawned task, pinned by rows - [x] The decline matrix per build green through the harness (unset- field rows included); identity ride asserted; the `env_request` decline unchanged; skeleton stub rows migrated @@ -123,16 +123,16 @@ proved, re-landed as contract: ## References - docs/architecture/mapping.md (the `session` channel-type row, - request-level rows, §Exit chunk → SSH exit reporting — rules 1–6) + request-level rows, §Exit chunk → SSH exit reporting, rules 1–6) - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - (decision 4 — spawn-and-return: status before close from the spawned + (decision 4, spawn-and-return: status before close from the spawned task), docs/architecture/decisions/003-feature-graph.md - docs/architecture/auth.md §Server direction step 4 (the identity ride), §Dispositions (OQ-03) - docs/research/poc-3-mapping-bakeoff.md §Stage D (the bridge shape, control rows, the signal/exit fidelity findings, §D.3 mechanics) -- docs/research/reverse-runner.md (pipe-mode pid-only signal caveat — - the fidelity note) +- docs/research/reverse-runner.md (the pipe-mode pid-only signal + caveat, the fidelity note) - alktty 0.8.1: `adapter.rs:234-241` (`drive_session_pre_negotiated`), `negotiation.rs` (`NegotiateRequest`/`TerminalParamsWire`), `wire.rs` (chunks, readers/writers), `control.rs` (`ControlMessage`), @@ -143,31 +143,32 @@ proved, re-landed as contract: ### Decisions recorded (implementation, 2026-10-28) - **The module** (`src/tty_map.rs`, feature `session`): the session - rows live in one module following the `git_map` pattern — the - bridge, the locally-built negotiation, the derivation contract, and + rows live in one module following the `git_map` pattern, covering + the bridge, the locally-built negotiation, the derivation contract, + and the serving path. `serde_json` (the OQ-44 modes JSON + the exit chunk/error-frame reads) and `bytes` (the chunk plane) joined as session-gated optional deps at alktty's own pins. -- **The negotiation** (locally built, never wire-derived — OQ-08): +- **The negotiation** (locally built, never wire-derived; OQ-08): `pty_request` args → `TerminalParamsWire` with SSH ttymodes → the **reserved `{}` modes JSON** (alktty OQ-44's reservation, deferred - there; the map is honest — nothing is carried). Shell argv rides the + there; the map is honest and nothing is carried). Shell argv rides the serve-config **verbatim**; exec rides `wrapper ++ [cmd]` (never naively split); cwd/env come from `session_cwd`/`session_env` only. `backend_params` stays empty (no backend-specific selector fields on this door; the seam selector is the `backend` string). **Pipe-less shell decided: a `shell_request` without a preceding - pty SERVES pipe-mode (`tty = None`)** — vanilla sshd serves a shell - without a terminal too (`ssh -T` is legitimate vanilla). + pty SERVES pipe-mode (`tty = None`)**, because vanilla sshd serves + a shell without a terminal too (`ssh -T` is legitimate vanilla). -- **Unset-declines (OQ-08) — wire shape decided + recorded**: the +- **Unset-declines (OQ-08), wire shape decided + recorded**: the **serving-path shape (`channel_failure` then close, from a spawned task)** for `shell_argv`-unset (`shell-unconfigured`), `exec_wrapper_argv`-unset (`wrapper-unconfigured`), and the - `session_seam`-unset rows (`seam-unconfigured` — the git row's + `session_seam`-unset rows (`seam-unconfigured`, the git row's recorded shape); the pty row checks the seam first (a doomed - allocation is declined before its params are stored — decline first, + allocation is declined before its params are stored (decline first, alloc never). Field checks precede the seam check. The unvanilla guards keep the git row's shapes: `channel-not-held` → inline request-level failure; `no-identity` → the spawned serving-path @@ -177,7 +178,7 @@ proved, re-landed as contract: - **The translation bridge** (the POC-3 §D.1 shape, two adapters around a duplex pair): SSH `data` → `Chunk::stdin` through a bounded mpsc (bound 64) feeding a framing task; SSH channel EOF → the - **zero-length stdin-chunk sentinel** — decided and verified against + **zero-length stdin-chunk sentinel**, decided and verified against alktty 0.8.1's `pump_client_to_backend`: a zero-length stdin chunk shuts the backend's stdin, identical in effect to `ControlMessage::Eof`, and rides the data path the SSH EOF already @@ -189,31 +190,32 @@ proved, re-landed as contract: - **The negotiation-error frame is a serving-path decline class** (decided + recorded): alktty re-runs validation/allocation/ownership inline even on the pre-negotiated path and answers failures with a - length-prefixed error frame (first byte `0x00` — alktty's ADR-052 §5 - disambiguation invariant). The bridge reads it via a 4-byte + length-prefixed error frame (first byte `0x00`, which is alktty's + ADR-052 §5 disambiguation invariant). The bridge reads it via a 4-byte first-window disambiguation and maps it to `negotiation-failed` + - **`exit-status(255)` + close — the one place this door claims 255**: + **`exit-status(255)` + close, the one place this door claims 255**: the session never started, nothing ran, so 255 is the honest could-not-start semantic (contrast the git row, whose service runs and honestly reports `1`; `255` is never fabricated for a real outcome). A bridge read ending **without** an exit chunk reports - nothing (sole-fidelity-input posture: absence is neither 0 nor 255) - — plain close. + nothing (sole-fidelity-input posture: absence is neither 0 nor 255), + and simply closes. - **The control rows**: `window_change_request` → `ControlMessage::Resize`, `signal` → `ControlMessage::Signal`, forwarded through the **per-channel `BridgeSurface`** (handler state while the row serves): a bounded mpsc whose `try_send` is - never awaited on the packet loop — a full/closed surface drops the - control event (fire-and-forget rows carry no SSH reply to decline + never awaited on the packet loop, so a full/closed surface drops + the control event (fire-and-forget rows carry no SSH reply to decline with; bounded resources, convention 9). **Signal names are accepted - verbatim — no decline shape**: the backend owns the rejection + verbatim, no decline shape**: the backend owns the rejection semantics, nothing on the wire can answer. A pre-serve resize - updates the stored pty params instead (the final size wins — - vanilla sshd's ordering). + updates the stored pty params instead (the final size wins, + matching vanilla sshd's ordering). - **The derivation contract** (mapping.md rules 1–6, in code + unit - rows + wire rows): `derive_exit` — `code ≥ 0` → `exit-status(N)`; + rows + wire rows): `derive_exit`, where `code ≥ 0` → + `exit-status(N)`; negative `−N`, N ≥ 2 → `exit-signal(name)` via the crate-local [`SIGNAL_NAMES`] signum→name table (1–31, server-side work, deliberately not alktty's `signal_from_name` inverse); `−1` → @@ -223,22 +225,22 @@ proved, re-landed as contract: `i32::MIN`'s checked-abs failure) → 255 (rule 6); the PTY-path rule-5 fidelity limit documented in the module docs. Report sent **before** close from the spawned task; names ride russh's `Sig` - (known variants verbatim, `Sig::Custom` for the rest — RFC 4254 + (known variants verbatim, `Sig::Custom` for the rest; RFC 4254 §6.10). - **Cancellation** (bounded resources + alktty's kill-on-drop): the - serving path watches two signals — the per-channel cancel (fired by + serving path watches two signals: the per-channel cancel (fired by `channel_close` via the surface's watch) and the connection teardown (fired by the handler's `Drop`; the russh session ended however it ended). On either: the serving task **aborts the alktty - session task**, dropping its not-yet-resolved `exit_code` future — - alktty's ADR-056 kill-on-drop contract is the cancelled session's - cleanup — then closes the channel and ends. Witnessed live by the + session task**, dropping its not-yet-resolved `exit_code` future, + letting alktty's ADR-056 kill-on-drop contract clean up the + cancelled session, then closes the channel and ends. Witnessed live by the shim's kill log row. - **The contract tests** (`tests/tty_mapping.rs`, session-gated; `tests/common/tty_doubles.rs`): the shim double (hand-rolled per - the `TtyHandle` field shapes — alktty's `MockBackend` is + the `TtyHandle` field shapes, because alktty's `MockBackend` is `pub(crate)` and never importable) records TtyParams, consumes stdin/EOF/resizes/signals, emits scripted chunks and exit codes, and carries the kill-on-drop witness; the ownership witness double @@ -250,20 +252,21 @@ proved, re-landed as contract: cap row fixed). `env_request` decline unchanged (OQ-09). - **The identity ride** (the parked wave-2 item, discharged): asserted - through alktty's ADR-050 ownership check — the shim's - `resource_id` guarantees the check evaluates, and the ownership + through alktty's ADR-050 ownership check, with the shim's + `resource_id` guaranteeing the check evaluates, and the ownership double records the resolved identity; the deny shape also pins the negotiation-error wire shape. - **Real-tool smoke posture (recorded)**: one OpenSSH `-tt` pty session (`ssh -tt … smoke-rider@127.0.0.1` with piped stdin) through a TCP-mounted adapter against a **real PTY via a dev-dep alktty - `local` composition** — the decided posture: a shim row cannot show + `local` composition**, the decided posture: a shim row cannot show what vanilla OpenSSH really sends, and POC-3 stage D proved exactly this composition. The dev-dep `features = ["local"]` unification applies to the test graph only (the published lib never enables it; `src/` never names a local shape). Requires ssh + sh; absent - tooling is skipped **with a printed reason** — never silently green. + tooling is skipped **with a printed reason**, never silently + green. The smoke pins the pty lifecycle (serve, stdin ride, clean exit → exit-status(0) before close); the `stty size`-grade fidelity matrix is wave 5's. @@ -273,4 +276,4 @@ proved, re-landed as contract: `cargo test --features tunnel`, `cargo test --all-features`; `cargo clippy --all-targets -- -D warnings` ×5 (default / session / git / tunnel / all-features); `cargo fmt --check`; `cargo doc - --no-deps` (default + session + all-features) — all green. \ No newline at end of file + --no-deps` (default + session + all-features); all green. \ No newline at end of file diff --git a/tasks/implementation/w3-tunnel-direct.md b/tasks/implementation/w3-tunnel-direct.md index 30322ff..9189954 100644 --- a/tasks/implementation/w3-tunnel-direct.md +++ b/tasks/implementation/w3-tunnel-direct.md @@ -1,6 +1,6 @@ --- id: w3-tunnel-direct -name: Wave 3 — tunnel leg 1 (the route-1 bridge — direct-tcpip + streamlocal) +name: "Wave 3: tunnel leg 1 (the route-1 bridge, direct-tcpip + streamlocal)" status: completed depends_on: [w3-tty-session] scope: moderate @@ -11,71 +11,72 @@ level: implementation ## Description -Land the tunnel branch's proven leg (ADR-006 decision 1) — the +Land the tunnel branch's proven leg (ADR-006 decision 1), placing the adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal` (POC-3 stage C's shape, re-landed as contract): -- **The route-1 bridge** — the `tunnel_row_stub` replaced with the +- **The route-1 bridge**, the `tunnel_row_stub` replaced with the real flow: per accepted open, the target/substrate are constructed from the SSH open arguments (`direct-tcpip`(host, port) → `SubstrateAddr::HostPort` + `Substrate::Tcp`; `direct-streamlocal` (socket path) → `SubstrateAddr::Path` + `Substrate::Unix`) and spawn the flow POC-3 proved: **`TunnelSession::open_direct(& channel_client, target, substrate)`** → **accept only after the - fabric open succeeds** (accept consumes the handle — the reject - shape is only possible pre-accept) → on fabric failure the explicit + fabric open succeeds** (accept consumes the handle, and the + reject shape is only possible pre-accept) → on fabric failure the explicit `ConnectFailed` reject (the stub's reason survives as the real decline; `tunnel_seam_stub_decline` retires into it) → on success `take_halves()` pumped against the taken channel's - stream (`pump_bidi` — the alkcall primitive the POC recipe used) on + stream (`pump_bidi`, the alkcall primitive the POC recipe used) on the spawned task, per spawn-and-return. The open hook returns fast with the fabric open in flight. -- **The cap admission across the fabric window** — the wave-2 stub +- **The cap admission across the fabric window**, where the wave-2 stub declined without consuming a count; the real flow admits. Mechanics to get right (record): the admission is taken at the hook (the - packet-loop single-writer site — no race window against the cap - check) and held across the fabric open; **a fabric failure must + packet-loop single-writer site, with no race window against the + cap check) and held across the fabric open; **a fabric failure must release the count inside the spawned task** (the `channel_close`-hook release point only fires for established - channels — a rejected open never established one), so a failed + channels, and a rejected open never established one), so a failed bridge cannot leak budget. Decide the release mechanism and record. - **Identity propagation to the family fabric (auth.md §Server - direction step 4 — the visible-surface invariant's gate)**: the + direction step 4, the visible-surface invariant's gate)**: the resolved identity rides the channels connection so the fabric's `AccessControl::check(peer_identity)` gates the mapped opens. The parked wave-2 `auth_succeeded` item lands here: on auth completion the handler propagates **once** via alkcall's `Connection:: - set_identity` — mechanics decided and recorded (the alkhub + set_identity`, with mechanics decided and recorded (the alkhub `hub_leg.rs` one-time-set precedent: `ChannelClient:: take_call_connection()` → `set_identity(identity)`; check the - `take` semantics against alkcall 0.10.1's docs — the relay note + `take` semantics against alkcall 0.10.1's docs, since the relay + note says the detach is wrong for hubs; verify a door's reading and hook placement, `auth_succeeded` vs the first seam call). -- **The runtime-Unix gate** (OQ-11 — universal compile, clean +- **The runtime-Unix gate** (OQ-11: universal compile, clean open-decline off-Unix): `direct-streamlocal` compiles wherever `tunnel` compiles; off-Unix a clean open-decline **before any fabric call** (never a partial bridge); on-Unix the real bridge. - **Contract rows through the harness, per build** (the wave-2 gate's - booked acceptor vocabulary note is the listen leg's — this task's - rows ride plain client opens): a git/session build with `tunnel` + booked acceptor vocabulary note is the listen leg's, so this + task's rows ride plain client opens): a git/session build with `tunnel` off declines the tunnel opens (unfeatured, `AdministrativelyProhibited`); the featured rows: a configured `tunnel` handle (feature + `Arc< ChannelClient>`) serves, an **absent handle declined with a clean error even under the feature** (the unchanged unset-declines - posture — the wave-1 config note), the cap rows (cap-gated before - spawn — never a leaked count over a failed bridge: the release - mechanism pinning row), and the `ConnectFailed` row over a refusing + posture, the wave-1 config note), the cap rows (cap-gated before + spawn, never a leaked count over a failed bridge, meaning the + release mechanism pinning row), and the `ConnectFailed` row over a refusing fabric double. The **fabric double** is the POC-3 §C.4 in-process recipe as test composition (ChannelsAdapter + InstallChannelZero + - the alktunnels openables + a hand-rolled dial/dial-fail seam) — + the alktunnels openables + a hand-rolled dial/dial-fail seam), hand-rolled in `tests/`, never a sibling-checkout path. - **The optional dep lands here** (`alktunnels = "0.4.1"`, wired - `tunnel = ["dep:alktunnels"]` — first consuming code; crates.io pin - re-verified before committing). -- **The real-tool smoke** — one `ssh -L` (+ curl) through the + `tunnel = ["dep:alktunnels"]` (first consuming code; the crates.io + pin re-verified before committing). +- **The real-tool smoke**: one `ssh -L` (+ curl) through the harness against the fabric double (tooling posture decided like the - git task — required vs conditional-skip, recorded). + git task, either required or conditional-skip, recorded). ## Acceptance Criteria @@ -84,8 +85,8 @@ adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal` explicit `ConnectFailed` reject, the stub retired; the open hook returns fast - [x] The cap admission across the fabric window pinned (admit at the - hook, release on fabric failure inside the spawned task — no - leaked count, no double-admit) + hook, release on fabric failure inside the spawned task, with + no leaked count and no double-admit) - [x] The identity propagates to the channels connection once (`set_identity` mechanics + hook placement decided and recorded); the wave-2 `auth_succeeded` item discharged @@ -105,7 +106,7 @@ adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal` - docs/architecture/mapping.md (the `direct-tcpip` / `direct- streamlocal` channel-type rows; §the structural constraint) - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - (decision 1 — route 1; the reject shape; the wiring recipe pointer) + (decision 1, route 1; the reject shape; the wiring recipe pointer) - docs/architecture/decisions/003-feature-graph.md (the tunnel row, the OQ-11 runtime-Unix gate) - docs/architecture/auth.md §Server direction step 4 (set_identity, @@ -126,26 +127,26 @@ adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal` > the module docs (`src/tunnel_map.rs`, `src/channel_cap.rs`, > `src/handler.rs`) and are summarized here for the gate. -- **The cap release mechanism (the task's first decide-and-record)** — +- **The cap release mechanism (the task's first decide-and-record)**: `ChannelCap` became a `parking_lot::Mutex` shared as an `Arc`: **admissions stay packet-loop-only** (every `admit` call site - is an open hook — the check-and-increment is one atomic section + is an open hook, so the check-and-increment is one atomic section against the shared writer), while **releases have exactly two - disjoint sites** — the `channel_close` hook for *established* + disjoint sites**, the `channel_close` hook for *established* channels, and *inside the spawned bridge task* on a fabric-open failure (the admitted-but-rejected shape: ADR-006 decision 1 rejects the open, so no `CHANNEL_CLOSE` can ever cycle back, and the count - must be released where the failure is known — before the reject is + must be released where the failure is known, before the reject is answered, so the very next open observes the recovered budget). Both release sites saturate. Pinning rows: `a_failed_bridge_releases_the_cap_count_before_the_next_open` - (failure-then-serve under `max_concurrent_channels: 1` — a leaked + (failure-then-serve under `max_concurrent_channels: 1`, where a leaked count would answer `AdministrativelyProhibited`) and `the_cap_refuses_beyond_the_ceiling_and_recovers_on_close` (refusal + close-hook recovery). - **`set_identity` mechanics (the task's second decide-and-record; the - `w2-auth-bridge` parked item discharged)** — the alkhub `hub_leg.rs` + `w2-auth-bridge` parked item discharged)**, the alkhub `hub_leg.rs` one-time-set precedent is a **serving-side** set (the hub resolves the peer identity and sets it on its accept-side channel-0 `Connection` inside the channel-0 install, before any dispatch). @@ -154,45 +155,46 @@ adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal` `CallConnection::connection()` → `Connection::set_identity`) was **checked and rejected for this door**, verified against alkcall 0.10.1's docs: the take is a one-shot single-slot detach - ("taking it detaches the client's own calling surface" — every later + ("taking it detaches the client's own calling surface", so every + later `call_open_op`, and with it every `TunnelSession::open_direct`, errors "channel client closed"), and the slot is the **shared assembly handle** (`SshAdapter` hands one `Arc` to every mounted session), so the first session's take would detach the open surface for every later session of the whole serving - direction — the relay note's "the detach is wrong for hubs" + direction, and the relay note's "the detach is wrong for hubs" applies a fortiori. The identity that alkcall's protocol consults at the fabric's serving dispatch is the channels connection's **dial identity** (CF-005: set once at dial, propagated to channel - 0 at `from_connection`) — identity crossing the alkcall wire is not - a protocol concept. Decision: the composition — which identity the + 0 at `from_connection`), since identity crossing the alkcall wire + is not a protocol concept. Decision: the composition, meaning which identity the fabric leg dials with, against which the fabric's ACL gates - alkssh's mapped opens — is **assembly policy** (the assembly + alkssh's mapped opens, is **assembly policy** (the assembly supplies both the injected `IdentityProvider` and the fabric leg); alkssh structurally refuses anonymous serving (the `no-identity` - open guard, unreachable on the wire — russh authorizes before - channels exist — fail-closed like the git/tty rows) so no + open guard, unreachable on the wire since russh authorizes before + channels exist, fail-closed like the git/tty rows) so no SSH-shaped exception to the visible-surface invariant exists, and the harness rows witness the composed gate end to end: `the_fabric_acl_refuses_an_unscoped_channels_identity` (the fabric's `AccessControl::check` refusing a scopeless channels - identity — the establisher never dialed) and + identity, the establisher never dialed) and `direct_tcpip_serves_through_the_route_1_bridge`'s identity-witness assertion (the establisher's per-call auth seeing the channels - identity — POC-3 §C.3). **Hook placement**: the identity store + identity, POC-3 §C.3). **Hook placement**: the identity store stays the `auth_publickey` ride-along in handler state; the tunnel rows' dispatch reads it; `auth_succeeded` stays - unimplemented (russh default) — nothing rides it that the store - does not carry. **Upstream ask booked for the wave-3 gate**: a + unimplemented (russh default), since nothing rides it that the + store does not carry. **Upstream ask booked for the wave-3 gate**: a non-detaching post-construction identity seam on `ChannelClient` (or per-session fabric legs) would let the door attach per-peer identities to a shared leg; nothing is silently absorbed. -- **Wire decline classes (recorded)** — two vanilla-observable classes - at the open: *policy refusals* → `AdministrativelyProhibited` +- **Wire decline classes (recorded)**, two vanilla-observable + classes at the open: *policy refusals* → `AdministrativelyProhibited` (unfeatured, cap-exceeded, the `no-identity` guard) vs *cannot-bridge* → `ConnectFailed` - ([`dispatch::connect_failed_open_decline`] — the wave-2 stub's + ([`dispatch::connect_failed_open_decline`], the wave-2 stub's reason surviving as the real decline): the failed fabric open, the absent fabric handle under the feature (`seam-unconfigured`, the unset-declines posture), an impossible wire target @@ -201,30 +203,32 @@ adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal` are distinguishable in the event vocabulary, which gained `invalid-target` and `unix-unavailable`. -- **Admission mechanics** — the pre-admit decline classes are checked +- **Admission mechanics**, so the pre-admit decline classes are + checked in order (unset handle → no-identity → invalid target), so every pure-decline path consumes nothing; the admission is taken at the hook and held across the fabric open; the success release rides the established channel's close. `forwarded-tcpip` kept its - pre-admit `has_room` cap gate and stub — its `-R` data-leg flow is - `w3-tunnel-listen` material (the harness's russh client cannot + pre-admit `has_room` cap gate and stub, as its `-R` data-leg flow + is `w3-tunnel-listen` material (the harness's russh client cannot initiate the type; its wire rows are acceptor vocabulary). -- **The fabric double** — the POC-3 §C.4 in-process recipe as test +- **The fabric double**, the POC-3 §C.4 in-process recipe as test composition (`wire_fabric` in `tests/tunnel_mapping.rs`): `ChannelsAdapter` + `InstallChannelZero` + `ChannelOperations` + `register_tunnel_direct_openable` + `Dispatcher::serve_single_stream` over a tokio duplex, the connect side the `Arc` the serve-config holds. The hand-rolled dial/dial-fail seam is an in-process map of duplex halves keyed by the establisher-rendered - target string (consume-once — the cap-recovery row reads the + target string (consume-once, and the cap-recovery row reads the consume-once dial as its release signal), with an atomics call counter for the "never called" assertions (the ACL row and the OQ-11 row); the smoke's dial seam is a real-TCP closure (alktunnels' - `local` feature stays off everywhere — the shipped crate and the - tests). + `local` feature stays off everywhere, in both the shipped crate + and the tests). -- **The real-tool smoke** — tooling posture decided like the git task +- **The real-tool smoke**, with tooling posture decided like the git + task (conditional skip with a printed reason when `ssh`/`curl` is absent), and it ran green here: one `ssh -N -L` (OpenSSH client) through a TCP-mounted adapter → the route-1 bridge → the fabric diff --git a/tasks/implementation/w3-tunnel-listen.md b/tasks/implementation/w3-tunnel-listen.md index b5a8e9d..be4786d 100644 --- a/tasks/implementation/w3-tunnel-listen.md +++ b/tasks/implementation/w3-tunnel-listen.md @@ -1,6 +1,6 @@ --- id: w3-tunnel-listen -name: Wave 3 — tunnel leg 2 (the -R listen flow + the forwarded-tcpip data leg) +name: "Wave 3: tunnel leg 2 (the -R listen flow + the forwarded-tcpip data leg)" status: completed depends_on: [w3-tunnel-direct] scope: moderate @@ -12,18 +12,18 @@ level: implementation ## Description Land the `-R` leg's server direction (mapping.md §Global-request rows + -the `forwarded-tcpip` channel-type row) — the one leg the POC only +the `forwarded-tcpip` channel-type row), and it is the one leg the POC only exercised as refusal ("only the refusal shape was exercised"), so this task carries the wave-3 gate's biggest open mechanics. Shape per the family door semantics (vanilla -R: the door's fabric listens, per arrival the data flows back to the vanilla client, which dials its own target): -- **The listen request rows** — `tcpip_forward` / `streamlocal_forward` +- **The listen request rows**: `tcpip_forward` / `streamlocal_forward` (and their cancel forms) go from wave-2's unconditional decline to the real flow under `tunnel` (+ the streamlocal runtime-Unix gate riding its serving flow, OQ-11): the listen is mapped **family-ward** - through the alktunnels listen/forwarded shapes — the door owns + through the alktunnels listen/forwarded shapes, and the door owns nothing local. The **exact composition is decided here** against alktunnels 0.4.1's actual producer/consumer surfaces (`TunnelListener:: open`'s listen dial + reply fields; the minted listen token in @@ -36,52 +36,52 @@ target): door-side acceptance of the fabric's inbound forwarded opens (alkcall channel-manager openable registration on the door-side connection) or the serve-config's tunnel handle must grow a - registration-seam handle — **if the door-side handle shape must be + registration-seam handle; **if the door-side handle shape must be amended, amend it here** (the `w3-seam-config` shape, recorded as this wave's second config course-correction). If the topology does not close over the published alkcall 0.10.1 / alktunnels 0.4.1 - surfaces, **stop and book it** for the gate — never silently + surfaces, **stop and book it** for the gate, never silently forked, worked around in-family, or re-litigating ADR-006's resolution. -- **The `forwarded-tcpip` data leg** (mapping.md's row — "hand the +- **The `forwarded-tcpip` data leg** (mapping.md's row, "hand the channel duplex to the handler"): per fabric arrival, the door opens a `forwarded-tcpip` channel **toward the vanilla client** with the SSH message's fields mapped per the vanilla -R convention (`host_to_connect`/`port` echoing the target args the original - `tcpip_forward` request carried — recorded per pending listen; - originator fields carrying the arrival info — decide and record + `tcpip_forward` request carried, recorded per pending listen; + originator fields carrying the arrival info, decide and record what `TunnelForwardedParams.peer` yields) → accept-or-reject per the in-crate cap (the **acceptor vocabulary** the wave-2 gate - booked — the first shape of what wave 4 grows into the client-side + booked, the first shape of what wave 4 grows into the client-side acceptor registry) → pump the fabric `BiStream` ↔ the SSH channel (`ForwardedHandler` ownership: the handler owns the pump per alktunnels' shape; correlation-miss → teardown-at-birth, never a half-open SSH channel). -- **Cancel forms + bounded bookkeeping** — a per-session listener map +- **Cancel forms + bounded bookkeeping**, with a per-session listener map (the bound-port handles) drives `cancel_tcpip_forward` / `cancel_streamlocal_forward` 1:1 into the family listener's teardown (`TunnelListener::close` posture); the listener count is a bounded resource (decide the bound relation to - `Limits::max_concurrent_channels` or a dedicated cap — record; no + `Limits::max_concurrent_channels` or a dedicated cap, recorded; no unbounded map). The bound-port out-param: `port = 0` → the actually-bound number returned through the `&mut u32` (the family-side listen reply's numbers; the mapping.md/client.md lifecycle spec). - **Identity**: the listen dial rides the propagated identity (the fabric's `AccessControl` gates the listen + forwarded opens the - same as leg 1's — the visible-surface invariant across the whole - listen flow). -- **Contract rows through the harness, per build** — the wave-2 stub - rows replaced: featured + configured serve (the listen flow against + same as leg 1's, giving the visible-surface invariant across the + whole listen flow). +- **Contract rows through the harness, per build**, with the wave-2 + stub rows replaced: featured + configured serve (the listen flow against the fabric double); unfeatured/unset/streamlocal-off-Unix decline cleanly (honest advertisement: a declined listen never left a half state); the cancel rows; the arrival → forwarded-tcpip → pump row; cap rows at the accept point. The **harness grows the client-side accept hook** (russh client - `server_channel_open_forwarded_tcpip` — the server-initiated opens + `server_channel_open_forwarded_tcpip`, the server-initiated opens the contract rows observe; the wave-2 note said exactly this). - **The real-tool smoke** posture decided + recorded (a real `ssh -R` - through the harness's TCP loopback if tooling permits — else the + through the harness's TCP loopback if tooling permits; else the harness rows carry wave 3 and the real-tool `-R` smoke is named wave-5 e2e territory explicitly; never silently dropped). @@ -93,7 +93,7 @@ target): - [x] The family-ward listen composition decided against alktunnels 0.4.1's actual surfaces and recorded; the door-side inbound-open mechanics closed (or a serve-config amendment landed + recorded; - or stopped-and-booked for the gate — nothing silent) + or stopped-and-booked for the gate, nothing silent) - [x] The `forwarded-tcpip` data leg serves per the handler shape (target-arg echo per pending listen, cap-gated acceptor, the pump, teardown-at-birth on correlation-miss) @@ -112,15 +112,15 @@ target): - docs/architecture/mapping.md §Channel-type rows (`forwarded-tcpip` row), §Global-request rows (the `-R` listen flow, the POC-3 note) - docs/architecture/client.md §Op rows (the `-R` leg, the acceptor - framing — the client mirror this leg shares vocabulary with), + framing, the client mirror this leg shares vocabulary with), §Lifecycle primitives (the bound-port spec) - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - (decision 1 — the fabric wiring recipe), docs/architecture/ + (decision 1, the fabric wiring recipe), docs/architecture/ decisions/003-feature-graph.md (the tunnel row + §4 the client-side acceptor symmetry) - docs/research/poc-3-mapping-bakeoff.md §Stage C (the recipe; §C.2's refusal shape this task goes past), OQ seed 4 -- docs/research/reverse-runner.md (the `-R` row — the client-side +- docs/research/reverse-runner.md (the `-R` row, the client-side mirror of the same wire vocabulary) - alktunnels 0.4.1: `producer.rs:718-772` (`ForwardedOpener`, `make_bindfirst_accept_handler`), `producer.rs:982-1082` @@ -142,7 +142,7 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in - **The composition decisions (recorded in `src/tunnel_map.rs`'s module doc + `mapping.md` §Global-request rows):** - - *Who dials `tunnel/forwarded`*: the fabric (not the door) — + - *Who dials `tunnel/forwarded`*: the fabric (not the door); alktunnels' bind-first accept dispatcher (`make_bindfirst_accept_handler` + the `ForwardedOpener` closure) dials the op on the channel-0 call plane toward the listen opener @@ -151,10 +151,10 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in directions collision-free: the door allocates odd as the connect side, the fabric even as the accept side). - *The door-side inbound-open mechanics*: closed over the published - surfaces — the `Arc` alone cannot carry them (a + surfaces, as the `Arc` alone cannot carry them (a client serves inbound ops on its channel-0 loop only when built with `from_connection_with_serving` against an `OperationRegistry` - the assembly owns; no post-construction way in) — so the + the assembly owns; no post-construction way in), so the serve-config's tunnel handle gained `tunnel_listen_seam` (`TunnelListenSeam`, the wave-3 seam-config's second course-correction): the same `Arc` the assembly @@ -162,7 +162,7 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in forwarded openable itself (`register_tunnel_forwarded_openable` over a `ChannelCore` wrapping a clone of the held client's manager, alkcall's secure default per-identity policy) at - `SshAdapter::new` — a registration failure is a failed + `SshAdapter::new`, where a registration failure is a failed construction (`AdapterError::ListenRegistration`), never a half-configured adapter. A scratch probe (published crates only, no `alktunnels/local`) proved the topology end to end before any @@ -173,10 +173,10 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in - *The resource-naming convention (the flow's one open surface)*: the vanilla wire bind endpoint rides family-ward as the resource string verbatim (`"localhost:9200"` / `"0.0.0.0:9200"` / the - socket path) — the OpenSSH client normalizes the no-address case + socket path); the OpenSSH client normalizes the no-address case to `"localhost"` before sending (sshd `channel_rfwd_bind_host` parity), the door passes through, and the fabric's - establisher/bind closure (assembly policy — the dynamic-address + establisher/bind closure (assembly policy, the dynamic-address producer opt-in the door's convention fronts) treats it as the bind endpoint. ADR-001's "resource is not an address" discipline stays a producer-side naming rule; this is a recorded door-to- @@ -185,34 +185,34 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in as refusal): per arrival → token correlation through the shared `ListenRouter` (one route serves unboundedly many arrivals; removed only at the cancel forms/session teardown) → the in-crate - per-session channel cap admits at the accept surface — the acceptor + per-session channel cap admits at the accept surface, the acceptor vocabulary's first shape, the wave-2 gate's booked item now discharged (never-established arrival paths release inside the arrival pump; established closes release through the packet loop's `channel_close` as ever) → `Handle::channel_open_forwarded_tcpip` echoing the listen request's own args: the wire address string verbatim + the actually-bound port (sshd `c->path` + - `get_local_port` parity — the OpenSSH client matches that exact - string + port and dials its own target), the + `get_local_port` parity, since the OpenSSH client matches that + exact string + port and dials its own target), the `TunnelForwardedParams.peer` arrival mapped to the originator fields (HostPort → address/port; Path → path + 0, degenerate-but-typed) → `pump_bidi(ssh, fabric)` (half-close semantics: EOF-shutdown in the completed direction; the vanilla - client closes its half when its target socket dies — the row's - stand-in — and the cap release follows through the normal close). + client closes its half when its target socket dies (the row's + stand-in), and the cap release follows through the normal close). Correlation-miss → teardown-at-birth (drop the fabric half; the - open op's channel reaps — never a half-open SSH channel). Unix + open op's channel reaps, never a half-open SSH channel). Unix arrivals (`streamlocal_forward` mints) open `forwarded-streamlocal@openssh.com` with the path echoed (russh's server `Handle` carries that channel-open at this pin; the client - hook exists) — the "no russh hook" concern in the wave-2 stub notes + hook exists); the "no russh hook" concern in the wave-2 stub notes was about the door's *inbound* handlers of that type, not the server-initiated direction. - **Bounded bookkeeping** (convention 9): the per-session listener map is packet-loop-only (like the `channels`/`ptys` stores); its bound is a dedicated counter against the same - `max_concurrent_channels` ceiling — a listen is at least one future - channel's potential; the listener/channels mix stays unconstrained + `max_concurrent_channels` ceiling, since a listen is at least one + future channel's potential; the listener/channels mix stays unconstrained while both stay bounded (no unbound listener map). The route map is bounded by construction: entries minted exactly at accepted listens, removed 1:1 at the cancels and at the owning session's teardown (the @@ -220,8 +220,8 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in failed open → teardown-at-birth. `cancel` of an unknown (endpoint, bound-port) pair answers `false` (sshd cancel-of-unknown parity). Admissions have two sites and releases - three since this task — the cap's recorded single-writer reasoning - updated in `channel_cap.rs` (mutex-guarded, saturating, both). + three since this task, and the cap's recorded single-writer + reasoning updated in `channel_cap.rs` (mutex-guarded, saturating, both). - **The real-tool smoke**: implemented and green here (tooling check-gated like the -L smoke's posture): a real `ssh -R 127.0.0.1:PORT:HTTP` (OpenSSH client) through the TCP-mounted @@ -231,8 +231,8 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in - **The decline matrix** per build, all through the harness with the client-side accept hook in place (the harness handler's `server_channel_open_forwarded_tcpip` / - `server_channel_open_forwarded_streamlocal`, acceptor-driven — no - acceptor → clean decline, the family's client-side contract): the + `server_channel_open_forwarded_streamlocal`, acceptor-driven, so + no acceptor means a clean decline, the family's client-side contract): the wave-2 stub listen rows migrated (`tcpip_forward_decline_emits_*` split per build: `unfeatured` vs `seam-unconfigured`); the featured + configured rows serve/arrive/cancel (incl. the port-0 @@ -243,11 +243,11 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in rows (the fabric-scope gate on the listen dial, the door-side ACL gate on arrivals); the OQ-11 off-Unix rows; the correlation-miss row; the unvanilla inbound `forwarded-tcpip` open declined - (event-level contract — russh's client has no arbitrary-type open - to drive that row on the wire). + (event-level contract, as russh's client has no arbitrary-type + open to drive that row on the wire). - **`tests/common` grew**: the forwarding acceptor (`ForwardedAcceptor` + `ForwardedOpen` + `drive_with_acceptor`) - and a minimal hand-rolled tracing subscriber (`trace_global` — the + and a minimal hand-rolled tracing subscriber (`trace_global`, the family posture: no `tracing-subscriber` dependency; opted in per row) as the leg-2 rows' failure-triage vehicle. - **Verification (all green)**: `cargo test` across default / diff --git a/tasks/implementation/w4-channel-not-held-rows.md b/tasks/implementation/w4-channel-not-held-rows.md index ab79ed7..f395c2f 100644 --- a/tasks/implementation/w4-channel-not-held-rows.md +++ b/tasks/implementation/w4-channel-not-held-rows.md @@ -1,6 +1,6 @@ --- id: w4-channel-not-held-rows -name: Wave 4 — channel-not-held guard wire rows (wave-3 gate booked item 2) +name: "Wave 4: channel-not-held guard wire rows (wave-3 gate booked item 2)" status: completed depends_on: [w4-git-exit-close] scope: narrow @@ -14,25 +14,27 @@ level: implementation Close the wave-3 gate's booked finding item 2 (`review-wave-3` §Booked findings): the **`channel-not-held` guard lacks wire test rows**. The guard is a documented decline class on -all three rows (git / tty pipe / shell) — a second exec (or request) -on an already-served channel — constructible on the wire, but no row +all three rows (git / tty pipe / shell): a second exec (or request) +on an already-served channel, constructible on the wire, but no row asserts it in any build. `mapping.md`'s every-row-tested-in-every-build convention names it contract territory; the fail-closed invariant is contract on both sides of every feature (AGENTS.md convention 5). -**Test-only task** — no production code change expected (if landing +**Test-only task**, so no production code change is expected (if +landing the rows exposes a guard gap, stop and book it for the gate rather than silently widening scope): - Wire rows through the harness, **per build** (the per-build cfg-pinned module convention): a second exec on a served git channel → the channel-not-held decline; the same on the tty pipe - row and the shell row — the decline class asserted in every build + row and the shell row, with the decline class asserted in every + build that carries the row (featured builds carry the serving rows; the decline-class rows ride the same per-build matrix the dispatch tests use). - The rows observe the decline's wire shape + the tracing event - vocabulary (the `w3-decline-tracing` class/reason fields — the + vocabulary (the `w3-decline-tracing` class/reason fields, and the rows double as the vocabulary's coverage for this class). - Depends on `w4-git-exit-close` only to keep `tests/git_mapping.rs` single-writer (the hardening lands first; these rows land into the @@ -43,7 +45,7 @@ than silently widening scope): - [x] Wire rows for the channel-not-held decline class on all three rows (git / tty pipe / shell), per build, through the harness - [x] The rows assert the wire shape + the tracing event class/reason -- [x] No production code change (no guard gap exposed — nothing to +- [x] No production code change (no guard gap exposed, so nothing to book; the guard's shape held on the wire in every featured build) - [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2 @@ -52,7 +54,7 @@ than silently widening scope): ## References - tasks/implementation/review-wave-3.md §Booked findings item 2 -- docs/architecture/mapping.md (the request-level table — the +- docs/architecture/mapping.md (the request-level table, the channel-not-held class; the every-row-tested convention) - tests/git_mapping.rs, tests/tty_mapping.rs, tests/dispatch_core.rs (the per-build matrix convention the rows join), src/observe.rs @@ -60,39 +62,40 @@ than silently widening scope): ## Notes -- **Test-only, as planned** — no production change; no guard gap +- **Test-only, as planned**, hence no production change; no guard + gap surfaced (the landing verified the guard's documented shape on the wire in every build that carries each row). - **Rows landed** (one per served row, each with the inline-decline - wire shape — `channel_failure` while the first serving path rides - on — plus the channel-stays-usable witness and the tracing event's + wire shape (`channel_failure` while the first serving path rides + on), plus the channel-stays-usable witness and the tracing event's exact request-decline vocabulary `src/observe.rs` carries): - `tests/git_mapping.rs` (cfg `git`): a second exec on a served git - channel — the decline event carries `exec_route = "git"`, and the + channel, whose decline event carries `exec_route = "git"`, and the declined re-exec never spawns a second serving path (the doubles' resolve count stays at one). Race-free by construction: the advertisement drains first and the serve parks at the doubles' gated `list_refs` behind it, so the wire is quiescent for the decline. - `tests/tty_mapping.rs` (cfg `session`), two rows: - the pipe row (second exec on a served pipe channel — event - carries `exec_route = "pipe"`) and the shell row (second shell - request on a served shell channel — the event carries **no** + the pipe row (second exec on a served pipe channel, with the event + carrying `exec_route = "pipe"`) and the shell row (second shell + request on a served shell channel, whose event carries **no** `exec_route` field, pinning the absent-field shape on the wire). The exit-`None` shim script keeps the serve live and the wire quiescent. -- **The per-build matrix**: the rows join the cfg-pinned convention — +- **The per-build matrix**: the rows join the cfg-pinned convention, git-build rows run in every build carrying `git`, session rows in every build carrying `session` (verified across default / `git` / `session` / `session,git` / `tunnel` / `sftp` / `--all-features`). -- **Shared capture helper**: `tests/common/event_capture.rs` (new) — +- **Shared capture helper**: `tests/common/event_capture.rs` (new) with the observe-suite's recorded hand-rolled global-subscriber convention shared across the mapping binaries; `await_event` field-matching (parallel rows never order against each other) and - the exact-field-set assertion (`assert_request_decline`) — the + the exact-field-set assertion (`assert_request_decline`), and the rows double as this class's vocabulary coverage, including the `debug` level and the build self-description. -- **No `dispatch_core.rs` rows added** — that binary builds no seam, +- **No `dispatch_core.rs` rows added**, because that binary builds no seam, so its git row declines at the config gate *before* the store-take; the guard is unreachable there (the reference names the per-build convention the rows join, which the mapping binaries carry). @@ -102,12 +105,13 @@ than silently widening scope): The channel-not-held guard's wire rows landed on all three served rows: `second_exec_on_a_served_git_channel_declines_channel_not_held` (`tests/git_mapping.rs`), and the pipe/shell pair -(`tests/tty_mapping.rs`) — each pinning the inline decline shape +(`tests/tty_mapping.rs`), each pinning the inline decline shape (`channel_failure`, no close, the first serve rides on, the channel stays usable, no second serving path) plus the decline event's exact vocabulary (class `request`, reason `channel-not-held`, route `git`/`pipe`/absent, `debug` level, self-describing build). No -production change was needed — the guard held its documented shape in +production change was needed, and the guard held its documented +shape in every featured build. `tests/common/event_capture.rs` shares the observe-suite's capture convention across the mapping binaries. Verified: full suite + per-feature builds (default, `git`, `session`, diff --git a/tasks/implementation/w4-dial.md b/tasks/implementation/w4-dial.md index 34021dc..2a881f2 100644 --- a/tasks/implementation/w4-dial.md +++ b/tasks/implementation/w4-dial.md @@ -1,6 +1,6 @@ --- id: w4-dial -name: Wave 4 — the client dial surface (russh client stack + the connection handle) +name: "Wave 4: the client dial surface (russh client stack + the connection handle)" status: completed depends_on: [review-wave-3] scope: moderate @@ -11,49 +11,52 @@ level: implementation ## Description -Land the client direction's substrate — the door outward +Land the client direction's substrate, the door facing outward (`client.md` §The dial surface): the russh client stack every wave-4 op row composes into, mirroring `w2-server-mount`'s role for the server direction. Everything later in the wave rides the authenticated connection handle this task lands. -- **The dial surface** — `connect`-shaped entry taking the wave-1 +- **The dial surface**: a `connect`-shaped entry taking the wave-1 [`DialConfig`](the address, the key source, the host-key policy, the `Limits`): the russh client `Config` built from the bounded knobs - (keepalive 15 s / 3 default posture per the reverse-runner evidence — - `client.md` §Correctness facts; inactivity; window/packet knobs per + (keepalive 15 s / 3 default posture per the reverse-runner evidence + and `client.md` §Correctness facts; inactivity; window/packet knobs per `overview.md`'s table), the dial target as `DialConfig.address` carries it. If the dial surface needs a `DialConfig` amendment, land + record it here (the `w3-seam-config` course-correction - pattern) — the wave-1 shape is expected to hold; decide and record. -- **Key source decrypt-at-dial** (ADR-005) — the [`KeySource`] load + pattern), while the wave-1 shape is expected to hold; decide and + record. +- **Key source decrypt-at-dial** (ADR-005), with the [`KeySource`] + load happens at dial time; locked / absent / decryption-failure / unknown-`key_version` are all **no-dial outcomes** with their distinct `KeySourceError` variants surfaced (never env vars, never plaintext at rest; the adapter-side `Zeroizing` wrap per OQ-07 - already landed in wave 1 — consumed here). -- **Host-key policy at `check_server_key`** (OQ-05) — the wave-1 + already landed in wave 1, consumed here). +- **Host-key policy at `check_server_key`** (OQ-05), the wave-1 [`HostKeyPolicy`] seam consumed at russh's client hook (the verdict type was pinned against this hook shape in `w1-host-key-policy`): the crate default ([`RejectAll`]) connects to nothing; case-1 pins hard-decline on mismatch; case-2 assembly policies decide. The - crate is policy-blind — no accept-all built-in. -- **The client Handler skeleton at fail-closed defaults** — the + crate is policy-blind, with no accept-all built-in. +- **The client Handler skeleton at fail-closed defaults**: the russh `client::Handler` impl the dial constructs: server-initiated channel opens (`server_channel_open_forwarded_tcpip` / `_forwarded_streamlocal` / `_unknown` / x11 / agent) **declined - cleanly** — never russh's accept-and-drop default (ADR-003 §4, the + cleanly**, never russh's accept-and-drop default (ADR-003 §4, the client-side symmetry rule; the acceptor registry that serves them is `w4-reverse-listen`'s surface, this task pins the default). -- **The authenticated connection handle** — the type everything else +- **The authenticated connection handle**, the type everything else rides: the russh `client::Handle` wrapped with the crate's surface - (publickey-only authentication from the key source — the family + (publickey-only authentication from the key source, the family contract; no password/keyboard-interactive paths), the policy and limits carried, the lifecycle primitives' raw material (full cancellation/clean-close shape is `w4-lifecycle`'s). -- **Test vehicle** — the client direction dials *vanilla* servers, so +- **Test vehicle**, because the client direction dials *vanilla* + servers, so the loopback harness (alkssh server) is not the vehicle: land an - in-process russh **server double** (a fake `sshd` — hand-rolled + in-process russh **server double** (a fake `sshd`: a hand-rolled `russh::server` handler in `tests/common`) as the contract-test vehicle, decide + record its placement and shape. Contract rows: dial success (authenticated), each key-source failure posture @@ -62,7 +65,7 @@ connection handle this task lands. wiring observable, server-initiated open declined cleanly. - **The smoke posture** decided + recorded (an alkssh-client ↔ alkssh-server in-process loopback is the cheap real-stack smoke; - a real-`sshd` dial is wave-5 e2e territory — never silently + a real-`sshd` dial is wave-5 e2e territory, never silently dropped). ## Acceptance Criteria @@ -92,10 +95,11 @@ connection handle this task lands. surface), docs/architecture/decisions/003-feature-graph.md (§4 the client-side fail-closed acceptor default this skeleton pins), docs/architecture/decisions/005-key-sources.md (decrypt-at-dial) -- docs/research/russh.md §Client surface (`client::Handler` inventory - — `check_server_key` defaults to refuse; the +- docs/research/russh.md §Client surface (`client::Handler` inventory, + where `check_server_key` defaults to refuse; the `server_channel_open_*` hook set; `client::Config` knobs), - docs/research/reverse-runner.md (lessons 3/5 — keepalives + docs/research/reverse-runner.md (lessons 3/5, with the + keepalives load-bearing, the ephemeral-fleet policy posture) - docs/research/poc-1-auth-bridge.md §6/§7 (the client-side plumbing facts), docs/research/poc-2-vault-keys.md (the dial lifecycle) @@ -106,19 +110,19 @@ connection handle this task lands. ### What landed (2026-10-29) -- `src/client.rs`: the dial surface — `dial(DialConfig)` over +- `src/client.rs`: the dial surface, `dial(DialConfig)` over [`DialConfig`], the four-step sequence (key load → bounded russh `Config` → policy at `check_server_key` → single publickey auth), [`SshConnection`] (the connection handle: `limits()` - / `address()` / `username()` / `is_closed()` / `close()` — the + / `address()` / `username()` / `is_closed()` / `close()`, the OQ-12 raw material; the full lifecycle surface is `w4-lifecycle`'s), the address parse (`host:port` + the bracket form; - `DialError::Address` on refusal — dial-time, the family's + `DialError::Address` on refusal (dial-time, matching the family's no-construction-validation shape), and the doc-hidden `russh_client_config` mapping (the bounded-config observable). - `src/client_handler.rs`: the client Handler skeleton (the mirror of `handler.rs`'s server-direction handler; a separate - module because `w4-op-rows` / `w4-reverse-listen` grow it) — + module because `w4-op-rows` / `w4-reverse-listen` grow it), with `check_server_key` wired to the injected policy (certificates fingerprint their inner key; the verdict passes through), every `server_channel_open_*` hook overridden @@ -126,16 +130,16 @@ connection handle this task lands. `ChannelOpenFailure::AdministrativelyProhibited` reject (never russh's accept-and-drop default), `should_accept_unknown_server_channel` the explicit `false`, decline events on the crate vocabulary. -- `src/error.rs`: [`DialError`] — the dial direction's closed +- `src/error.rs`: [`DialError`], the dial direction's closed taxonomy (`KeySource` per-variant preserved / `Address` / `HostKeyRejected` / `AuthRejected` / `Wire`), with a manual - `From` that upgrades russh's `UnknownKey` — the only - producer of which is the `check_server_key` decline path — into + `From` that upgrades russh's `UnknownKey` (the only + producer of which is the `check_server_key` decline path) into `HostKeyRejected`; hence the `Wire` variant carries no `#[from]` (one conversion, one place). - `src/config.rs`: the **DialConfig amendment** (below) + doc updates; `src/observe.rs`: the client-side decline reasons - (`no-acceptor` — the skeleton's decline-all default; the registry + (`no-acceptor`, the skeleton's decline-all default; the registry is `w4-reverse-listen`'s) recorded in the vocabulary; `src/lib.rs`: the re-exports (`dial` / `SshConnection` / `DialError` public; the handler + config mapping `#[doc(hidden)]` test surface, the @@ -145,33 +149,33 @@ connection handle this task lands. ### Decided + recorded -1. **The DialConfig amendment (`username`) — the task's sanctioned +1. **The DialConfig amendment (`username`), the task's sanctioned course-correction, the only one.** The wave-1 shape carried everything `client.md` §The dial surface names, but the USERAUTH_REQUEST structurally requires a user string (`authenticate_publickey(user, key)`) and the remote end evaluates - it — no dial can exist without one. Decided with the + it, and no dial can exist without one. Decided with the `w3-seam-config` pattern: `username: String` is a required - `DialConfig::new` parameter (no crate default — a default account + `DialConfig::new` parameter (no crate default, since a default account would be deployment policy smuggled into the crate; the empty string is accepted as data and is the remote end's to refuse). It - is *not identity* — the identity model stays fingerprint-only + is *not identity*, as the identity model stays fingerprint-only (ADR-004 decision 5, mirrored client-side), it is never logged (the event vocabulary carries no username), and it rides [`SshConnection`] as carried dial data. Wave-1 records amended (the config module + field docs). 2. **The server double's placement/shape** (`tests/common/sshd_double.rs`): a plain russh server (no alkssh - in its serving path — vanilla-parity) on a **real loopback TCP - listener** (`127.0.0.1:0` — a dial target is an address the surface + in its serving path, hence vanilla-parity) on a **real loopback TCP + listener** at `127.0.0.1:0` (a dial target is an address the surface resolves and connects to; the duplex-pair trick is the server-direction harness's, and a dial has no `&mut Handle` slot to attach one to). One dial per instance (doubles are cheap); the handler resolves fingerprints against an injected accept set (the vanilla-`authorized_keys` stand-in) with every other auth hook an explicit reject; the `auth_succeeded` hook hands the row the - per-connection `russh::server::Handle` through a one-shot carrier — - the initiator for the server-initiated-open decline rows (the + per-connection `russh::server::Handle` through a one-shot carrier, + which is the initiator for the server-initiated-open decline rows (the double calls `channel_open_*` at the client and observes each as `Err(ChannelOpenFailure(AdministrativelyProhibited))`); a per-session outcome channel is the clean-close rows' observable; @@ -180,14 +184,14 @@ connection handle this task lands. 3. **The smoke posture**: an alkssh-client ↔ alkssh-server in-process loopback over a real TCP socket, landed as the row `smoke_alkssh_client_dials_the_alkssh_server_direction` - (`tests/client_dial.rs` — both russh stacks, the full auth bridge + (`tests/client_dial.rs`, with both russh stacks, the full auth bridge server-side, the full custody + policy chain client-side). A real-`sshd` dial is wave-5 e2e territory (`docs/plans/ implementation.md` §Wave 5); nothing is silently dropped. 4. **The decline-class mapping**: russh surfaces a policy decline as its `UnknownKey` wire error; since the client's `check_server_key` hook is the only producer of that class, the manual `From` makes - it the semantic [`DialError::HostKeyRejected`] — every other russh + it the semantic [`DialError::HostKeyRejected`], while every other russh error stays [`DialError::Wire`]. 5. **The skeleton's unknown-type gate** (`should_accept_unknown_ server_channel` → explicit `false`) is code-pinned, not @@ -199,25 +203,25 @@ connection handle this task lands. wire-row-pinned; the `_unknown` open hook is a defensive reject behind the gate. 6. **Auth posture**: exactly one `authenticate_publickey` attempt - (ed25519-only, ADR-002 decision 4 — `None` is the whole + (ed25519-only, ADR-002 decision 4, `None` being the whole hash-algorithm posture); a refusal closes the attempt cleanly (`Disconnect::ByApplication`) and surfaces [`DialError::AuthRejected`]. russh's other auth methods - (password / kbi / cert / gss / signer-injection — the signer seam - is dismissed by ADR-005 decision 8) are never reached from + (password / kbi / cert / gss / signer-injection, with the signer + seam dismissed by ADR-005 decision 8) are never reached from [`SshConnection`]. 7. **Bounded posture**: the knob mapping keeps the crate's documented postures (`keepalive` 15 s / 3, the wall clock always `Some`, window/packet/buffer verbatim) and leaves russh's own default postures untouched (rekey `Limits`, - preferred algorithms, `client_id`, `nodelay` — no client-side + preferred algorithms, `client_id`, `nodelay`. No client-side auth-attempt knob exists at this pin and the single-attempt contract makes none needed). ### Verification `cargo test` (default) and `cargo test --all-features` (and -per-feature `--features {session,git,tunnel}`) all green — 14 test +per-feature `--features {session,git,tunnel}`) all green, with 14 test binaries each, the new `tests/client_dial.rs` carrying 10 rows; `cargo clippy --all-features --all-targets` and `cargo clippy --all-targets` both `-D warnings` clean; `cargo fmt --check` clean; @@ -229,11 +233,11 @@ The client direction's substrate landed: `dial(config) -> Result` runs the fail-closed four-step sequence (decrypt-at-dial key load with every no-dial posture distinct; the bounded russh client `Config` from `Limits`; the -injected host-key policy at `check_server_key` — `RejectAll` +injected host-key policy at `check_server_key`, where `RejectAll` connects to nothing; a single publickey-only auth attempt) and returns the authenticated connection handle everything later in the wave rides. One sanctioned `DialConfig` amendment: the required -`username` field (the USERAUTH_REQUEST's user string — no crate +`username` field (the USERAUTH_REQUEST's user string; no crate default, never identity, never logged). The fail-closed client Handler skeleton declines every server-initiated open on the wire (the fake-`sshd` double in `tests/common` pins six hook rows + diff --git a/tasks/implementation/w4-git-exit-close.md b/tasks/implementation/w4-git-exit-close.md index 4d0296a..75b75ff 100644 --- a/tasks/implementation/w4-git-exit-close.md +++ b/tasks/implementation/w4-git-exit-close.md @@ -1,6 +1,6 @@ --- id: w4-git-exit-close -name: Wave 4 — git row hardening (status-before-close by construction — wave-3 gate booked item 1) +name: "Wave 4: git row hardening (status-before-close by construction, wave-3 gate booked item 1)" status: completed depends_on: [w3-git-mapping] scope: narrow @@ -15,7 +15,7 @@ Close the wave-3 gate's only structural booked finding (`review-wave-3` §Booked findings item 1): the git row's status-before-close is honored **by scheduling, not by construction**. Today `serve_duplex_resolved` splits the channel stream internally and -its session halves drop when the serve returns — russh's +its session halves drop when the serve returns, so russh's `ChannelCloseOnDrop` rx-half emits a server→client `CHANNEL_CLOSE` *inside* the seam call, before `serve_git_row`'s explicit `exit_status_request`. The ordering currently holds (the task's @@ -23,26 +23,27 @@ continuation queues the report in the same poll, ahead of the drop's send) and every wire row passes, but a full `Sender` buffer at the serve tail could order the implicit close first and silently drop the report (russh no-ops `exit_status_request` for an already-closed -channel) — a silent exit-report loss on a published row. +channel), a silent exit-report loss on a published row. -The fix wants a seam/shape change — **decide + record which**: +The fix wants a seam/shape change (decide and record which): - split the channel halves in `serve_git_row` and pass them into the seam (the report then rides a half the seam never closes), or - queue the exit report while the stream is alive (the report's send ordered before any close-contracting drop by construction), or - another shape that makes the ordering structural rather than - scheduler-luck — the gate's reasoning and the two named options are + scheduler-luck; the gate's reasoning and the two named options are the starting points, not the bounds. Constraints: every existing git wire row stays green (no behavior -change on the happy path — this is ordering hardening, not semantics -change); the spawn-and-return constraint (ADR-006 decision 4) holds — -the reporting still rides the spawned task; the tty rows are NOT in +change on the happy path, since this is ordering hardening, not + semantics +change); the spawn-and-return constraint (ADR-006 decision 4) holds, +because the reporting still rides the spawned task; the tty rows are NOT in this class (their report precedes any close-contracting drop in one -task — the gate verified; don't touch them). New wire row(s) pinning +task, which the gate verified; don't touch them). New wire row(s) pinning the construction: a saturated `Sender` buffer at the serve tail -(the failure scenario made real) still delivers the exit report — +(the failure scenario made real) still delivers the exit report, the by-construction property proven on the wire, not by reasoning. ## Acceptance Criteria @@ -61,10 +62,10 @@ the by-construction property proven on the wire, not by reasoning. - tasks/implementation/review-wave-3.md §Booked findings item 1 (the full finding + the two named fix shapes) -- docs/architecture/mapping.md (the git exec row — exit-status both +- docs/architecture/mapping.md (the git exec row, exit-status both directions), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - (decision 4 — spawn-and-return) -- src/git_map.rs, src/serve.rs (`report_exit_status_then_close` — the + (decision 4, spawn-and-return) +- src/git_map.rs, src/serve.rs (`report_exit_status_then_close`, the wave-2 reporting mechanics this hardening composes with), alkgit 0.1.0 `serve_duplex_resolved` (the seam whose half-ownership is the question) @@ -73,8 +74,8 @@ the by-construction property proven on the wire, not by reasoning. ### The shape decided (2026-10-29) -Went with the first named option — **split the channel halves in -`serve_git_row` and pass them into the seam** — refined by one russh +Went with the first named option, **splitting the channel halves in +`serve_git_row` and passing them into the seam**, refined by one russh 0.65 reality: `russh::channels::io::{ChannelRx, ChannelTx}` are *private* at this pin, so the split-halves duplex is rebuilt from the public API in `src/git_map.rs`: @@ -83,14 +84,14 @@ public API in `src/git_map.rs`: [`ChannelHalveReader`]: the owned `ChannelReadHalf` (`channel.split()`), a `poll_read` state machine whose observation semantics repin russh's private `ChannelRx` *without* the - `ChannelCloseOnDrop` wrapper — `Data` fills (partial reads + `ChannelCloseOnDrop` wrapper, so `Data` fills (partial reads buffered), `ExtendedData`/other non-data messages swallowed and re-read (the `_`-arm parity), `Eof` ends the stream, the peer's `Close` leaves the reader to the channel's sender-death EOF (`wait()` → `None`). The `wait()` access is *future-owned*: each read round-trips the half through a boxed future that parks it inside `ChannelReadHalf::wait` and hands it back with the observed - message — fully owned (`'static`-usable, the seam's `T` bound) + message, fully owned (`'static`-usable, the seam's `T` bound) across every park/buffer/drop state, no extra task, and **no `Drop` path ever messages the wire**. - **The writer** is the write half's `ChannelTx` via @@ -101,20 +102,20 @@ public API in `src/git_map.rs`: the seam's `'static` bound satisfiable without parking the half. `poll_shutdown` still sends EOF (exactly `into_stream`'s). -The result: **nothing the seam drops ever messages the wire** — the +The result: **nothing the seam drops ever messages the wire**, with the report's `exit_status_request` send is the sole close-contracting sender, and its queue position ahead of the explicit close holds by construction (sequential awaited sends on the one `Handle`), saturated buffer or not. The hazard's premise (russh's `ChannelCloseOnDrop` `Drop` spawning a `CHANNEL_CLOSE` send that can -win a queue position on the bounded `Sender` — -`event_buffer_size`, default 10 — before the report's) is *removed*, +win a queue position on the bounded `Sender` +(`event_buffer_size`, default 10) before the report's, is *removed*, not raced. ### The wire row (the by-construction property pinned) -`tests/git_mapping.rs::backlogged_seam_tail_still_reports_exit_status_before_close` -— the finding's failure scenario made real on the wire: the serve's +`tests/git_mapping.rs::backlogged_seam_tail_still_reports_exit_status_before_close`, +the finding's failure scenario made real on the wire: the serve's answer volume (300 one-pkt-line ref answers) exceeds the entire outbound pipeline via the new client-direction data gate (`tests/common/harness.rs::DataGate` + the harness handler's `data` @@ -125,18 +126,18 @@ serve's remaining answer writes pend (the serve tail blocked). The gate's release then drives the serve tail through the just-unwinding contention, and the row pins: the whole backlogged answer drains **through the same queue the report rides**, then exit-status(0), then -close — order asserted, exit code asserted. +close; the order is asserted, the exit code asserted. **The row falsifies the old shape.** With a temporary one-line revert of `serve_git_row` to `channel.into_stream()` the row FAILED -**15/15** runs — the implicit close won the frozen-drain queue race +**15/15** runs, as the implicit close won the frozen-drain queue race ("expected exit-status(0), got Some(Close)"), the silent report loss the gate booked. With the split-halves shape: **10/10 green**. So the row is a two-sided pin: deterministic red under the hazard, green by construction without it. Test-vehicle additions (test surface, freely versioned): the doubles' -per-repo serve gate (`tests/common/git_doubles.rs`: `install_gate` — +per-repo serve gate (`tests/common/git_doubles.rs`: `install_gate`, a race-safe double-check `MemGate`, gating `GitRefs::list_refs`, so the row schedules the serve's stages against prepared wire state) and the harness's `DataGate` + `drive_with_data_gate` (the `drive*` bodies @@ -145,15 +146,15 @@ signatures unchanged). ### Constraints check -- **Every existing git wire row green** — the full git suite +- **Every existing git wire row green**, with the full git suite (`tests/git_mapping.rs`, 12 rows) and every other loopback suite: - no behavior change on the happy path (the wire order the rows pin — - data, exit-status, close — is identical under both shapes when the - race doesn't fire). + no behavior change on the happy path (the wire order the rows pin, + i.e. data, exit-status, close, is identical under both shapes when + the race doesn't fire). - **Spawn-and-return intact** (ADR-006 decision 4): the reporting still rides the spawned serving task through the cloned `Session::handle()`; `serve.rs`'s mechanics untouched. -- **The tty rows untouched** (not in this class — the gate verified). +- **The tty rows untouched** (not in this class; the gate verified that). - **No API/limits change**: `event_buffer_size` stays russh's default posture (the adapter docs' recorded default posture preserved); the row pins the default's value with a note that a russh default @@ -163,7 +164,7 @@ signatures unchanged). **Completed.** The git row's status-before-close now holds **by construction**: `serve_git_row` no longer hands russh's -close-on-drop `ChannelStream` (`into_stream()`) to the seam — the +close-on-drop `ChannelStream` (`into_stream()`) to the seam; the seam rides a split-halves duplex (`git_channel_duplex` → `ChannelHalveReader` over the owned read half + the write half's `ChannelTx` via the capture-erased trait object) where **no drop @@ -173,8 +174,8 @@ unconditional (russh 0.65's private `ChannelRx`/`ChannelTx` noted; the semantics repinned from the public API). New wire row pins the by-construction property on a genuinely saturated tail (volume flood → frozen client loop → duplex → bounded message queue; gate release -→ the tail through the unwinding contention) — and falsifies the old -shape deterministically (15/15 red under a temporary +→ the tail through the unwinding contention), and it also falsifies +the old shape deterministically (15/15 red under a temporary `into_stream()` revert, 10/10 green with the fix). Doubles gained a race-safe per-repo serve gate; the harness gained the client-side data gate + `drive_with_data_gate` (the `drive*` cores factored, diff --git a/tasks/implementation/w4-lifecycle.md b/tasks/implementation/w4-lifecycle.md index 2d000d9..b1c0a44 100644 --- a/tasks/implementation/w4-lifecycle.md +++ b/tasks/implementation/w4-lifecycle.md @@ -1,6 +1,6 @@ --- id: w4-lifecycle -name: Wave 4 — lifecycle primitives (cancellable handles + clean disconnect) +name: "Wave 4: lifecycle primitives (cancellable handles + clean disconnect)" status: completed depends_on: [w4-op-rows, w4-reverse-listen] scope: narrow @@ -13,24 +13,24 @@ level: implementation Land the OQ-12 resolution in code (`client.md` §Lifecycle primitives): the primitives that make assembly-side reconnect/restore -policy *possible* — and nothing more: +policy *possible*, and nothing more: -- **Explicitly cancellable handles** — every listener/handle the +- **Explicitly cancellable handles**, so every listener/handle the client direction returns (the `-R` listener handles from `w4-reverse-listen`, the connection handle from `w4-dial`) carries an explicit cancellation/clean-close path; no handle relies on drop timing alone for wire-visible state. -- **Clean disconnect** — `Disconnect::ByApplication` (russh's client +- **Clean disconnect**, namely `Disconnect::ByApplication` (russh's client `Handle::disconnect`): the explicit-close path leaves no half-open channels, observed clean by the peer (row-pinned through the server double). -- **No reconnect or restore policy owned by the crate** — the +- **No reconnect or restore policy owned by the crate**, hence the module-doc posture: reconnect-and-restore (rebinding persisted - tunnel ports — the alkminer flow, reverse-runner fact 4) is + tunnel ports, the alkminer flow, per reverse-runner fact 4) is assembly policy; the crate provides bound ports, handles, cancellation, clean close. The docs state the boundary explicitly so no later task drifts into baking policy in. -- **Contract rows through the server double** — cancel a listener +- **Contract rows through the server double**: cancel a listener mid-connection (the server's forward is torn down, the bound port released); clean disconnect observed by the peer; the connection-handle cancellation path; the no-policy posture is a @@ -51,27 +51,27 @@ policy *possible* — and nothing more: ## References - docs/architecture/client.md §Lifecycle primitives (the OQ-12 - resolution), docs/architecture/open-questions.md (OQ-12 — resolved: - primitives only) -- docs/research/reverse-runner.md (fact 4 — reconnect-and-restore is + resolution), docs/architecture/open-questions.md (OQ-12, which + resolved as primitives only) +- docs/research/reverse-runner.md (fact 4, where reconnect-and-restore is assembly policy, kept in view so the crate never bakes it in) - docs/research/russh.md §Client surface (`Handle::disconnect`, the forward cancel forms) -- src/tunnel_map.rs (the server-side teardown posture — the cancel +- src/tunnel_map.rs (the server-side teardown posture, the cancel semantics mirror) ## Notes ### What landed (2026-10-29) -- `src/client_accept.rs`: the **listener handles** — +- `src/client_accept.rs`: the **listener handles**, namely the [`ReverseListener`] + [`ReverseEndpoint`] (the handles' surface the `w4-reverse-listen` block deferred to this task): `reverse_listen` / `reverse_listen_unix` return a handle carrying the *actually-bound* endpoint (the `bound_port()` accessor, `None` for the unix shape) with the explicit `cancel()`; the shared cancel helpers - (`cancel_reverse_tcp` / `cancel_reverse_unix`) — both the handles' - `cancel()` and the stateless wire forms compose into the same + (`cancel_reverse_tcp` / `cancel_reverse_unix`), and both the + handles' `cancel()` and the stateless wire forms compose into the same paths; the confirmed cancel releases the connection's listener count exactly once (the repeat is the wire's unknown-listener refusal, no release). The recorded decisions (module docs): no @@ -80,12 +80,12 @@ policy *possible* — and nothing more: its `cancel()` works against a live stack regardless of the connection handle's own holders; the stateless forms ride beside 1:1. -- `src/client.rs`: the **`SshConnection` restructure** — the russh +- `src/client.rs`: the **`SshConnection` restructure**, where the russh handle, limits/labels, acceptor registry and listener budget ride one `ConnectionShared` behind the `Arc` (the concurrency-safe sharing the handles' independent `cancel()`s need; the russh client request methods all take `&self` at this pin, so the arc is the - sharing shape — no mutex), `SshConnection`'s public surface + sharing shape, with no mutex), `SshConnection`'s public surface unchanged (`close()` consumes the handle and sends `Disconnect::ByApplication`), and the module docs' new **§Lifecycle posture** (the primitives-not-policy boundary, reverse-runner fact @@ -98,16 +98,16 @@ policy *possible* — and nothing more: - `tests/client_dial.rs`: the every-build row `dropping_the_connection_is_an_abrupt_teardown_close_is_the_only_clean_path` (the `next_session_outcome` observable: drop → the peer's session - ends with an **error wire class, never the clean `Ok`** — the + ends with an **error wire class, never the clean `Ok`**, the boundary pinned both ways: the clean `Ok` is only `close()`'s). -- `tests/client_reverse_listen.rs`: three new lifecycle rows — +- `tests/client_reverse_listen.rs`: three new lifecycle rows: `dropping_the_listener_handle_leaves_no_drop_timing_wire_state` (drop → the peer's listen still serves a round-tripping data leg; only the explicit cancel tears down), `the_listener_handle_cancel_tears_the_forward_down_and_releases_the_budget` (ceiling → refuse pre-wire; the handle's cancel confirms and releases; the next listen admits; the repeat rides the wire and - refuses — no budget manufactured), + refuses, with no budget manufactured), `cancel_mid_data_leg_tears_the_forward_down_mid_connection` (the cancel lands while a data leg is mid-pump; the forward tears down, the connection stays usable, ends clean); the existing rows @@ -117,46 +117,46 @@ policy *possible* — and nothing more: ### Decided + recorded -1. **The handles' shape — single `ReverseListener`, one +1. **The handles' shape, a single `ReverseListener`, one `ReverseEndpoint` enum, not two handle types.** The endpoint enum mirrors the wire's two bind shapes (an actually-bound address+port / a socket path); the tcp-flavored `bound_port()` - accessor returns `Option` (the unix shape carries no port — - honest, no panic path). A second handle type would have duplicated + accessor returns `Option` (the unix shape carries no port, + honestly with no panic path). A second handle type would have duplicated the cancel plumbing for a shape the cancel forms already treat as raw strings. 2. **The abrupt-drop fact (row-pinned, corrected mid-task):** at this russh pin dropping the *last* wire-stack handle closes the `Handle`'s sender; the client runner's `receiver.recv()` → `None` - sets `disconnected` and the transport tears down abruptly — the + sets `disconnected` and the transport tears down abruptly; the peer's session outcome is an error wire class (`Err(Disconnect)` observed through the double). So the honest boundary is not - "drop does nothing" but **"drop is never the clean path"** — the + "drop does nothing" but **"drop is never the clean path"**, since the clean `Ok` session end is only `close()`'s (`Disconnect::ByApplication`), row-pinned in both directions (abrupt-on-drop, clean-on-close) through the server double. No handle relies on drop timing for *clean* wire state, and the listener handles' drop is a pure local no-op (no `Drop` hook; a - cancellation is async and cannot hide in one — the listen lives at + cancellation is async and cannot hide in one, and the listen lives at the peer until an explicit cancel or the connection's end). 3. **The `SshConnection` restructure is a shape change, not a - surface change** — the public surface (the wrapped methods, + surface change**: the public surface (the wrapped methods, `limits()`/`address()`/`username()`/`is_closed()`/`acceptors()`/ `close()`) is unchanged; only the internals moved behind the arc - (the handles' independent cancel). No `Clone` was added — not in + (the handles' independent cancel). No `Clone` was added, which is not in the contract, and the arc means a cloned handle's `close()` semantics would need their own contract; wave-5 review can add it if an assembly shape needs it. 4. **A live `ReverseListener` keeps the wire stack alive after the - connection handle's drop** — the arc's ownership side effect, - recorded (the alkminer flow's shape: the listener handle outlives + connection handle's drop**, an arc ownership side effect recorded + (the alkminer flow's shape: the listener handle outlives the connection slot; its `cancel()` composes on the still-live stack). ### Verification `cargo test` (default), `--features session`, `--features git`, -`--features tunnel`, `--all-features` — all green (16 test binaries +`--features tunnel`, `--all-features`: all green (16 test binaries each); `cargo clippy --all-targets -- -D warnings` and `cargo clippy --all-features --all-targets -- -D warnings` clean; `cargo fmt --check` clean; `cargo doc --no-deps` warning-free in the @@ -179,20 +179,20 @@ Details in the `Landed` block below. Landed (`feat(client)`: w4-lifecycle): -- **`src/client_accept.rs`** — the listener handles: `ReverseListener` - (+ `ReverseEndpoint`) — `reverse_listen` / `reverse_listen_unix` - now return them; the actually-bound endpoint carried, the explicit +- **`src/client_accept.rs`**, the listener handles: `ReverseListener` + (+ `ReverseEndpoint`), which `reverse_listen` / `reverse_listen_unix` + now return; the actually-bound endpoint carried, the explicit `cancel()` the wire-honest teardown, the listener count released exactly once per confirmed cancel through the shared cancel paths the stateless wire forms compose into too; no `Drop` hides a wire op (recorded, row-pinned no-drop-timing wire state). -- **`src/client.rs`** — the `SshConnection` internals behind the arc +- **`src/client.rs`**, the `SshConnection` internals behind the arc (`ConnectionShared`) + the §Lifecycle posture module docs (the primitives-not-policy boundary, the abrupt-drop/clean-close facts); the public surface unchanged (`close()` is the clean path). -- **`src/lib.rs`** — the handle exports; **`docs/architecture/ - client.md`** — the §Lifecycle primitives landing block. -- **Rows** — 3 new tunnel-build rows through the server double +- **`src/lib.rs`** for the handle exports; **`docs/architecture/ + client.md`** for the §Lifecycle primitives landing block. +- **Rows**, with 3 new tunnel-build rows through the server double (`tests/client_reverse_listen.rs`: the drop-handle no-wire-state row, the handle-cancel + budget-release row, the cancel-mid-data-leg row) + 1 new every-build row (`tests/client_dial.rs`: the abrupt- diff --git a/tasks/implementation/w4-op-rows.md b/tasks/implementation/w4-op-rows.md index fb3cdac..8cfb2f3 100644 --- a/tasks/implementation/w4-op-rows.md +++ b/tasks/implementation/w4-op-rows.md @@ -1,6 +1,6 @@ --- id: w4-op-rows -name: Wave 4 — the client op rows (exec/pty, direct-tcpip, git exec, sftp decline) +name: "Wave 4: the client op rows (exec/pty, direct-tcpip, git exec, sftp decline)" status: completed depends_on: [w4-dial] scope: moderate @@ -11,54 +11,54 @@ level: implementation ## Description -Land the op rows — family op → vanilla translation per +Land the op rows: family op → vanilla translation per `client.md`'s table, pointed upstream at a vanilla server (the reverse-runner shape: the hub drives every channel open). Type symmetry at the seam is the contract: family-protocol types in, vanilla wire ops out, no vanilla-concept leaks up. The POC-3 §D.3 mechanics are the body of knowledge this task lands in code: -- **Session exec/pty rows** (under `session`) — `channel_open_session` +- **Session exec/pty rows** (under `session`): `channel_open_session` + the exec/pty-req/shell-req translations onto the alktty session - types (pipe vs PTY; exit semantics): `Channel::exec()` — **no + types (pipe vs PTY; exit semantics): `Channel::exec()`, with **no `request_exec` at this pin**; Success/Failure arrive on the channel queue via `wait()`-drain, **not** as `Err` from the request call (drain until the expected message, `continue` past the rest); `window_change`/`signal` are fire-and-forget and must be sent **before** `into_stream()` (the Channel is consumed by conversion); - **never consume the channel queue before `into_stream()`** (a - `wait()` on a fresh channel breaks it — POC-1 §6, both sides). The + **never consume the channel queue before `into_stream()`**, because a + `wait()` on a fresh channel breaks it (POC-1 §6, both sides). The data pump + the exit consumption mirror the server direction's derivation contract (the pipe-mode pid-only signal caveat rides the - alktty exit-chunk semantics — `mapping.md`'s contract, consumed not + alktty exit-chunk semantics, `mapping.md`'s contract, consumed not re-derived). -- **`direct-tcpip` typed dial** (under `tunnel`) — the row the `-L` +- **`direct-tcpip` typed dial** (under `tunnel`), the row the `-L` leg and the alksocks `DialFn` compose over: `channel_open_direct_tcpip` - resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6) — - the typed open, no `wait()` before `into_stream`; **the connection + resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6), + giving the typed open with no `wait()` before `into_stream`; **the connection stays usable after a declined open** (pinned by row). The `direct-streamlocal` twin with the runtime-Unix gate (OQ-11 symmetry: universal compile, clean off-Unix decline). -- **Git exec consumption** (under `git`) — the same exec grammar the +- **Git exec consumption** (under `git`), the same exec grammar the server side parses (`git-{upload,receive}-pack '/repo'`), - **reused, not forked** — pointed upstream at a vanilla `sshd`: + **reused, not forked**, pointed upstream at a vanilla `sshd`: parse → exec → the pack duplex pumped → exit-status consumed. The per-length limits and service-only decline classes ride the shared grammar's verdicts. -- **The sftp subsystem placeholder decline** (every build, OQ-13) — +- **The sftp subsystem placeholder decline** (every build, OQ-13), the client-side half of the tested decline placeholder: a `subsystem "sftp"` open declines cleanly in every build (the feature exists, the route doesn't until alkfs lands). The server-side rows already landed in wave 2; this lands the client mirror so both directions are contract-tested. -- **Contract rows through the server double, per build** — the +- **Contract rows through the server double, per build**, with the `w4-dial` fake-`sshd` vehicle grown with the op-row surfaces: featured + working rows (exec exit-status, pty window-change ordering, direct-tcpip pump, git exec round trip), decline rows (server-rejected opens, the sftp decline, off-Unix streamlocal), the connection-stays-usable row after each decline class. The real-tool smoke posture decided + recorded (a real `sshd` exec is - cheap where tooling permits — check-gated like the wave-3 smokes; + cheap where tooling permits, check-gated like the wave-3 smokes; else named wave-5 e2e territory explicitly). ## Acceptance Criteria @@ -83,15 +83,15 @@ mechanics are the body of knowledge this task lands in code: - docs/architecture/client.md §Op rows (the table), §Correctness facts, §What the crate does *not* include (job supervision is - assembly work — the crate gives channels + exit semantics) + assembly work; the crate gives channels + exit semantics) - docs/architecture/mapping.md §The exit derivation contract (the mirror the client consumes), docs/architecture/decisions/003-feature-graph.md (the feature rows), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (exec-only git, both directions) -- docs/research/poc-3-mapping-bakeoff.md §D.3 (the client mechanics — +- docs/research/poc-3-mapping-bakeoff.md §D.3 (the client mechanics, the body of knowledge), docs/research/poc-1-auth-bridge.md §6 (the open-call `Err` semantics), docs/research/reverse-runner.md (the - per-connection surface table — the consumer evidence) + per-connection surface table, the consumer evidence) - src/git_map.rs (the grammar reused), src/tty_map.rs (the exit-chunk semantics consumed), russh 0.65 `client::Handle` (`channel_open_session` / `channel_open_direct_tcpip` / `channel_open_direct_streamlocal`) @@ -101,7 +101,8 @@ mechanics are the body of knowledge this task lands in code: ### What landed (2026-10-29) - `src/client_ops.rs` (new, every build; rows feature-gated inside): - the op rows — the family op → vanilla translation surfaces, all on + the op rows, the family op → vanilla translation surfaces, all + on `SshConnection` (`dial`'s handle; sibling-module method impls over the crate accessors `russh_handle()` / `budgets()`): - *session*: `exec` (pipe), `pty_exec` (`ssh -t`), `shell(Option< @@ -110,30 +111,30 @@ mechanics are the body of knowledge this task lands in code: parity) → `RemoteSession`, the **family chunk plane** (§below). - *tunnel*: `tunnel_direct(host, port)` / `tunnel_direct_unix(path)` → `TunnelChannel` (a plain byte - duplex — the alksocks `DialFn` composes it; the `-D` leg's + duplex, which the alksocks `DialFn` composes; the `-D` leg's composition needs no dependency, ADR-003 §1a). - *git*: `git_service(alkgit::Service, repo_id)` → `GitPackSession` (raw pack duplex + stderr duplex + the consumed-exit oneshot). - *every build*: `sftp()` → `Err(OpError::SftpNotServed)` (the - `Ok` type is `Infallible` — the feature ships but the route + `Ok` type is `Infallible`, so the feature ships but the route doesn't, OQ-13, now tested in both the featureless and the featured build). -- `src/exit_code.rs` (new, extracted from `tty_map.rs` — the wave's +- `src/exit_code.rs` (new, extracted from `tty_map.rs`, the wave's structural edit): the derivation contract's machinery (`SIGNAL_NAMES` / `ExitReport` / `derive_exit` / `sig_from_name` / `sig_control_name`) is now **two-direction - territory** — the module gained the client arm's + territory**; the module gained the client arm's `signal_code_from_name` (the wire `exit-signal` name → the family `−signum` code, `−1` for the unmapped/the sentinel collision) and the module docs carry the client arm's rules as the same contract's inverse. `tty_map.rs` consumes it (no fork anywhere); `handler.rs`'s signal row follows the moved path. -- `src/error.rs`: [`OpError`] — the op rows' closed taxonomy - (`OpenDecline(OpenDeclineReason)` — the *family-mirrored* copy of +- `src/error.rs`: [`OpError`], the op rows' closed taxonomy + (`OpenDecline(OpenDeclineReason)`, the *family-mirrored* copy of the typed open's wire class, `Other{code,reason}` verbatim, nothing collapsed / - `RequestRefused` (the `wait()`-drain's `Failure` reply — POC-3 + `RequestRefused` (the `wait()`-drain's `Failure` reply, as POC-3 §D.3's not-an-`Err` fact surfaced as the op's decline class) / `ChannelEnded` (the drain saw the channel's end; nothing fabricated) / `CommandTooLong` (the exec-length budget's @@ -141,14 +142,14 @@ mechanics are the body of knowledge this task lands in code: validation gate mirrored, pre-wire) / `UnixUnavailable` (the OQ-11 gate, pre-wire) / `SftpNotServed` (the placeholder) / `Wire(russh::Error)` (transparent); the `From` maps - the typed open's `ChannelOpenFailure` into the family mirror — - one conversion point, unit rows pin every class). -- `src/dispatch.rs`: the shared grammar's **construction arm** — + the typed open's `ChannelOpenFailure` into the family mirror, + with one conversion point, unit rows pin every class). +- `src/dispatch.rs`: the shared grammar's **construction arm**, `build_git_exec_command(route, repo_id)` beside `parse_exec`, with unit rows proving the construction round-trips the parse for both services (the grammar's gate is the `Service` *type*: the construction arm can emit no other token, so the client rows are - structurally unreachable by `unknown-service`/`service-only` — + structurally unreachable by `unknown-service`/`service-only`, recorded). - `src/client.rs`: the crate accessors (`russh_handle`/`budgets`, feature-gated; no public-surface change); `src/lib.rs`: the @@ -156,37 +157,37 @@ mechanics are the body of knowledge this task lands in code: (`RemoteSession` / `TunnelChannel` / `GitPackSession`, `OpError`/`OpenDeclineReason` public). - `tests/common/sshd_double.rs`: the fake `sshd` grown with the - op-row script — the session requests' reply knob, the scripted - program (stdout/stderr/exit — `exit-then-close`, the vanilla + op-row script, with the session requests' reply knob, the scripted + program (stdout/stderr/exit in the `exit-then-close` vanilla discipline; a no-output/no-exit script launches *nothing*, keeping interactive sessions open), the direct opens' echo-or-reject behavior, a counted-refusal knob for the stays-usable-after-a-decline rows, and the observation surface - (`SshdObserved`: recorded execs/ptys/windows/signals — the wire + (`SshdObserved`: recorded execs/ptys/windows/signals, the wire translations' witnesses); `tests/client_ops.rs`: the contract rows (below). ### Decided + recorded 1. **The family plane (the session rows) is the alktty chunk framing - in both directions** — the exact plane the server direction's + in both directions**, the exact plane the server direction's bridge speaks, consumed not re-derived: stdin chunks + `CTRL_IN` (sentinel/`Resize`/`Signal`/`Eof`) toward the wire; stdout/stderr chunks + the **in-band exit chunk, last** toward the hub. The exit ride is the family exit-currency (`ControlMessage::Exit`'s `i32`): wire `ExitStatus(N)` → `N`, wire `ExitSignal(name)` → `−signum` via the table's inverse read (unmapped names ride the - `−1` sentinel — including a genuine SIGHUP, the same collision + `−1` sentinel, including a genuine SIGHUP, the same collision rule 3 consumes server-side). A session ending with no report delivers no exit chunk (the no-exit-chunk close family's mirror). 2. **The exec/pty/git rows never `into_stream()`; the tunnel rows never wait.** The mechanics facts pin the *shapes*: the conversion's reader swallows exactly the non-`Data` messages the exit reports ride on, so an exit-consuming row is structurally a - **split-halves wait-pump** (`Channel::split()` + spawned drain of - `wait()` — the same shape `w4-git-exit-close` pinned server-side; + **split-halves wait-pump** (`Channel::split()` plus a spawned drain + of `wait()`, the same shape `w4-git-exit-close` pinned server-side); the write half stays alive, so the fire-and-forget - `window_change`/`signal` rows ride mid-session — the ordering + `window_change`/`signal` rows ride mid-session; the ordering fact's constraint, that controls precede `into_stream()`, is respected by *construction* across both row kinds). The tunnel rows go typed-open → straight `into_stream()`, no queue @@ -197,7 +198,7 @@ mechanics are the body of knowledge this task lands in code: disambiguation** (`ChunkReader::read_chunk`, not `tty_map::read_bridge_frame`): that plane's chunk types are stdin `0` / `CTRL_IN` `3`; a negotiation-error frame never exists - toward the door — and a `0x00`-leading read there is a legitimately + toward the door, and a `0x00`-leading read there is a legitimately valid zero-length stdin chunk, so `read_bridge_frame`'s `0x00`-disambiguation (whose home is the alktty→hub direction, where error frames live) would misparse it. The wave-3 harness's @@ -211,22 +212,22 @@ mechanics are the body of knowledge this task lands in code: write failure); the *input* pump closes the wire channel on it. stdin-EOF (the sentinel, a `CTRL_IN` Eof, or the hub's input half dropping) sends the wire EOF *once* and parks the pump in the - idle-for-kill state (the session keeps pumping — stdin closure + idle-for-kill state (the session keeps pumping; stdin closure never ends the session). Dropping the surface (both halves) closes - the channel — wire-clean, nothing dangling outside the dial's + the channel, wire-clean, nothing dangling outside the dial's own wall clock. 5. **The launch drains are message-capped** (`DRAIN_MESSAGE_CAP`, 1024): a peer answering only with non-reply messages cannot loop the drain unboundedly (bounded resources; the drain's idle waits are bounded by the dial's always-set inactivity timeout). A - refusal closes the channel behind itself (`drain_launch_request` - — the row owned it; never half-serves). + refusal closes the channel behind itself (`drain_launch_request`, + which the row owned; never half-serves). 6. **The pty translation**: family `TerminalParamsWire` → wire `pty_request` verbatim (term/cols/rows/pixels, `term: None` → the empty-terminal-string value; **the modes JSON never rides the - wire** — always the empty set, the OQ-44 reserved posture + wire**, always the empty set per the OQ-44 reserved posture mirrored: the door never invents wire terminal modes); a refused - pty request refuses the whole row (never a pty-less fall) — the + pty request refuses the whole row (never a pty-less fall), the never-half-capability row. 7. **The git row's gates before any wire op** (the shared grammar's verdicts): `validate_repo_id` → `InvalidRepoId`; the built @@ -236,7 +237,7 @@ mechanics are the body of knowledge this task lands in code: stderr rides a split raw duplex (the hub may drop it); the pack duplex is the protocol's own byte plane, untouched. 8. **The tunnel rows' originator**: the door's label - (`"alk-ssh"`, port 0) — nothing invented, nothing leaked; peer + (`"alk-ssh"`, port 0): nothing invented, nothing leaked; peer logs name the door (recorded; a real originator would be the assembly's local-listener fact, and the `DialFn` shape carries the target only). @@ -246,7 +247,7 @@ mechanics are the body of knowledge this task lands in code: this direction: the always-on wall clock/keepalives, the russh windows, the bounded duplexes/mpscs, the drain cap. 10. **No crate events on the client op rows**: the outcomes ride the - typed surfaces (`Result`/channels) — the decline* event + typed surfaces (`Result`/channels), as the decline* event vocabulary takes a wire channel id, and a *client-side* declined open has none to name (the typed open carries no id back); the events remain the server-direction (and dial-skeleton) rows' @@ -255,18 +256,18 @@ mechanics are the body of knowledge this task lands in code: 11. **The smoke posture** (decided + recorded, per the task's item): the session rows' cheap real-stack smoke is the alkssh-client-op-row ↔ alkssh-server loopback (the client exec - row against the server direction's *real* session rows — both + row against the server direction's *real* session rows, with both chunk-bridge arms over one live wire), landed as the row `smoke_client_exec_row_against_the_server_directions_session_rows` (gated on `sh` like the wave-3 smokes, skipped-with-reason when absent). A **real-vanilla-`sshd`** dial smoke is wave-5 e2e - territory explicitly — nothing silently dropped. + territory explicitly, with nothing silently dropped. ### Test rows (`tests/client_ops.rs`, one per decline/contract class; per-build binaries) - *every build*: the sftp placeholder's decline (before any wire op, no channel opened at the peer) + the featured-build variant (under - `sftp`) — the connection-stays-usable close on both. + `sftp`), and the connection-stays-usable close on both. - *session* (10 rows + the smoke): the exec family-plane row (command rides the wire verbatim; stdout/stderr chunks distinct; the exit chunk in-band, last; the stdin sentinel = wire EOF), the @@ -277,7 +278,7 @@ mechanics are the body of knowledge this task lands in code: rows (CTRL_IN Resize → wire `window_change`; Signal → wire `signal`, name verbatim), the exit-signal mirror row (terminal `exit-signal("TERM")` → the family code `−15` in-band), the - never-half-capability row (a refused pty refuses the whole row — + never-half-capability row (a refused pty refuses the whole row, so no exec behind it ever rode the wire), the server-rejected-open decline row (`OpenDeclined(Prohibited)`) with the stays-usable-after-the-decline launch on the same connection, and @@ -286,8 +287,8 @@ mechanics are the body of knowledge this task lands in code: round-trips; `poll_shutdown` EOF rides), the reject-maps-the-class + stays-usable row (`ConnectFailed` mirror; the streamlocal twin then opens on the same connection on-Unix; - `#[cfg(not(unix))]`'s OQ-11 decline row compiles off-Unix only — - the universal-compile/clean-decline gate mirrored from the server + `#[cfg(not(unix))]`'s OQ-11 decline row compiles off-Unix only, per the + universal-compile/clean-decline gate mirrored from the server side), the twin's on-Unix pump served in the same row. - *git*: the grammar's construction riding the wire verbatim + the pack duplex pump + the `exit-status(0)` consumed into the family @@ -300,7 +301,7 @@ mechanics are the body of knowledge this task lands in code: ### Verification `cargo test` (default), per-feature (`--features {session, git, -tunnel, sftp}`), pairs, and `--all-features` — all 15 test binaries +tunnel, sftp}`), pairs, and `--all-features`, all 15 test binaries green in the full builds (the client_ops binary: 18 rows under `--all-features`, the per-build sets: session 12 / git 5 / tunnel 3 / featureless 1); `cargo clippy --all-features --all-targets` and @@ -316,7 +317,7 @@ typed-open decline's one-to-one mirror). The session rows give the hub the alktty chunk plane in both directions with the exit consumed in-band as the family exit chunk (the derivation contract's client arm, extracted into the shared two-direction `exit_code.rs` -module — table and rules never forked); the tunnel rows are the +module; table and rules never forked there); the tunnel rows are the typed `direct-tcpip`/`direct-streamlocal` opens into raw byte duplexes (rejects mirror to `OpenDeclineReason`, the connection stays usable, row-pinned per decline class); the git row builds the diff --git a/tasks/implementation/w4-reverse-listen.md b/tasks/implementation/w4-reverse-listen.md index 7c4bec7..a5175de 100644 --- a/tasks/implementation/w4-reverse-listen.md +++ b/tasks/implementation/w4-reverse-listen.md @@ -1,6 +1,6 @@ --- id: w4-reverse-listen -name: Wave 4 — the client -R leg (forward requests + the acceptor registry) +name: "Wave 4: the client -R leg (forward requests + the acceptor registry)" status: completed depends_on: [w4-op-rows] scope: moderate @@ -15,44 +15,45 @@ Land the client direction's `-R` leg (`client.md` §Op rows row 4 + §The acceptor): the hub asks the vanilla server to listen, then **accepts the server-initiated `forwarded-tcpip` opens** and routes each to the family handler. The acceptor row is unavoidable and its -disposition is ours (reverse-runner fact 1) — russh's client default +disposition is ours (reverse-runner fact 1), because russh's client default is accept-and-drop, a half-capability the family forbids; the `w4-dial` skeleton pins the fail-closed default, this task grows the registry that serves: -- **The forward request rows** — `tcpip_forward(address, port) -> +- **The forward request rows**: `tcpip_forward(address, port) -> bound` (russh's client `Handle` returns the actually-bound `u32`; - `port = 0` → the server-picked number surfaced — the lifecycle + `port = 0` → the server-picked number surfaced, the lifecycle primitive `client.md` §Lifecycle pins), `cancel_tcpip_forward`, `streamlocal_forward` + `cancel_streamlocal_forward` with the **runtime-Unix gate** (OQ-11 symmetry: universal compile, clean - off-Unix decline — the dial-call-witness posture the server side + off-Unix decline, the dial-call-witness posture the server side landed). -- **The acceptor registry** — the assembly-mutable, per-channel-type +- **The acceptor registry**, the assembly-mutable, per-channel-type registry the client Handler routes server-initiated opens through: served **only** under a registered acceptor **for that type and under its feature** (`forwarded-tcpip` / `forwarded-streamlocal` under `tunnel` is the real case); unregistered/unfeatured accepted - opens **declined cleanly** — never silently dropped after accept + opens **declined cleanly**, never silently dropped after accept (ADR-003 §4). Decide + record the registry's exact shape (per-type - slots vs a typed map; the acceptor trait signature — what an + slots vs a typed map; the acceptor trait signature, naming what an accepted open hands the assembly: the channel duplex + the open's originator fields) against the harness's `ForwardedAcceptor` vocabulary (`w3-tunnel-listen` landed the first shape client-side - in tests — grow it into the crate's real surface, don't fork it). + in tests; grow it into the crate's real surface, don't fork it). - **Late-install across a live connection** (the alkminer - `install_forward_sink` dynamic — reverse-runner fact 2): acceptors + `install_forward_sink` dynamic, reverse-runner fact 2): acceptors install/uninstall while the connection is up; consumers turn capabilities on/off per connection. An open arriving before its - acceptor installs is declined (fail-closed at arrival time — - decide + record; no buffering of unclaimed opens). -- **The data leg** — a registered acceptor's open: the channel duplex + acceptor installs is declined (fail-closed at arrival time, with a decide-and-record; + no buffering of unclaimed opens). +- **The data leg**, a registered acceptor's open: the channel duplex handed to the assembly handler (the pump is the acceptor's, per the family handler shape); bounded bookkeeping (no unbounded registry, - no unbounded per-connection listener map — the `Limits` cap + no unbounded per-connection listener map, with the `Limits` cap relation decided + recorded, the server-side listener-counter posture as the template). -- **Contract rows through the server double, per build** — the fake +- **Contract rows through the server double, per build**, with the + fake `sshd` grown with the server-initiated-open surfaces: featured + registered → served; featured + unregistered → clean decline; unfeatured → clean decline (per-build matrices, the symmetry rule); @@ -61,7 +62,7 @@ registry that serves: off-Unix streamlocal rows; the bounded-bookkeeping rows. The real-tool smoke posture decided + recorded (a real `ssh -R`-shaped round trip against the double is the in-process form; real-`sshd` - `-R` is wave-5 e2e — never silently dropped). + `-R` is wave-5 e2e, never silently dropped). ## Acceptance Criteria @@ -72,7 +73,7 @@ registry that serves: - [x] The acceptor registry lands (shape decided + recorded): served only under a registered acceptor under `tunnel`; unregistered/unfeatured server-initiated opens declined cleanly - (never accept-and-drop) — wire rows per build + (never accept-and-drop), wire rows per build - [x] Late-install works across a live connection; a pre-install arrival is declined (decided + recorded, no unclaimed buffering) - [x] The data leg hands accepted opens to the assembly handler; @@ -87,16 +88,16 @@ registry that serves: - docs/architecture/client.md §Op rows (row 4), §The acceptor (fail-closed client-side), §Lifecycle primitives - docs/architecture/decisions/003-feature-graph.md (§4 the - client-side fail-closed acceptor — the rule this task implements), + client-side fail-closed acceptor, the rule this task implements), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (route 1, both legs) -- docs/research/reverse-runner.md (facts 1–2 — the acceptor row's +- docs/research/reverse-runner.md (facts 1–2, the acceptor row's disposition is ours; the late-install dynamic; lesson 1), docs/research/russh.md §Client surface (the `server_channel_open_*` hook set; the `tcpip_forward -> u32` signature) -- docs/research/poc-3-mapping-bakeoff.md §C.4 (the fabric recipe — +- docs/research/poc-3-mapping-bakeoff.md §C.4 (the fabric recipe, the family-side half the accepted opens feed) -- tests/common/harness.rs (`ForwardedAcceptor` — the vocabulary to +- tests/common/harness.rs (`ForwardedAcceptor`, the vocabulary to grow, not fork), src/tunnel_map.rs (the server-side listener bookkeeping posture as the template) @@ -107,36 +108,38 @@ registry that serves: Implementation decisions (also recorded in `src/client_accept.rs`'s module docs and `client.md` §The acceptor's landing block): -- **Registry shape — per-type fixed slots, not a typed map**: the +- **Registry shape (per-type fixed slots, not a typed map)**: the client-direction registry-servable set is closed (exactly the two `-R` data-leg types under `tunnel`); one acceptor trait per type (`TcpipForwardedAcceptor` / `StreamlocalForwardedAcceptor`), the payload an accepted open hands the assembly: the channel duplex - (family-typed — the `-L` rows' `TunnelChannel`, one type both legs - ride) + the open's echo/originator fields — the harness - `ForwardedAcceptor` vocabulary grown, not forked. Install is - **refuse-never-replace** (`AcceptorHeld` — the server-side + (family-typed, the `-L` rows' `TunnelChannel` that both legs + ride) plus the open's echo/originator fields, the harness + `ForwardedAcceptor` vocabulary grown and not forked. Install is + **refuse-never-replace** (`AcceptorHeld`, the server-side `ListenRouter::register` posture mirrored); uninstall is explicit and reports honestly. A new module `src/client_accept.rs` (one module per file; the rows' method bodies live beside the registry). - **The listen rows gate on the registered acceptor** (the - issuing-side disposition — a listen whose data leg cannot serve is + issuing-side disposition, so a listen whose data leg cannot serve + is never asked for; install-first recorded order; `OpError::NoAcceptor` pre-wire). The cancel forms do *not* gate on the acceptor (teardown must work after an uninstall). - **Bound-port mapping** (recorded wire fact at this pin): russh's client `tcpip_forward` reply carries a port **only for - `port = 0` requests** (sshd parity — an explicit request's bound + `port = 0` requests** (sshd parity, since an explicit request's bound port *is* the requested port); `reverse_listen` maps both to the actually-bound number it returns. -- **Bounded bookkeeping — the cap relation**: two dedicated +- **Bounded bookkeeping (the cap relation)**: two dedicated `ChannelCap` counters, each against the same `Limits::max_concurrent_channels` ceiling (the server-side listener/ListenerCap posture mirrored): (a) the per-connection - **reverse-listen count** — admitted at the listen rows, released at - confirmed cancels and the refused/wire-error paths (never a map — - the outstanding listens live at the peer); (b) the **accepted-open - handoff count** — admitted in the dial handler's open hook before + **reverse-listen count**, admitted at the listen rows, released + at + confirmed cancels and the refused/wire-error paths (never a map, + since the outstanding listens live at the peer); (b) the **accepted-open + handoff count**, admitted in the dial handler's open hook before the accept, released when the acceptor's `accept` call completes in the spawned handoff task; a full counter declines the arrival (`cap-exceeded`, event vocabulary). No accepted-open ledger: the @@ -146,14 +149,15 @@ module docs and `client.md` §The acceptor's landing block): bind is the platform-bound op; `forwarded-streamlocal` *ingestion* is platform-neutral byte pumping, hook universal compile). - **Pre-existing wart fixed**: `client_ops.rs`'s test mod gated - `family_exit_code` under a `tunnel`-inclusive cfg — the + `family_exit_code` under a `tunnel`-inclusive cfg, so the `--features tunnel` build did not compile its tests before this task; the import/test are now gated to the exit-consuming builds. Surprises: russh's client reply shapes (above, the port-0-only mint) and the `JoinHandle::drop`-is-detach-not-cancel behavior in the double's cancel teardown (first draft removed the entry without -aborting the accept loop — the real listener stayed bound; the double +aborting the accept loop, so the real listener stayed bound; the +double aborts explicitly now). ## Summary @@ -173,49 +177,49 @@ Details in the `Landed` block below. Landed (`feat(client)`: w4-reverse-listen): -- **`src/client_accept.rs`** — the acceptor registry +- **`src/client_accept.rs`**: the acceptor registry (`AcceptorRegistry` + the two per-type acceptor traits + the `ForwardedTcpip`/`ForwardedStreamlocal` payloads + `AcceptorHeld`), the reverse-listen rows (`reverse_listen` / `reverse_listen_cancel` / the unix pair, with the gates + dedicated counters), and the accepted-opens' handoff tasks; exports from the crate root under `tunnel`. -- **`src/client.rs`** — the dial wires the (empty) registry + +- **`src/client.rs`**: the dial wires the (empty) registry plus tunnel surface into the handler and the connection - (`SshConnection::acceptors` — the live-connection accessor; + (`SshConnection::acceptors`, the live-connection accessor; the listener budget's private field). -- **`src/client_handler.rs`** — the two forwarded hooks route through +- **`src/client_handler.rs`**, the two forwarded hooks route through the registry at arrival time (registered + room → accept + spawned handoff; unregistered/cap-exhausted → clean `Administratively- Prohibited` decline with the event vocabulary); every other type keeps the decline-all skeleton; the never-feature rows unchanged. -- **`src/error.rs`** — `OpError::NoAcceptor` / `ListenRefused` / +- **`src/error.rs`**, holding `OpError::NoAcceptor` / `ListenRefused` / `ListenCapExceeded` + the `RequestDenied → ListenRefused` mapping (the listen/cancel forms' only wire decline shape at this pin). -- **`tests/common/sshd_double.rs`** — the fake `sshd`'s `-R` server +- **`tests/common/sshd_double.rs`**, the fake `sshd`'s `-R` server half: the four listen/cancel hooks (scripted accept/deny via `DblListen`; an accept binds a **real** socket, mints the real bound port for `port = 0` into the out-param, spawns the accept - loop — per accepted connection a server-initiated `forwarded-*` + loop; per accepted connection a server-initiated `forwarded-*` open echoing the listen's args + the peer, then the bytes pumped; cancels match by the raw wire strings, tearing the real listener down) + the listen/cancel observations. -- **`tests/client_reverse_listen.rs`** (`tunnel` build) — the +- **`tests/client_reverse_listen.rs`** (`tunnel` build), carrying the contract rows: listen + data-leg pump round trip (real `ssh -R`-shaped), the port-0 mint, the no-acceptor pre-wire declines (listen + open, with the event rows), the late-install rows (pre-install declined → serve-after; uninstall takes it off), - the cancel rows (teardown observable — reconnect refused; unknown + the cancel rows (teardown observable: reconnect refused; unknown → `ListenRefused`), the listener-ceiling row (exceeded pre-wire; cancel recovers), the registry contract through the accessor, and the streamlocal unix rows (+ the compiled off-Unix gate row). Per-build matrix: the *unfeatured* builds' wire rows run in every build through `tests/client_dial.rs`'s decline-all row (the surface itself is the compile-time mirror). -- **`tests/tunnel_mapping.rs`** — the `client_smoke` module: the +- **`tests/tunnel_mapping.rs`**, the `client_smoke` module: the full-circle alkssh-client ↔ alkssh-server-direction smoke (both `-R` legs through the alkcall fabric double over a real TCP - mount) — the port-0 mint through the server direction's fabric + mount), with the port-0 mint through the server direction's fabric listen + arrival pump into the registered acceptor's duplex, bytes round-tripped end to end; real-`sshd` `-R` remains wave-5 e2e (recorded, never silently dropped). diff --git a/tasks/implementation/w4-router-collision.md b/tasks/implementation/w4-router-collision.md index 93417c6..6a46e3a 100644 --- a/tasks/implementation/w4-router-collision.md +++ b/tasks/implementation/w4-router-collision.md @@ -1,6 +1,6 @@ --- id: w4-router-collision -name: Wave 4 — ListenRouter token-collision pre-check (wave-3 gate booked item 4) +name: "Wave 4: ListenRouter token-collision pre-check (wave-3 gate booked item 4)" status: completed depends_on: [w3-tunnel-listen] scope: single @@ -17,20 +17,22 @@ token-collision pre-check**. `register` silently *replaces* on token collision today; safety rests on the fabric's ADR-008 mint discipline (collision "can't happen" upstream). The gate's disposition: a fail-closed decline-the-listen-on-collision is a cheap wave-4 -addition — the same posture the gate landed inline for duplicate +addition, following the same posture the gate landed inline for +duplicate `tunnel/forwarded` op registration (`install_forwarded_surface` now refuses with `AdapterError::ListenRegistration` instead of trusting the silent-replace). - The pre-check: a `register` on an already-held token **refuses** (the listen declines fail-closed) instead of silently replacing the - held route — a collided mint can never steal a live adapter's + held route, so a collided mint can never steal a live adapter's arrival routes. - The error surface: reuse/extend the `AdapterError::ListenRegistration` - class (decide + record — a distinct variant only if the gate's + class (decide + record, with a distinct variant only if the gate's variant can't carry the distinction honestly). - Wire row(s): the collision constructible (two registers on one - token — the test forces what the fabric's discipline prevents) → + token, with the test forcing what the fabric's discipline prevents) + → the second refuses, the first route still serves (the steal is impossible, row-pinned); the existing arrival/cancel/teardown rows stay green. @@ -47,11 +49,11 @@ the silent-replace). ## References - tasks/implementation/review-wave-3.md §Booked findings item 4 (and - §Minor issues — the `install_forwarded_surface` pre-check this + §Minor issues, the `install_forwarded_surface` pre-check this mirrors) - src/tunnel_map.rs (the `ListenRouter`), src/error.rs (the `AdapterError` class), docs/architecture/decisions/ (the fabric's - ADR-008 mint discipline — the upstream guarantee the pre-check + ADR-008 mint discipline, the upstream guarantee the pre-check defends in depth against) ## Notes @@ -60,7 +62,7 @@ the silent-replace). - **`ListenRouter::register` (src/tunnel_map.rs)** now returns `Result<(), String>`: a register on an already-held token returns - `Err` and **never replaces** the held route — a collided mint can + `Err` and **never replaces** the held route, so a collided mint can never steal a live adapter's arrival routes. The refusal detail carries the token + the steal posture. - **Error surface (decided + recorded):** the refusal surfaces as the @@ -68,7 +70,7 @@ the silent-replace). `token-collision`), not a distinct `AdapterError` variant. The deciding fact: a global-request hook's `Err` is the taxonomy's only route into the request surface, and russh 0.65 propagates it with - `?` out of the packet loop — killing the whole session — while the + `?` out of the packet loop, which kills the whole session, while the vanilla contract for a refused listen is `RequestDenied`. `AdapterError::ListenRegistration` honestly carries the construction-time class it always did (the doc in src/error.rs @@ -78,38 +80,38 @@ the silent-replace). **before** the `&mut u32` out-param write (a refused listen never writes the bound port); on refusal the path releases the listener cap, emits `decline_global_request(row, "token-collision")` (the - uniform vocabulary, closed — no dynamic fields), and tears the + uniform vocabulary, closed, with no dynamic fields), and tears the minted fabric listener down immediately in a spawned task - (`TunnelListener::close` — the cancel path's mechanism; a refused + (`TunnelListener::close`, the cancel path's mechanism; a refused registration never left a half state). - **Wire row** (`tests/tunnel_mapping.rs` `a_collided_mint_is_refused_and_never_steals_the_live_route`): the fabric double gained a mint-strategy construct - (`wire_fabric_with_listen_mint` + `MintStrategy::WithCollisions` — - the first N mints return a fixed token, then the ADR-008 counter + (`wire_fabric_with_listen_mint` + `MintStrategy::WithCollisions`, + where the first N mints return a fixed token, then the ADR-008 counter continues; the test forces exactly what the fabric's discipline prevents). The row pins, against a 2-count listener ceiling (the - ceiling that admits the collided mint past the cap — the cap row's - shape would decline at the cap before the register — and makes the + ceiling that admits the collided mint past the cap, since the cap row's + shape would decline at the cap before the register, and it makes the release observable): the first listen registers normally, the colliding mint's listen answers `RequestDenied`, its bound endpoint - never serves again (teardown observable — the cancel row's + never serves again (teardown observable, per the cancel row's bounded-retry shape), the listener count was released (a fresh - `port = 0` mint is admitted and registers — and its success reply + `port = 0` mint is admitted and registers, with its success reply proves the shape), and the first route **still serves** end to end - (an arrival opens the echoed `forwarded-tcpip` and the bytes ride) - — the steal is impossible, row-pinned. + (an arrival opens the echoed `forwarded-tcpip` and the bytes ride); + the steal is impossible, row-pinned. ## Summary **Completed.** The `ListenRouter` token-collision pre-check landed fail-closed (register refuses, never replaces) with the error-surface decision recorded: the wire-time collision is the decline disposition -(`token-collision` / `RequestDenied`), not the taxonomy — an `Err` -would kill the whole russh session — while `AdapterError:: +(`token-collision` / `RequestDenied`), not the taxonomy, since an `Err` +would kill the whole russh session, while `AdapterError:: ListenRegistration` keeps its construction-time class. The refusal path is the full fail-closed shape (cap release, uniform decline -event, immediate fabric teardown — no half state). Row-pinned through +event, and immediate fabric teardown, so no half state). Row-pinned through a mint-strategy construct on the fabric double: second register refuses, first route still serves end to end, refused endpoint never serves, count released. Full verification set green: cargo test diff --git a/tasks/implementation/w4-tty-coverage.md b/tasks/implementation/w4-tty-coverage.md index 11d09f1..13bc01e 100644 --- a/tasks/implementation/w4-tty-coverage.md +++ b/tasks/implementation/w4-tty-coverage.md @@ -1,6 +1,6 @@ --- id: w4-tty-coverage -name: Wave 4 — tty coverage minors (wave-3 gate booked item 3) +name: "Wave 4: tty coverage minors (wave-3 gate booked item 3)" status: completed depends_on: [w4-channel-not-held-rows] scope: narrow @@ -12,23 +12,23 @@ level: implementation ## Description Close the wave-3 gate's booked finding item 3 -(`review-wave-3` §Booked findings — "minors, one ticket's worth"): +(`review-wave-3` §Booked findings, "minors, one ticket's worth"): - **The `channel_close`-fired per-channel cancel path is - unexercised on the wire** — only the teardown signal is. Land the + unexercised on the wire**; only the teardown signal is. Land the wire row: a channel closing (client-side close on a served tty row) fires the per-channel cancel path (the cap release + the - per-channel state teardown observed — the disjoint release sites + per-channel state teardown observed, the disjoint release sites the gate verified per row, now wire-witnessed for this path). -- **The Closed/Break no-exit-chunk close shape has no wire row** — +- **The Closed/Break no-exit-chunk close shape has no wire row**, so land the row(s): a session ending via the Closed/Break close shape emits **no exit chunk** (the derivation contract's close-shape - class — the absent-report posture pinned, not just the happy + class, the absent-report posture pinned, not just the happy paths). -- **The tty stdout `write_all` carries no deadline** — record as a +- **The tty stdout `write_all` carries no deadline**, recorded as a **known limit only** (module docs): backpressure is bounded, liveness is covered by the inactivity timeout; no code change (the gate's - recorded disposition — a deadline here is a behavior change, not a + recorded disposition, since a deadline here is a behavior change, not a coverage minor). Depends on `w4-channel-not-held-rows` to keep `tests/tty_mapping.rs` @@ -49,56 +49,58 @@ settled file). - tasks/implementation/review-wave-3.md §Booked findings item 3 - docs/architecture/mapping.md §The exit derivation contract (the - close-shape classes; the PTY-path rule-5 limit — the docs-posture + close-shape classes; the PTY-path rule-5 limit, the docs-posture template), src/tty_map.rs, src/channel_cap.rs (the release sites the rows witness) - tests/tty_mapping.rs (the per-build matrix the rows join) ## Notes -- **Test + module-docs only, as planned** — no production code change; +- **Test + module-docs only, as planned**, hence no production code + change; the shapes held on the wire exactly as documented. -- **`channel_close`-fired cancel path** — +- **`channel_close`-fired cancel path**, namely `a_client_close_on_a_served_row_fires_the_per_channel_cancel_and_releases_the_cap` (`tests/tty_mapping.rs`, cfg `session`): a served pipe row under a - cap of 1; a second open is refused (`AdministrativelyProhibited` — + cap of 1; a second open is refused (`AdministrativelyProhibited`, the admission held), the client then closes the served channel, and - the row witnesses (a) the per-channel state teardown — the shim's + the row witnesses (a) the per-channel state teardown, with the + shim's kill-on-drop witness fires (the bridge surface's cancel → the serving task aborts the alktty session), polled *before* unmount so it is the `channel_close` site, not the teardown; (b) no exit report fabricated on the wire (plain close / channel end); (c) the serving - path's ended lifecycle event; (d) the **cap release** — the next + path's ended lifecycle event; (d) the **cap release**, with the next open admits and routes (release site 1, disjoint from the bridge-failure and arrival sites). -- **Closed/Break no-exit-chunk close shape** — +- **Closed/Break no-exit-chunk close shape**, namely `a_bridge_end_without_an_exit_chunk_reports_nothing_and_closes_plain` (same file): the shape is constructed deterministically through the - real path — the shim's stdout carries a payload beyond alktty's own + real path: the shim's stdout carries a payload beyond alktty's own `MAX_CHUNK_LEN` wire bound; alktty's `ChunkWriter` rejects it locally, its drainer shuts the write half down, and the bridge's next read is EOF (`BridgeFrame::Closed`) with the scripted exit (`Some(0)`) never forwarded. The row pins: the in-bounds chunk rode first, the end is a plain close, **no exit-status/exit-signal ever - rides** (the absent-report posture — neither 0 nor 255), the session + rides** (the absent-report posture, neither 0 nor 255), the session truly served (an allocation happened, so this is the close shape, not a decline), and the ended lifecycle event landed. -- **Break's classification row** — +- **Break's classification row**, which pins `clean_ends_classify_closed_and_oversized_lengths_classify_break` (`src/tty_map.rs` test mod): Break is **unreachable on the wire** through alktty's own validated `ChunkWriter` (an oversized chunk - fails local validation → the drainer break → the read sees EOF — + fails local validation → the drainer break → the read sees EOF, exactly the Closed row's construction; a non-EOF duplex read error has no wire-constructible producer at this pin, and the serving-path body of Closed/Break is one arm). So the family's *serving* outcome is wire-witnessed by the Closed row, and the frame-level `Break` classification (bounds-checked, never an - oversized read) is pinned at the frame layer by the unit row — + oversized read) is pinned at the frame layer by the unit row, recorded here as the reason one wire row + one unit row cover the "row(s)". -- **The stdout `write_all` no-deadline limit** — recorded in +- **The stdout `write_all` no-deadline limit**, recorded in `src/tty_map.rs`'s module docs (§the translation bridge): the gate's - recorded disposition verbatim — backpressure bounded, liveness + recorded disposition verbatim; backpressure bounded, liveness covered by the inactivity timeout, a deadline would be a behavior change, not a coverage minor. No code change. - A `session_with_limits` / `session_config(limits)` driver variant @@ -110,19 +112,19 @@ settled file). The wave-3 gate's booked item 3 is closed: the `channel_close`-fired per-channel cancel path is now wire-witnessed (a served pipe row under a cap of 1: the refused over-cap open pins the admission, the client -close pins the cancel — the kill-on-drop witness fires before any +close pins the cancel, with the kill-on-drop witness firing before any teardown, no exit report is fabricated, the ended lifecycle event -lands — and the succeeding re-open pins the cap release from the +lands, and the succeeding re-open pins the cap release from the established-channel-close release site); the Closed/Break no-exit-chunk close shape landed its wire row (constructed through the real path via an oversized shim chunk dying at alktty's own `ChunkWriter` validation: EOF without an exit chunk → plain close, nothing fabricated, the scripted exit never forwarded) plus the frame-level Break classification row (Break is wire-unreachable -through alktty's validated writer at this pin — recorded); and the +through alktty's validated writer at this pin, as recorded); and the stdout `write_all` no-deadline limit is recorded in the module docs per the gate's disposition. No production code change. Verified: `cargo test` (default, `session`, `session,git`, `git`, `tunnel`, -`sftp`, `--all-features` — the tty rows' 5×-repeat flake check +`sftp`, `--all-features`, with the tty rows' 5×-repeat flake check included), clippy ×2 (`-D warnings`), `cargo fmt --check`, `cargo doc --no-deps` 0 warnings, `taskgraph validate` ✓ (35 tasks). \ No newline at end of file diff --git a/tasks/implementation/w5-decline-matrix.md b/tasks/implementation/w5-decline-matrix.md index ef5954f..9506886 100644 --- a/tasks/implementation/w5-decline-matrix.md +++ b/tasks/implementation/w5-decline-matrix.md @@ -1,6 +1,6 @@ --- id: w5-decline-matrix -name: Wave 5 — the decline-matrix contract suite (every build × every row × both directions) +name: "Wave 5: the decline-matrix contract suite (every build × every row × both directions)" status: completed depends_on: [review-wave-4] scope: moderate @@ -12,40 +12,42 @@ level: implementation ## Description The complete decline-matrix contract suite (`mapping.md` §Decline -matrix — the fail-closed invariant is contract territory on both +matrix, where the fail-closed invariant is contract territory on +both sides of every feature, AGENTS.md conventions 5–6): **every build** (none / `git` / `session` / `tunnel` / `--all-features`) **× every row it can appear in × both directions**, including the sftp placeholder rows (server subsystem + client-side, OQ-13) and the -never-features (x11, `auth-agent` — never feature-gated, ADR-003 §5). +never-features (x11 and `auth-agent`, never feature-gated per +ADR-003 §5). Waves 2–4 already landed per-build rows incrementally (`tests/dispatch_core.rs`, `tests/client_dial.rs`'s decline-all row, the mapping binaries' per-feature matrices); this task makes the matrix **complete and systematic** rather than accreted: -- **The coverage audit** — every row of `mapping.md`'s tables +- **The coverage audit**, i.e. every row of `mapping.md`'s tables (channel-type rows, session-request rows, the global-request `-R` rows, the sftp placeholder rows both directions, the never-features) × every build it can appear in × both directions → where it is pinned today (binary + test name). Recorded as a table in this task's Notes; the audit is the gate's checklist. -- **Gap rows landed** — every cell the audit finds unpinned gets its +- **Gap rows landed**, so every cell the audit finds unpinned gets its row in the existing per-build binaries (the single-writer map recorded: which binaries this task edits, so the e2e tasks' new binaries stay disjoint). -- **The matrix runner** — the per-build verification set (cargo test +- **The matrix runner**, the per-build verification set (cargo test per build + `--all-features`, clippy ×2 `-D warnings`) as one - command (a script under `scripts/` or the documented command list — - decide + record the vehicle), joining the verification list so the + command (a script under `scripts/` or the documented command list, + with the vehicle decided and recorded), joining the verification list so the full matrix is one invocation from here on (the corpus gate's sibling posture). -- **The sftp rows explicit in every build** — the feature gates +- **The sftp rows explicit in every build**, since the feature gates nothing (no dep, no behavior change); the decline-with-reason rows (server subsystem + client-side placeholder) are pinned in every build, and the `sftp`-only build compiles + declines identically (the no-op-feature fact itself verified, not assumed). -- **The never-features in every build, both directions** — x11 / +- **The never-features in every build, both directions**: x11 / `auth-agent` decline rows server-side (open + any request shape) and client-side (the decline-all skeleton's server-initiated-open rows), never advertised, never feature-gated. @@ -95,20 +97,20 @@ direction, C = client direction; `dispatch_core` = tests/dispatch_core.rs. | git-parseable exec declined (the `git` parse is session's gate) | session | S | `session_build::git_parseable_exec_declines_in_a_session_build` | | unknown git services declined surface | git | S | `git_build::unknown_git_services_take_the_declined_surface`, tests/git_mapping.rs `unknown_services_decline_request_level_with_alive_channel` | | subsystem `sftp` declines with reason (OQ-13) | git, session, all-features | S | `git_build::subsystems_decline_with_their_named_reasons_in_a_git_build`, `session_build::subsystems_decline_with_their_named_reasons_in_a_session_build`, `all_features::capability_declines_hold_with_all_features` | -| subsystem `sftp` declines (the feature gates nothing) | sftp-only, none, tunnel | S | session channels decline at open (row 1) — the subsystem row cannot appear; the sftp-only run exercises the identical none-build declining behavior below | +| subsystem `sftp` declines (the feature gates nothing) | sftp-only, none, tunnel | S | session channels decline at open (row 1), so the subsystem row cannot appear; the sftp-only run exercises the identical none-build declining behavior below | | subsystem `sftp` declines (client-side placeholder, before any wire op) | every build | C | tests/client_ops.rs `sftp_placeholder_declines_in_every_build_before_any_wire_op` (+ `sftp_feature_on_the_placeholder_still_declines`, session builds) | | subsystem git service names decline (no repo id on a subsystem) | git, session, all-features | S | same subsystem rows as `sftp` + tests/git_mapping.rs `service_only_declines_request_level_with_alive_channel` | -| env_request declines unconditionally (OQ-09) | git, session, all-features | S | `git_build::env_requests_decline_in_a_git_build`, `session_build::env_requests_decline_in_a_session_build`, `all_features::capability_declines_hold_with_all_features` (+ C-side: env never sent ahead of any op — no client env op exists) | +| env_request declines unconditionally (OQ-09) | git, session, all-features | S | `git_build::env_requests_decline_in_a_git_build`, `session_build::env_requests_decline_in_a_session_build`, `all_features::capability_declines_hold_with_all_features` (+ C-side: env never sent ahead of any op, as no client env op exists) | | never-feature x11: channel open | every build | S | `dispatch_core::x11_channel_opens_decline_in_every_build` (shared) | | never-feature x11: request shape | git, session, all-features (rows need a session channel) | S | `dispatch_core::x11_and_agent_requests_decline_wherever_sessions_open` | | never-feature x11: server-initiated open | every build | C | tests/client_dial.rs `server_initiated_opens_decline_cleanly_on_the_wire` (the decline-all skeleton, `channel_open_x11` + the `never-feature` event row) | -| never-feature auth-agent: channel open | — | S/C | **pin-bound upstream**: russh 0.65 declines a server-received `ChannelType::AgentForward` open itself (`ADMINISTRATIVELY_PROHIBITED` pre-handler, russh-0.65/src/server/encrypted.rs), and exposes no client-initiated agent open at this pin — the row is unreachable through the public API in either direction; recorded here per ADR-002 (the pin's decline is part of the pin) | +| never-feature auth-agent: channel open | none | S/C | **pin-bound upstream**: russh 0.65 declines a server-received `ChannelType::AgentForward` open itself (`ADMINISTRATIVELY_PROHIBITED` pre-handler, russh-0.65/src/server/encrypted.rs), and exposes no client-initiated agent open at this pin, so the row is unreachable through the public API in either direction; recorded here per ADR-002 (the pin's decline is part of the pin) | | never-feature auth-agent: request shape | git, session, all-features | S | `x11_and_agent_requests_decline_wherever_sessions_open` (the agent-forward request) | | never-feature auth-agent: server-initiated open | every build | C | tests/client_dial.rs `server_initiated_opens_decline_cleanly_on_the_wire` (`channel_open_agent`) | | direct-tcpip open | unfeatured (none/git/session); seam-unset (tunnel/all-features) | S | `default_build::every_channel_open_declines_in_a_default_build`, `git_build::tunnel_opens_decline_unfeatured_in_a_git_build`, `session_build::tunnel_opens_decline_unfeatured_in_a_session_build`, `tunnel_build::tunnel_direct_rows_decline_seam_unconfigured_without_a_handle` (`ConnectFailed`), `all_features::tunnel_direct_rows_decline_seam_unconfigured_without_a_handle_with_all_features` | | direct-streamlocal open (OQ-11) | same builds as direct-tcpip | S | same rows + tests/tunnel_mapping.rs `direct_streamlocal_declines_off_unix_before_any_fabric_call` (off-Unix builds) | | direct rows serve (the matrix's serve side) | tunnel, all-features | S | tests/tunnel_mapping.rs `direct_tcpip_serves_through_the_route_1_bridge` etc. | -| forwarded-tcpip inbound open (unvanilla class, `ConnectFailed`) | — | S | **pin-bound**: russh 0.65's client `Handle` exposes no forwarded-tcpip open (only servers initiate it), so the wire row is unreachable from any vanilla-client driver; the hook's decline is recorded at `src/handler.rs::channel_open_forwarded_tcpip` (`w3-tunnel-listen`'s recorded wire classes). Both serve-side and decline-side of the `-R` data leg: tests/tunnel_mapping.rs's arrival rows | +| forwarded-tcpip inbound open (unvanilla class, `ConnectFailed`) | none | S | **pin-bound**: russh 0.65's client `Handle` exposes no forwarded-tcpip open (only servers initiate it), so the wire row is unreachable from any vanilla-client driver; the hook's decline is recorded at `src/handler.rs::channel_open_forwarded_tcpip` (`w3-tunnel-listen`'s recorded wire classes). Both serve-side and decline-side of the `-R` data leg: tests/tunnel_mapping.rs's arrival rows | | forwarded-tcpip / forwarded-streamlocal server-initiated opens, no acceptor | every build | C | tests/client_dial.rs `server_initiated_opens_decline_cleanly_on_the_wire` + tests/client_reverse_listen.rs (tunnel builds: `reverse_listen_without_an_acceptor_declines_before_any_wire_op`, `unregistered_forwarded_open_declines_cleanly`, the late-install/uninstall rows) | | `-R` listen: `tcpip_forward` declines | every build (unfeatured gate / tunnel seam-unset) | S | `dispatch_core::tcpip_forward_listen_requests_decline_in_every_build` (shared); tunnel serving + cap + mint rows: tests/tunnel_mapping.rs | | `-R` listen: `streamlocal_forward` declines | every build | S | `dispatch_core::streamlocal_forward_listen_requests_decline_in_every_build` (**gap closed**: the unix listen form's decline had no shared row) | @@ -116,14 +118,14 @@ direction, C = client direction; `dispatch_core` = tests/dispatch_core.rs. | listen seam unset declines the listen forms | tunnel, all-features | S | tests/tunnel_mapping.rs `listen_forms_decline_when_the_listen_seam_is_unset` | | window_change / signal (fire-and-forget, no decline shape) | session, all-features | S | no wire row by contract (mapping.md §Request-level rows): the serving rows pin the path (tests/tty_mapping.rs resize/signal rows); unfeatured builds emit the decline event only | | client op declines (the acceptor's `NoAcceptor` pre-wire, the op-row `OpError` classes) | session/tunnel/git builds respectively | C | tests/client_ops.rs `session_rows`/`tunnel_rows`/`git_rows` + tests/client_reverse_listen.rs | -| sftp-only build is the no-op feature | sftp-only | both | `scripts/matrix.sh` runs the full suite under `--features sftp`: the same none-build decline rows + the shared rows + the C-side placeholder run unmodified — the no-op fact (no dep, no behavior change) verified, not assumed | +| sftp-only build is the no-op feature | sftp-only | both | `scripts/matrix.sh` runs the full suite under `--features sftp`: the same none-build decline rows + the shared rows + the C-side placeholder run unmodified; the no-op fact (no dep, no behavior change) verified, not assumed | **Gaps found and landed** (tests/dispatch_core.rs, the single-writer map: this task edits only dispatch_core's rows, `scripts/` + -AGENTS.md — the e2e tasks' new binaries stay disjoint): streamlocal +AGENTS.md, so the e2e tasks' new binaries stay disjoint): streamlocal listen decline, cancel-form refusals, git-build pty. -**The matrix runner vehicle decided**: the script route — +**The matrix runner vehicle decided**: the script route, namely `scripts/matrix.sh` (the per-build verification set as one command; the documented-command-list alternative rejected on the one-invocation requirement, the alkgit corpus-gate posture). Joined @@ -141,10 +143,10 @@ no-op-feature fact is verified by the `sftp`-only build running the identical decline suite, the never-features are pinned in every build both directions (the auth-agent open recorded pin-bound upstream with the exact russh-0.65 mechanism), and the matrix runner landed as -`scripts/matrix.sh` (tests × 6 builds — none / git / session / tunnel -/ sftp / --all-features — then clippy ×2 `-D warnings`, fmt, doc) and +`scripts/matrix.sh` (tests × 6 builds: none / git / session / tunnel +/ sftp / --all-features; then clippy ×2 `-D warnings`, fmt, doc) and joined AGENTS.md's verification list. Verification: the full -`scripts/matrix.sh` run green — every `cargo test` build (84…114 lib +`scripts/matrix.sh` run green, with every `cargo test` build (84…114 lib tests per build, all integration binaries included), both clippy runs `-D warnings` clean, fmt clean, `cargo doc --no-deps` clean. No src/ -changes were needed — the gaps were all test-graph rows. \ No newline at end of file +changes were needed, given the gaps were all test-graph rows. \ No newline at end of file diff --git a/tasks/implementation/w5-e2e-client.md b/tasks/implementation/w5-e2e-client.md index 4c645ae..3e4a38a 100644 --- a/tasks/implementation/w5-e2e-client.md +++ b/tasks/implementation/w5-e2e-client.md @@ -1,6 +1,6 @@ --- id: w5-e2e-client -name: Wave 5 — real-tool e2e, client direction (the door against a real sshd) +name: "Wave 5: real-tool e2e, client direction (the door against a real sshd)" status: completed depends_on: [review-wave-4] scope: moderate @@ -11,41 +11,42 @@ level: implementation ## Description -The client-direction real-tool e2e — the reverse-runner shape +The client-direction real-tool e2e, which takes the reverse-runner +shape re-landed against a **real `sshd`** (`docs/plans/implementation.md` §Wave 5, the real-tool e2e bullet's client half; the wave-4 client rows ran against the in-process fake `sshd` double, and the wave-4 gate named real-`sshd` e2e wave-5 territory explicitly). A dedicated -new binary (decide + record the placement — `tests/e2e_client.rs` +new binary (decide + record the placement, for which `tests/e2e_client.rs` reusing `tests/common` is the recommended posture, disjoint from the matrix task's binaries), under the check-gated **skipped-with-reason** posture: -- **The sshd fixture** — a spawned real OpenSSH server the dial +- **The sshd fixture**: a spawned real OpenSSH server the dial targets: host key generated (ed25519), a listen port picked, the `HostKeyPolicy` accepting the fixture's known fingerprint (the - case-1 pin posture — the policy seam consumed against a real + case-1 pin posture, with the policy seam consumed against a real presented key), a generated ed25519 user key authorized in the fixture's `authorized_keys` (the decrypt-at-dial path over the in-memory vault double). The fixture's exact shape (spawned - per-test vs a shared per-binary instance; the sshd_config knobs — + per-test vs a shared per-binary instance; the sshd_config knobs, publickey-only, no password) decided + recorded. -- **The op rows against real sshd** — exec (exit-status parity +- **The op rows against real sshd**: exec (exit-status parity through a real server's reaping), the direct-tcpip dial (a real - TCP connection through the server's forwarding — `-L`-shaped), + TCP connection through the server's forwarding, `-L`-shaped), **the `-R` leg**: `tcpip_forward` with the port-0 mint against a real server's actually-bound reply, the server-initiated `forwarded-tcpip` open served through the acceptor registry (the reverse-runner consumer pattern end to end), the cancel form tearing the real listen down. -- **The decline rows against a real server** — the sftp client-side +- **The decline rows against a real server**, the sftp client-side placeholder decline (`subsystem` sftp → the typed decline), the - unfeatured direct-tcpip decline (the connection stays usable — - the typed-open decline mirror's real-server row), the host-key + unfeatured direct-tcpip decline (the connection stays usable, + which is the typed-open decline mirror's real-server row), the host-key policy mismatch hard-decline (a wrong-fingerprint fixture → no dial, the case-1 posture against a real presented key). - **Every skip reasoned** (no `sshd` in the environment / fixture - constraints), every finding fixed in-task or booked for the gate — + constraints), every finding fixed in-task or booked for the gate, never absorbed silently. ## Acceptance Criteria @@ -76,14 +77,14 @@ matrix task's binaries), under the check-gated - tests/client_dial.rs, tests/client_ops.rs, tests/client_reverse_listen.rs (the double-driven rows this task mirrors against the real server), tests/common/sshd_double.rs (the - double's semantics — what the real fixture must match) + double's semantics, which the real fixture must match) ## Notes -**The sshd fixture decided**: `tests/e2e_client.rs` (the new binary), reusing `tests/common` — the +**The sshd fixture decided**: `tests/e2e_client.rs` (the new binary), reusing `tests/common`, and the wave-4 client binaries stay single-writer per the decomposition's file map; this task edited only its own binary (`tests/e2e_client.rs`, an in-file `support` module). **Fixture shape: per-row -spawned real `sshd`** — each row's config knobs differ (`AllowTcpForwarding` yes/no) and each row +spawned real `sshd`**, since each row's config knobs differ (`AllowTcpForwarding` yes/no) and each row dials its own server, so a shared per-binary instance buys nothing; per-row isolation (own workdir, own minted ed25519 host key via `ssh-keygen`, own probe-verified port from the e2e-server's monotonic allocator posture) beats the spawn cost. Knob set: publickey-only, `StrictModes no` (the @@ -91,23 +92,23 @@ throwaway workdir carries none of the real home-dir hygiene), `UsePAM no`, `UseD `PidFile none`, `PermitRootLogin prohibit-password`, host key + authorized_keys (fixture `alpha`'s public blob) at absolute paths, per-row `AllowTcpForwarding`. Readiness is a bounded TCP probe; a server that never comes up is the row's **reasoned skip** with the server's own `-e` log tail as -the reason (worked as non-root in this environment — verified against OpenSSH 9.6p1). The dial +the reason (worked as non-root in this environment, verified against OpenSSH 9.6p1). The dial rides the decrypt-at-dial path over the in-memory vault double (`VaultKeySource` over `TestVault`, `alpha` sealed) and the case-1 pin posture (`MemHostKeyPolicy::pin`) accepts only the minted host fingerprint. **Row inventory** (all check-gated, `#[cfg(unix)]` + feature-gated per row group; deadline-bounded -end to end — the POC-1 §5 posture; `Contract stamp:` markers cite `client.md` / `reverse-runner.md`): +end to end (the POC-1 §5 posture); `Contract stamp:` markers cite `client.md` / `reverse-runner.md`): | Row | Build(s) | What it pins | |---|---|---| -| `exec_row::real_sshd_exec_pumps_streams_and_reports_the_exit_status` | session (+all) | the wave-4 double-driven exec row re-landed: stdout/stderr separated through a real server, the **nonzero** exit (3) reported in-band last — the real server's reaping, parity not a canned 0; the case-1 pin accept decision on record against the really-presented key | +| `exec_row::real_sshd_exec_pumps_streams_and_reports_the_exit_status` | session (+all) | the wave-4 double-driven exec row re-landed: stdout/stderr separated through a real server, the **nonzero** exit (3) reported in-band last, the real server's reaping, parity not a canned 0; the case-1 pin accept decision on record against the really-presented key | | `direct_tcpip_row::real_sshd_direct_tcpip_round_trips_a_live_tcp_connection` | tunnel (+all) | the `-L`-shaped dial through a real server's forwarding into a live loopback echo server, bytes end to end | -| `reverse_rows::real_sshd_port_zero_mint_serves_the_accepted_arrival_and_the_data_leg` | tunnel (+all) | the port-0 mint surfaces the actually-bound port (verified by dialing it), the server-initiated `forwarded-tcpip` arrives into the installed acceptor, the external dialer's bytes round trip — the reverse-runner consumer pattern end to end | +| `reverse_rows::real_sshd_port_zero_mint_serves_the_accepted_arrival_and_the_data_leg` | tunnel (+all) | the port-0 mint surfaces the actually-bound port (verified by dialing it), the server-initiated `forwarded-tcpip` arrives into the installed acceptor, the external dialer's bytes round trip, giving the reverse-runner consumer pattern end to end | | `reverse_rows::real_sshd_cancel_tears_the_listen_down_and_unknown_cancels_refuse` | tunnel (+all) | explicit-port listen actually bound; the listener handle's `cancel()` frees the **real** bound port (later dials refused); a stateless cancel of the unknown listener is `ListenRefused`; connection stays usable | | `declines::real_connection_sftp_placeholder_declines_with_the_connection_intact` | **every build** | the OQ-13 placeholder against a real authenticated connection: typed decline before any wire op, connection intact | -| `declines::real_presented_key_mismatch_hard_declines_and_the_right_pin_dials` | **every build** | the wrong-pinned policy hard-declines the dial (`DialError::HostKeyRejected`), the policy log records the **real** fingerprint as rejected, and the correctly-pinned dial to the same server succeeds — the decline is the policy's decision, not the server's | -| `unfeatured_direct_decline::real_server_refused_direct_tcpip_declines_and_the_connection_stays_usable` | tunnel+session (all-features) | a real server with `AllowTcpForwarding no` refuses the typed open → `OpenDeclined(Prohibited)`, and a following exec is served — the connection-stays-usable fact end to end | +| `declines::real_presented_key_mismatch_hard_declines_and_the_right_pin_dials` | **every build** | the wrong-pinned policy hard-declines the dial (`DialError::HostKeyRejected`), the policy log records the **real** fingerprint as rejected, and the correctly-pinned dial to the same server succeeds, and the decline is the policy's decision, not the server's | +| `unfeatured_direct_decline::real_server_refused_direct_tcpip_declines_and_the_connection_stays_usable` | tunnel+session (all-features) | a real server with `AllowTcpForwarding no` refuses the typed open → `OpenDeclined(Prohibited)`, and a following exec is served, the connection-stays-usable fact end to end | **Decisions recorded** (for the wave-5 gate): @@ -118,9 +119,10 @@ end to end — the POC-1 §5 posture; `Contract stamp:` markers cite `client.md` - The sftp and host-key-mismatch rows run in **every** build (feature-free ops on `SshConnection`), so even the no-feature matrix build exercises two real-server rows. - The fixture skips are honest: no `sshd` in the environment / no `ssh-keygen` / spawn refusal / - never-listening (the server's `-e` log tail carried in the reason) — never silently green. + never-listening (the server's `-e` log tail carried in the reason), never silently green. -**Findings**: no src/ changes needed — every observed issue was e2e-graph local +**Findings**: no src/ changes needed, as every observed issue was e2e-graph +local (compile/fmt-level only). No bookings for the gate beyond the decisions above. ## Summary @@ -130,7 +132,7 @@ fixture shape above): exec (exit-status parity, nonzero through real reaping), t dial, the `-R` leg (port-0 mint + accepted arrival + data leg + cancel tearing the real listen down + unknown-cancel `ListenRefused`), and the three decline rows (sftp placeholder, host-key mismatch hard-decline with the real presented fingerprint on record, server-refused direct-tcpip -with the connection staying usable). Verification: full `scripts/matrix.sh` run green — `cargo +with the connection staying usable). Verification: full `scripts/matrix.sh` run green: `cargo test` per build (none / git / session / tunnel / sftp / --all-features; the new binary compiles in every build and runs only its rows' builds: 2 / 2 / 3 / 5 / 2 / 7 rows), both clippy runs `-D warnings` clean, fmt clean, `cargo doc --no-deps` clean; plus 10 consecutive all-features diff --git a/tasks/implementation/w5-e2e-server.md b/tasks/implementation/w5-e2e-server.md index 24137e6..f3ff52c 100644 --- a/tasks/implementation/w5-e2e-server.md +++ b/tasks/implementation/w5-e2e-server.md @@ -1,6 +1,6 @@ --- id: w5-e2e-server -name: Wave 5 — real-tool e2e, server direction (OpenSSH / git / curl against the door) +name: "Wave 5: real-tool e2e, server direction (OpenSSH / git / curl against the door)" status: completed depends_on: [review-wave-4] scope: moderate @@ -11,41 +11,41 @@ level: implementation ## Description -The server-direction real-tool e2e — the POC-3 gates re-landed +The server-direction real-tool e2e, with the POC-3 gates re-landed against the shipped crate (`docs/plans/implementation.md` §Wave 5, the real-tool e2e bullet's server half). The wave-3 smokes proved one clone, one `-L`, one pty session; this task lands the **full** server-direction e2e set in a dedicated home (decide + record the -placement — a new `tests/e2e_server.rs` binary reusing `tests/common` +placement, where a new `tests/e2e_server.rs` binary reusing `tests/common` is the recommended posture, keeping the wave-3/4 mapping binaries single-writer for `w5-decline-matrix`), under the established check-gated **skipped-with-reason** posture (never silently green): -- **OpenSSH against the door** — pty/shell interactive session +- **OpenSSH against the door**: pty/shell interactive session (the wave-3 smoke's composition, re-homed or extended per the placement decision), plain exec (`ssh host "cmd"` through the exec wrapper), `-L` local forward through the tunnel mapping, - **`-R` remote forward** (the `w3-tunnel-listen` flow — listen + **`-R` remote forward** (the `w3-tunnel-listen` flow: listen request, bound port, the server-initiated `forwarded-tcpip` - arrival pumped through the fabric — the leg the in-process smokes + arrival pumped through the fabric, the leg the in-process smokes covered only through the alkcall fabric double), and **`-D`**: curl through the SOCKS proxy with the door as the server (the - `direct-tcpip` leg end to end — POC-3 correction 2's posture). -- **Decline observability** — a declined open/session surfaces a + `direct-tcpip` leg end to end, POC-3 correction 2's posture). +- **Decline observability**: a declined open/session surfaces a clean diagnostic to the real client (POC-1 §5 posture: channel-scoped decline, auth intact, no hangs): an unfeatured channel type from real `ssh -L`/`-R`/pty attempts in a feature-limited build, the sftp subsystem decline from a real `sftp`/`ssh -s` attempt, the unknown-repo collapsed generic denial from a real `git clone` of a nonexistent repo. -- **git 2.43+ through the git mapping** — clone (re-land) **and +- **git 2.43+ through the git mapping**: clone (re-land) **and push** (the wave-3 smoke was clone-only; receive-pack through the real client is the POC-3 §B gate re-landed). -- **The harness posture** — real processes (`ssh`/`git`/`curl`), the +- **The harness posture**: real processes (`ssh`/`git`/`curl`), the adapter mounted on a real TCP listener over the in-memory doubles (the existing smoke composition); every skip reasoned (tool absent / version short), every finding fixed in-task or booked for - the gate — never absorbed silently. + the gate, never absorbed silently. ## Acceptance Criteria @@ -67,7 +67,7 @@ check-gated **skipped-with-reason** posture (never silently green): - docs/plans/implementation.md §Wave 5 (the real-tool e2e bullet) - docs/architecture/mapping.md (the rows the real tools exercise), - docs/research/poc-3-mapping-bakeoff.md (stages A–D — the gates + docs/research/poc-3-mapping-bakeoff.md (stages A–D, the gates re-landed), docs/research/poc-1-auth-bridge.md §5 (decline observability) - tests/git_mapping.rs / tests/tty_mapping.rs / @@ -76,13 +76,13 @@ check-gated **skipped-with-reason** posture (never silently green): ## Notes -**The e2e home decided**: `tests/e2e_server.rs` (the new binary), reusing `tests/common` — the +**The e2e home decided**: `tests/e2e_server.rs` (the new binary), reusing `tests/common`, so the mapping binaries stay single-writer per the decomposition's file map; this task edits only its own binary. Its fixtures live in-file: a `support` module (tool gates, the fixture-key/option builders, the registered identity provider, the port allocator, the real-TCP mount) and a tunnel-gated -`fabric` module (the trimmed leg-1/leg-2 fabric double — TCP-only, happy-path: the decline and +`fabric` module (the trimmed leg-1/leg-2 fabric double, TCP-only and happy-path, with the decline and collision machinery stays the mapping binaries'). Everything is `#[cfg(unix)]` with the body -feature-gated per row group; no src/ changes were needed — every observed issue was e2e-graph +feature-gated per row group; no src/ changes were needed; every observed issue was e2e-graph local. **Row inventory** (all check-gated, **skipped-with-reason** when a tool is absent / short, never @@ -94,9 +94,9 @@ decline event through the capture's deadline-bounded poll): | `git_rows::real_git_clones_the_empty_repo_through_the_door` | git (+all) | the wave-3 clone smoke re-landed: real `git clone ssh://…` → unborn-HEAD advertisement, normalized resolved id | | `git_rows::real_git_push_creates_the_ref_through_the_door` | git (+all) | **push** (the wave-3 smoke's gap): receive-pack through the real client, rev parity (`refs/heads/main` new-oid == local `rev-parse HEAD`), CAS-application row | | `session_rows::real_openssh_pty_shell_session_serves_through_the_door` | session (+all) | the wave-3 pty smoke re-landed + extended: the `echo` marker rides the real PTY round trip, exit-status(0) before close | -| `session_rows::real_openssh_pipe_exec_separates_stdout_and_stderr_through_the_door` | session (+all) | `ssh host "cmd"` through the exec wrapper (`wrapper ++ [cmd]`): stdout rides SSH data, stderr rides `extended_data(1)` — POC-3 §D.1's pipe split end to end | +| `session_rows::real_openssh_pipe_exec_separates_stdout_and_stderr_through_the_door` | session (+all) | `ssh host "cmd"` through the exec wrapper (`wrapper ++ [cmd]`): stdout rides SSH data, stderr rides `extended_data(1)`, POC-3 §D.1's pipe split end to end | | `tunnel_rows::real_ssh_local_forward_serves_the_direct_tcpip_leg` | tunnel (+all) | `-L` smoke re-landed: route-1 bridge + real `tcp_dial`, body parity | -| `tunnel_rows::real_ssh_remote_forward_serves_the_arrival_and_data_legs` | tunnel (+all) | `-R`: listen, fabric-bound port, the server-initiated `forwarded-tcpip` arrival on the real wire, the client-dialed data leg — the leg the in-process smokes covered only through the fabric double | +| `tunnel_rows::real_ssh_remote_forward_serves_the_arrival_and_data_legs` | tunnel (+all) | `-R`: listen, fabric-bound port, the server-initiated `forwarded-tcpip` arrival on the real wire, the client-dialed data leg, the leg the in-process smokes covered only through the fabric double | | `tunnel_rows::real_ssh_dynamic_forward_serves_curl_through_the_direct_tcpip_leg` | tunnel (+all) | **`-D`**: curl `--socks5-hostname` through vanilla OpenSSH's client-side SOCKS → the door's `direct-tcpip` leg (POC-3 correction 2 end to end); two requests pin the per-connection channel opens | | `unfeatured_session_decline::real_openssh_pty_attempt_declines_cleanly_in_a_feature_limited_build` | not-session (none/git/tunnel/sftp) | real `ssh -tt … echo` → nonzero exit, clean diagnostic, no hang; the decline event pins the build's class (`pty_request` "unfeatured" request-decline in the git build, `session` "unfeatured" open-decline otherwise) | | `unfeatured_tunnel_declines::real_openssh_remote_forward_attempt_declines_cleanly` | not-tunnel | real `ssh -N -R -o ExitOnForwardFailure=yes` → exit + OpenSSH's `forwarding failed` diagnostic; `tcpip_forward` global-request decline event | @@ -106,11 +106,11 @@ decline event through the capture's deadline-bounded poll): **Findings fixed in-task** (no src/ changes; all e2e-graph local): -1. **Parallel-row flake — the shared key workdir**: the three tunnel rows initially shared one +1. **Parallel-row flake (the shared key workdir)**: the three tunnel rows initially shared one `write_key` workdir, so concurrent rows raced on the same key file and one row's cleanup deleted the dir under the others (observed as refused forwards / curl exit 7). Fixed: per-row workdirs (`tunnel-l` / `tunnel-r` / `tunnel-d`). -2. **Parallel-row flake — port collisions**: the probe-and-drop ephemeral port picker let parallel +2. **Parallel-row flake (port collisions)**: the probe-and-drop ephemeral port picker let parallel rows (and under load, re-entries) collide on named binds. Fixed: an in-process monotonic allocator over the 2000–16999 window (below the kernel's ephemeral range, so `bind(0)` can never steal one), probe-verified per allocation. @@ -120,18 +120,18 @@ decline event through the capture's deadline-bounded poll): before returning (`settle`, bounded poll on `OperationRegistry::registration`). 4. **Event-window flake under CI load**: the decline rows' fixed 10 s `await_event` window can expire on a loaded host. Fixed: `event_capture::captured_with` (additive, non-blocking peek) - + `support::decline_event` — the rows poll within the row deadline instead of a fixed window. + + `support::decline_event`, so the rows poll within the row deadline instead of a fixed window. ## Summary Completed 2026-10-30. The server-direction real-tool e2e landed as `tests/e2e_server.rs` (the -placement decision above): 12 rows over the six build shapes — OpenSSH pty/shell + pipe exec +placement decision above): 12 rows over the six build shapes: OpenSSH pty/shell + pipe exec (stderr separation), `-L`/`-R`/`-D`+curl (with curl's two-request per-connection pin), git clone **and** push (rev parity + CAS row pinned through the recording quartet), and the six decline-observability rows (unfeatured pty/`-L`/`-R` from real clients, the sftp subsystem -decline, the unknown-repo collapsed generic denial — each also pinned by its door-side decline -event). Every finding fixed in-task (four, listed in Notes — flake hardening only, no src/ -changes). Verification: full `scripts/matrix.sh` run green — `cargo test` per build (none / git +decline, the unknown-repo collapsed generic denial, each also pinned by its door-side decline +event). Every finding fixed in-task (four, listed in Notes; flake hardening only, no src/ +changes). Verification: full `scripts/matrix.sh` run green: `cargo test` per build (none / git / session / tunnel / sftp / --all-features; the new binary rides each build compiling and running only its rows' builds), both clippy runs `-D warnings` clean, fmt clean, `cargo doc --no-deps` clean; plus 10 consecutive all-features e2e runs green after the flake diff --git a/tasks/implementation/w5-fuzz-harness.md b/tasks/implementation/w5-fuzz-harness.md index b272f38..55278dd 100644 --- a/tasks/implementation/w5-fuzz-harness.md +++ b/tasks/implementation/w5-fuzz-harness.md @@ -1,6 +1,6 @@ --- id: w5-fuzz-harness -name: Wave 5 — the fuzz sidecar (the crate's ingress surfaces) +name: "Wave 5: the fuzz sidecar (the crate's ingress surfaces)" status: completed depends_on: [review-wave-4] scope: moderate @@ -11,30 +11,30 @@ level: implementation ## Description -The family standing rule — anything worth fuzzing gets fuzzed — on +The family standing rule, "anything worth fuzzing gets fuzzed", applied to the crate's ingress surfaces (`docs/plans/implementation.md` §Wave 5, the fuzzing bullet): the `fuzz/` sidecar per the alkgit convention -(`fuzz/README.md` there — the layout, the detached runner, the corpus +(`fuzz/README.md` there, covering the layout, the detached runner, the corpus gate). Four targets over the plan's ingress inventory: -- **`exec_parse`** — the git-parseable exec grammar + the dispatch +- **`exec_parse`**, the git-parseable exec grammar + the dispatch parse path (`src/git_map.rs`'s parse + `src/dispatch.rs`'s parse-only routing): no-panic on any command string; the typed decline classes (git-parseable → the git row, non-git → the session pipe path, service-only → decline-with-reason) stable per input; the per-length caps enforced before any allocation (the decline-first-alloc-never invariant under hostile lengths). -- **`fingerprint`** — the `SHA256:` + unpadded base64 format path +- **`fingerprint`**, the `SHA256:` + unpadded base64 format path (`src/fingerprint.rs`): no-panic on arbitrary key blobs; the format invariant holds for every accepted input; determinism; the committed `ssh-keygen -lf` parity vectors still agree. -- **`key_decode`** — the `decode_secret_key` ingress +- **`key_decode`**, the `decode_secret_key` ingress (`src/key_source.rs`'s decrypt-at-use seam): no-panic on arbitrary plaintext bytes and passphrase shapes; every failure a typed - `KeySourceError` class (decode failure — never a catch-all, never + `KeySourceError` class (decode failure, never a catch-all, never a panic); no secret material retained past the one-time decode (the `Zeroizing` posture's shape asserted where assertable). -- **`terminal_params`** — the `pty_request` args → +- **`terminal_params`**, the `pty_request` args → `TerminalParamsWire` conversion (`src/tty_map.rs`, the SSH modes → reserved-modes JSON): no-panic on arbitrary term strings, sizes, pixel dims, and mode blobs; oversize/overflow fail-closed (typed, @@ -45,20 +45,20 @@ Shape (the alkgit convention, adapted): - `fuzz_targets/` thin `fuzz_target!` wrappers; `shared/` the invariant logic as a plain stable-clean library so `cargo test --manifest-path fuzz/shared/Cargo.toml` replays the committed - corpora through the same invariants — **the corpus gate joins the + corpora through the same invariants, and **the corpus gate joins the verification list**; the sidecar consumes the crate's public API only (plus `#[doc(hidden)]` fuzz-visible exposures where the - ingress is private — the exposure shape decided + recorded, the + ingress is private, with the exposure shape decided + recorded, the alkgit pattern; never a dependency of the root crate). - `corpus//` committed hand-made seeds + the deterministic generator (`gen_fuzz_seeds.py`, no-op-diff check); grown corpora/artifacts/coverage gitignored, never merged back. -- `run-detached.sh` — the mandatory detached runner (agent sessions +- `run-detached.sh`, the mandatory detached runner (agent sessions never foreground a campaign); `rust-toolchain.toml` nightly pin inside `fuzz/`; the root `Cargo.toml` gains `exclude = ["fuzz"]` - (the root is a single-member workspace — the sidecar is its own). + (the root is a single-member workspace, with the sidecar its own). - Campaigns run detached per target (the runner's default budget); - any finding fixed in-task or booked for the gate — never absorbed + any finding fixed in-task or booked for the gate, never absorbed silently. ## Acceptance Criteria @@ -79,7 +79,7 @@ Shape (the alkgit convention, adapted): ## References -- docs/plans/implementation.md §Wave 5 (the fuzzing bullet — the +- docs/plans/implementation.md §Wave 5 (the fuzzing bullet, giving the four-surface inventory) - /workspace/@alkdev/alkgit/fuzz/ (the convention's reference implementation: README, run-detached.sh, gen_fuzz_seeds.py, @@ -92,42 +92,42 @@ Shape (the alkgit convention, adapted): ## Notes -**The sidecar as landed**: `fuzz/` (its own workspace — `[workspace] +**The sidecar as landed**: `fuzz/` (its own workspace: members = []` + nightly pin in `fuzz/rust-toolchain.toml`; the root -`Cargo.toml` gains `exclude = ["fuzz"]` at *both* levels — the +`Cargo.toml` gains `exclude = ["fuzz"]` at *both* levels: the `[workspace]` exclude and the `[package]` exclude list (the alkgit published-crate policy: the sidecar never ships in a publish; verified with the dry-run)). `fuzz/Cargo.lock` is committed (the alkgit convention); `artifacts/`, `coverage/`, `target/`, and grown corpora are gitignored. -**The exposure-shape decision (per target, recorded)** — the sidecar +**The exposure-shape decision (per target and recorded)**: the sidecar consumes alkssh's public API wherever the ingress is already public, and the production surface itself (never a fork) where it is private: -- `fingerprint` — **no exposure needed**: the ingress is fully public +- `fingerprint`, with **no exposure needed**: the ingress is fully public ([`alkssh::fingerprint`] + the russh 0.65 blob parse `parse_public_key_base64`, the same re-export surface the crate rides). -- `exec_parse` — `#[doc(hidden)]` re-export of the dispatch parse +- `exec_parse`, a `#[doc(hidden)]` re-export of the dispatch parse path: `parse_exec`, `ExecRoute`, `ExecDecline`, `GitServiceRoute` (made `pub` in the private `dispatch` module; the lib re-export - gate is the only reachability — the grammar stays a dispatch seam, + gate is the only reachability, as the grammar stays a dispatch seam, not a public-API promise). -- `key_decode` — `#[doc(hidden)]` exposure `decode_key_arm`, the +- `key_decode`, a `#[doc(hidden)]` exposure `decode_key_arm`, the production `decrypt_and_decode`'s decode arm *extracted and routed through itself* (the fuzz target drives the exact production arm, and the arm takes `&Zeroizing` so the drive exercises the adapter seam's wrap posture). -- `terminal_params` — `#[doc(hidden)]` re-export (under `session`) of - `tty_map::terminal_params` — the exact function `handler.rs`'s +- `terminal_params`, a `#[doc(hidden)]` re-export (under `session`) of + `tty_map::terminal_params`, the exact function `handler.rs`'s `pty_request` row constructs the stored params with. **The harness shape**: the deterministic framed feed for `terminal_params` (`term_len` u32-LE + term bytes + four u32-LE dims, -zero-defaulted, trailing mode blob ignored — the modes are never +zero-defaulted, trailing mode blob ignored, since the modes are never parsed, OQ-44); the production ingress shape for `exec_parse` -(byte string → `from_utf8(..).unwrap_or("")` → parse — the handler's +(byte string → `from_utf8(..).unwrap_or("")` → parse, the handler's exact shape; the pre-parse cap and the git row's repo-id gate are the root contract rows' territory, pinned by `tests/dispatch_core.rs`); a from-spec base64 round-trip oracle for `fingerprint`; the @@ -137,34 +137,34 @@ parity replays the committed `ssh-keygen -lf` vectors inside the corpus gate. **Campaigns run (detached via `fuzz/run-detached.sh`, fork mode, the -default 600 s budget each; all four final campaigns exited 0 — zero +default 600 s budget each; all four final campaigns exited 0, with zero crashes/OOMs/timeouts)**: -- `exec_parse` — 2 campaigns: the first crashed on a **harness-side +- `exec_parse`, 2 campaigns: the first crashed on a **harness-side oracle bug** (the oracle stripped two leading `/` where the grammar - strips exactly one — `git-upload-pack //.git `); fixed, re-ran + strips exactly one (`git-upload-pack //.git `); fixed, re-ran clean (40M+ execs). -- `fingerprint` — 1 campaign, clean (12M+ execs). -- `key_decode` — 2 campaigns: the first found a **real crate +- `fingerprint`: 1 campaign, clean (12M+ execs). +- `key_decode`, 2 campaigns: the first found a **real crate finding** (below); fixed, re-ran clean (3.5M+ execs). -- `terminal_params` — 1 campaign, clean (8.8M+ execs). +- `terminal_params`: 1 campaign, clean (8.8M+ execs). **The real finding (fixed in-task, recorded)**: russh 0.65's `ssh-key` PPK parse path (`PpkParseError::Header`) *interpolates the -raw header line* — the plaintext's own bytes — into its `Display`, +raw header line*, the plaintext's own bytes, into its `Display`, and the decode arm previously passed that message through `KeySourceError::Decode`'s payload: a malformed or PPK-header-shaped vault record would echo decrypted-record material into a stored/logged error string (the ADR-005 decision-7 posture's textual leak). Fix: `decode_key_arm` (and so every `Decode` payload the crate ever produces) now carries crate-owned fixed text naming -the refusal — the machinery's message never rides the typed payload; +the refusal, so the machinery's message never rides the typed payload; `error.rs`'s `Decode` doc records the semantics. **The upstream ask is booked** (not absorbed): `ssh-key`'s PPK parse errors should not echo the untrusted header; filed to russh/ssh-key with the owners at the wave-5 gate. Related harness-side finding (also fixed in-task): the no-echo invariant's first shape (a plaintext-`contains`-message -check) was itself unsound — an attacker-chosen plaintext can be a +check) was itself unsound, as an attacker-chosen plaintext can be a substring of the machinery's fixed text (the campaign's own first crash: plaintext `"Could not read key"`); the assertable form is the bounded-message pair (the text is a bounded fixed message, ≤ 256 @@ -172,7 +172,7 @@ bytes) + the ≥ 256-byte plaintext-run check that cannot false-fire. **Verification list**: the corpus gate (`cargo test --manifest-path fuzz/shared/Cargo.toml`) is green (11 -tests — every committed seed replays the invariants on the stable +tests, as every committed seed replays the invariants on the stable toolchain, plus the fixture parity rows) and has joined AGENTS.md's verification commands. `scripts/matrix.sh` green after all changes (the sidecar changes no root behavior: 6 builds × the full suite + @@ -190,11 +190,11 @@ verification list (AGENTS.md); committed hand-made seeds + the deterministic `gen_fuzz_seeds.py` (no-op-diff verified); the detached runner + nightly pin inside `fuzz/`; the root workspace excludes the sidecar (workspace + package excludes). The exposure-shape decision is -recorded per target (one no-exposure — fingerprint's fully-public +recorded per target (one no-exposure, fingerprint's fully-public ingress; three `#[doc(hidden)]` production-surface re-exports). -Detached campaigns ran per target at the runner's default budget — -all clean after findings: one harness-oracle bug (the exec oracle's -double-`/` strip) and one real crate fix — russh's `ssh-key` PPP/PPK +Detached campaigns ran per target at the runner's default budget, +and all are clean after findings: one harness-oracle bug (the exec oracle's +double-`/` strip) and one real crate fix, russh's `ssh-key` PPP/PPK parse echo (raw header bytes) riding the typed `Decode` payload; `decode_key_arm` now carries a crate-owned fixed message and the upstream echo ask is booked, not absorbed. Verification: the sidecar diff --git a/tasks/implementation/w5-publish.md b/tasks/implementation/w5-publish.md index 317d6ee..83ab403 100644 --- a/tasks/implementation/w5-publish.md +++ b/tasks/implementation/w5-publish.md @@ -1,6 +1,6 @@ --- id: w5-publish -name: Wave 5 — the publish of alkssh 0.1 +name: "Wave 5: the publish of alkssh 0.1" status: completed depends_on: [review-wave-5] scope: narrow @@ -11,49 +11,49 @@ level: implementation ## Description -The publish — the plan's closing act (`docs/plans/implementation.md` -§Wave 5: "then **the publish of `alkssh 0.1`** — the gate review that +The publish, which is the plan's closing act (`docs/plans/implementation.md` +§Wave 5: "then **the publish of `alkssh 0.1`**, the gate review that closes the plan"; the alkgit pattern: the gate stays a pure review, the publish is its own task executing only over a green verdict). The checklist: -- **Manifest sanity** — version 0.1.0 (already the manifest value — +- **Manifest sanity**, covering version 0.1.0 (already the manifest value, so verify, no bump needed), `rust-version` 1.89, edition 2024, the license files present, the dependency pins as ADR-002 records them (russh 0.65 / aws-lc-rs, alkcall 0.10, alkvault 0.1, the per-feature siblings), the package-policy `exclude` list, the feature graph exactly ADR-003's. -- **The full verification set** — `cargo test` per build + +- **The full verification set**: `cargo test` per build + `--all-features`, clippy ×2 `-D warnings`, fmt, - `cargo doc --no-deps`, the fuzz corpus gate, the matrix runner — + `cargo doc --no-deps`, the fuzz corpus gate, the matrix runner, all green at the publish commit. -- **The dry-run** — `cargo publish --dry-run --allow-dirty` clean at +- **The dry-run**: `cargo publish --dry-run --allow-dirty` clean at the publish commit (the packaged file list spot-checked once more). -- **The tag** — the crate-qualified `alkssh-v0.1.0` (the alkgit +- **The tag**: the crate-qualified `alkssh-v0.1.0` (the alkgit convention: crate-qualified tags, a bare `v0.1.0` would be ambiguous across the family's multi-crate repos). -- **The publish** — `cargo publish`; the published version verified +- **The publish**: `cargo publish`; the published version verified resolvable on crates.io after (a fresh `cargo add`/`cargo search` or the index check). **If the publish credential is unavailable, - the task stops and books it — never a silent skip**; everything + the task stops and books it, never silently skipping**; everything before the credential step still lands (tag + records). -- **The plan's closure recorded** — `docs/plans/implementation.md` +- **The plan's closure recorded**: `docs/plans/implementation.md` updated (the wave-5 row + the closure note: the plan closed at the publish commit; the freeze inventory's surfaces are one-way from - here — the mapping contracts, the honest-advertisement posture, + here: the mapping contracts, the honest-advertisement posture, the decline-matrix behavior, the fingerprint format, the russh pin's wire posture; further wire-relevant change is a new ADR + a semver event). ## Acceptance Criteria -- [x] Manifest sanity verified + recorded (no version bump needed — +- [x] Manifest sanity verified and recorded (no version bump needed, 0.1.0 is the manifest value) - [x] The full verification set green at the publish commit - [x] The dry-run clean at the publish commit - [x] The `alkssh-v0.1.0` tag on the publish commit - [x] `cargo publish` executed; the published 0.1.0 verified - resolvable on crates.io (or the credential absence booked — + resolvable on crates.io (or the credential absence booked, never a silent skip) - [x] The plan's closure recorded in `docs/plans/implementation.md` @@ -74,11 +74,11 @@ The checklist: Filled by the implementation agent (2026-10-30), executing over the green `review-wave-5` verdict at `9383e95`. -- **No findings in the checklist itself** — unlike the alkgit +- **No findings in the checklist itself**, unlike the alkgit publish (which had to land missing LICENSE files), the wave-5 release readiness already landed every artifact; this task only verified. -- **Manifest sanity verified** (no bump — 0.1.0 is the manifest +- **Manifest sanity verified** (no bump, since 0.1.0 is the manifest value): version 0.1.0, edition 2024, `rust-version` 1.89, `license = "MIT OR Apache-2.0"` with `LICENSE-MIT` + `LICENSE-APACHE` present, the pins exactly as ADR-002 records them @@ -88,23 +88,23 @@ green `review-wave-5` verdict at `9383e95`. place, the feature graph exactly ADR-003's flat shape (`default = []`, no implies). - **The full verification set green at the gate commit** (the tree - was clean — gate commit == publish base): `scripts/matrix.sh` all + was clean, so gate commit == publish base): `scripts/matrix.sh` all six builds' `cargo test` + clippy ×2 (`--all-targets -D warnings`, with and without `--all-features`) + `cargo fmt --check` + - `cargo doc --no-deps` (doctests included — the two `no_run` + `cargo doc --no-deps` (doctests included, with the two `no_run` usage doctests compile-checked per build); the fuzz corpus gate `cargo test --manifest-path fuzz/shared/Cargo.toml` 11/11. -- **The dry-run clean**: `cargo publish --dry-run --allow-dirty` — +- **The dry-run clean**: `cargo publish --dry-run --allow-dirty`: 76 files packaged, the packaged tree compiled (verification compile passed), upload aborted by dry run as expected; - `cargo package --list` spot-checked against the package policy — + `cargo package --list` spot-checked against the package policy: exactly `src/` (20), `tests/` (+ `common/` + `vectors/`), the architecture docs (`docs/architecture/`), `README.md`, `CHANGELOG.md`, both licenses, the manifests, and `scripts/matrix.sh`; none of the excluded surface appears (`AGENTS.md`, `.opencode/`, `docs/plans|reviews|research/`, `docs/sdd_process.md`, `tasks/`, `fuzz/`). -- **The publish credential is present in this environment** — the +- **The publish credential is present in this environment**, so the publish executes live, no operator handover, no silent skip. - **The closure records ride the publish commit**: the plan file's status → `closed`, the wave-5 row → `published`, and the §Plan @@ -112,26 +112,26 @@ green `review-wave-5` verdict at `9383e95`. upstream asks) land in the tagged commit. - **The publish-commit dry-run rider**: the closure edits touch only package-excluded paths (`docs/plans/`, `tasks/`), but the dry-run - was nonetheless re-executed at the publish commit itself (`e59ef79`) - — clean again, same 76-file package. + was nonetheless re-executed at the publish commit itself (`e59ef79`), + and was clean again with the same 76-file package. ## Summary -**`alkssh 0.1.0` published — crates.io, 2026-10-30.** Publish commit +**`alkssh 0.1.0` published to crates.io on 2026-10-30.** Publish commit `e59ef79`, tagged **`alkssh-v0.1.0`** (crate-qualified, the alkgit tag convention), pushed to `origin/main` with the tag. The checklist ran in order over the green `review-wave-5` verdict at `9383e95`: -manifest sanity verified (no bump — 0.1.0 the manifest value; +manifest sanity verified (no bump, 0.1.0 being the manifest value; edition 2024; `rust-version` 1.89; both license files; the pins exactly as ADR-002 records them; the package-policy `exclude` list; the feature graph exactly ADR-003's); the full verification set green -(`scripts/matrix.sh` — six builds' `cargo test`, clippy ×2 -D +(`scripts/matrix.sh`: six builds' `cargo test`, clippy ×2 -D warnings, fmt, doc with the doctests compile-checked; the fuzz corpus gate 11/11); `cargo publish --dry-run --allow-dirty` clean twice (76 files, packaged-compile verified, once pre-commit and once -at `e59ef79`; the package list against policy — architecture docs +at `e59ef79`; the package list against policy, where architecture docs ship, process surface and `fuzz/` do not). `cargo publish` completed -live (`Published alkssh v0.1.0 at registry crates-io` — the +live (`Published alkssh v0.1.0 at registry crates-io`; the environment carried the credentials, no operator handover, no silent skip). Resolvability verified post-publish: `cargo search alkssh` lists `alkssh = "0.1.0"` and a fresh `cargo add alkssh` in a clean diff --git a/tasks/implementation/w5-release-readiness.md b/tasks/implementation/w5-release-readiness.md index 9ab8a54..2447a02 100644 --- a/tasks/implementation/w5-release-readiness.md +++ b/tasks/implementation/w5-release-readiness.md @@ -1,6 +1,6 @@ --- id: w5-release-readiness -name: Wave 5 — release readiness (docs, feature matrix, CHANGELOG/README, dry-run) +name: "Wave 5: release readiness (docs, feature matrix, CHANGELOG/README, dry-run)" status: completed depends_on: [w5-decline-matrix, w5-e2e-server, w5-e2e-client, w5-fuzz-harness] scope: moderate @@ -15,38 +15,39 @@ Everything the publish needs except the publish itself (`docs/plans/implementation.md` §Wave 5, the release-readiness bullet; the alkgit `w5-release-readiness` shape): -- **Crate docs** — public-API rustdoc warning-free (default build + +- **Crate docs**: public-API rustdoc warning-free (default build + `--all-features`; the pre-existing `client_ops.rs` private-link warnings resolved or explicitly dispositioned); the docs-honesty pass: module docs name each surface's postures and known limits - (the recorded limits ride the published docs — the `write_all` + (the recorded limits ride the published docs: the `write_all` no-deadline record, the PTY rule-5 fidelity limit, the port-0-only mint wire fact, the no-reconnect-policy boundary, the no-accept-all policy posture); crate-level docs state the feature graph and the fail-closed invariant. -- **README.md** — lands (none exists): what the crate is (the +- **README.md**, which lands (none exists): what the crate is (the two-direction door), the feature graph + the fail-closed posture (a default build has no capabilities), minimal compile-checked usage examples (a server mount + a dial), the custody/security posture summary (vault-backed keys, no env vars, no secret material on the wire), the license note. -- **CHANGELOG.md** — the 0.1.0 entry summarizing the plan's landing +- **CHANGELOG.md**: the 0.1.0 entry summarizing the plan's landing per wave (the file exists; the entry lands). -- **The feature matrix** — `default` / each feature over it / +- **The feature matrix**: `default` / each feature over it / `--all-features`: test + clippy + doc green (the matrix runner - from `w5-decline-matrix` if it landed the vehicle — one command). -- **The publish dry-run** — `cargo publish --dry-run --allow-dirty`; + from `w5-decline-matrix` if it landed the vehicle, one command). +- **The publish dry-run**: `cargo publish --dry-run --allow-dirty`; the packaged file list verified against the package policy (the `exclude` list: the architecture docs ship as the authoritative - contract; the process surface — plans/research/reviews/tasks/ - AGENTS.md/`.opencode` — does not); the manifest sanity (version + contract; the process surface (plans/research/reviews/tasks/ + AGENTS.md/`.opencode`) does not); the manifest sanity (version 0.1.0, `rust-version` 1.89, edition 2024, license files present, the pins as ADR-002 records them). -- **The freeze inventory** — the list of what the publish makes +- **The freeze inventory**, the list of what the publish makes one-way (the wire-relevant surfaces: the mapping contracts, the honest-advertisement posture, the decline-matrix behavior, the fingerprint string format, the russh pin's wire posture) recorded - for the gate's read — the semver-relevant surface is frozen at the + for the gate's read, because the semver-relevant surface is frozen at + the publish, so the gate reads this list against the landed code before `w5-publish` executes. @@ -82,8 +83,8 @@ bullet; the alkgit `w5-release-readiness` shape): **Crate docs.** `cargo doc --no-deps` is warning-free on both the default build and `--all-features`. The pre-existing doc-link debt is resolved (not dispositioned): 9 warnings (1 duplicate) at -`--all-features` — the `client_ops.rs` private-link class -(`crate::tty_map` / `crate::dispatch` / `crate::git_map` — private +`--all-features`: the `client_ops.rs` private-link class +(`crate::tty_map` / `crate::dispatch` / `crate::git_map`, private modules; de-linked to backtick prose), the two `rustdoc::redundant_explicit_links` spans (the `(client_ops::…)` destinations on the `lib.rs` re-export docs and two @@ -95,7 +96,7 @@ its wrapped re-export form) and the unresolved `Limits::…` intra-doc link (now `crate::Limits::…`); plus the twin duplicated "report is sent before close" paragraph in `tty_map.rs` removed one. **Docs honesty:** the five recorded limits now ride the -*published* docs (the crate-root rustdoc §Known limits — the +*published* docs (the crate-root rustdoc §Known limits: the `write_all` no-deadline record, the PTY rule-5 upstream fidelity limit, the port-0-only mint echo wire fact, the no-reconnect-policy boundary, the no-accept-all policy posture) with their module-doc @@ -107,10 +108,10 @@ fact + no-feature-implies-another + honest advertisement) and the fail-closed invariant. **Compile-checked examples.** Two `no_run` doctests in the -crate-root docs (they run in every matrix build — the publish task's +crate-root docs (they run in every matrix build, and the publish task's verification set keeps them compile-checked with zero extra vehicle): a server mount (`ServeConfig::new` → `SshAdapter::new` → -`adapter.handle`, on the default build — sketch key/identity seams +`adapter.handle`, on the default build, with sketch key/identity seams returning `Absent`/`None`, prose points at the vault-backed shapes) and a dial (`dial(DialConfig)` → `close()`, the op rows referenced in prose with the feature gate noted; `RejectAll` named as @@ -121,7 +122,7 @@ two-direction door, russh owns the wire, door-not-backend), the feature matrix table + the fail-closed posture (a default build serves nothing but declines; unset-config declines; never advertised), the two usage sketches (mirroring the doctests), the custody/security -posture summary (vault-backed sources — encrypted at rest, decrypted +posture summary (vault-backed sources, encrypted at rest, decrypted at use, zeroized; no env vars, no secrets on the wire; publickey-only single attempt; fail-closed host-key default with no accept-all; ACL-gated surface; bounded resources + the known-limits pointer; the @@ -130,9 +131,9 @@ decline matrix as contract), the docs pointers, the license note. **CHANGELOG.md.** The 0.1.0 entry landed (founding entry, dated 2026-10-30): the crate story (the two-direction door, the flat fail-closed graph, ADR-003) + the posture statement, then the -landed surface wave by wave — the phase 0/1 contract, waves 1–5 +landed surface wave by wave: the phase 0/1 contract, waves 1–5 (foundations, server stack, server mappings/spine, client direction, -validation+release) — plus Notes (the sftp decline-only feature fact, +validation+release), plus Notes (the sftp decline-only feature fact, the OQ-09 env posture, the russh-pin one-way-door record). Only served surface is listed; the never-features (x11/`auth-agent`/subsystem/`env`) are named as the declines they @@ -140,27 +141,27 @@ are. **Feature matrix** (`scripts/matrix.sh`, the one command): all six builds' `cargo test` green (none / git / session / tunnel / sftp-only -/ `--all-features` — 108 result-ok reports across the matrix, +/ `--all-features`, with 108 result-ok reports across the matrix, including both doctests per build), clippy ×2 (`--all-targets -D warnings`, with and without `--all-features`) clean, `cargo fmt --check` clean, `cargo doc --no-deps` clean; plus the fuzz corpus -gate (`cargo test --manifest-path fuzz/shared/Cargo.toml` — 11 +gate (`cargo test --manifest-path fuzz/shared/Cargo.toml`, all 11 invariants green). -**Publish dry-run.** `cargo publish --dry-run --allow-dirty` clean — -packaged 76 files, verified (the packaged tree compiles), upload +**Publish dry-run.** `cargo publish --dry-run --allow-dirty` clean, +with 76 files packaged and verified (the packaged tree compiles), upload aborted by dry run as expected. The packaged file list verified against the package policy (`cargo package --list`): exactly `src/` (20 files), `tests/` (+ `common/` doubles + `vectors/`), the -architecture docs (`docs/architecture/` — the authoritative +architecture docs (`docs/architecture/`, the authoritative contract), `README.md`, `CHANGELOG.md`, `LICENSE-MIT` / `LICENSE-APACHE`, the manifests (`Cargo.toml`/`.orig`, `Cargo.lock`, `.gitignore`, `.cargo_vcs_info.json`) and `scripts/matrix.sh` (the verification tooling, not process surface); none of the excluded surface appears (`.opencode/`, `AGENTS.md`, `docs/reviews|plans|research/` / `docs/sdd_process.md`, `tasks/`, -`fuzz/` — none listed). **Manifest sanity recorded:** version 0.1.0 -(no bump — `w5-publish` verifies), `rust-version` 1.89, edition +`fuzz/` are all absent from the list). **Manifest sanity recorded:** version 0.1.0 +(no bump, as `w5-publish` verifies), `rust-version` 1.89, edition 2024, `license = "MIT OR Apache-2.0"` with both license files present, the pins as ADR-002 records them (russh 0.65 `default-features = false` + `aws-lc-rs`; alkcall 0.10; alkvault @@ -168,11 +169,11 @@ present, the pins as ADR-002 records them (russh 0.65 alktunnels 0.4.1), the feature graph exactly ADR-003's, the package-policy `exclude` list in place. -**The freeze inventory (for `review-wave-5` and `w5-publish` — the +**The freeze inventory (for `review-wave-5` and `w5-publish`, the surfaces the 0.1 publish makes one-way; further wire-relevant change is a new ADR + a semver event):** -1. **The mapping contracts** — the channel-type → family-protocol +1. **The mapping contracts**, the channel-type → family-protocol row semantics as landed (`docs/architecture/mapping.md` executed by `dispatch.rs`/`tty_map.rs`/`git_map.rs`/`tunnel_map.rs`/ `client_ops.rs`): the route-1 bridge flow (open-direct → @@ -182,27 +183,27 @@ is a new ADR + a semver event):** negotiation-error frame → 255), the exit derivation rules 1–6, the client op-row planes (the family chunk/duplex surfaces, the acceptor registry + payload types, the lifecycle primitives). -2. **The honest-advertisement posture** — the fail-closed invariant +2. **The honest-advertisement posture**, the fail-closed invariant as behavior: an unfeatured/unimplemented channel type is declined (never advertised, never half-served); a default build serves declines only; the `sftp` placeholder stays a decline in every build (OQ-13). -3. **The decline-matrix behavior** — the decline classes and shapes: +3. **The decline-matrix behavior**, the decline classes and shapes: the `OpenDeclineReason` vocabulary as exposed, the spawned-`channel_failure`-then-close serving-path shape, the `ConnectFailed` fabric reject, teardown-at-birth, the token-collision refusal, the config-driven (`seam-unconfigured`/unset-field) declines, the always-`sftp` - decline row — as pinned by the matrix suite. -4. **The fingerprint string format** — the `SHA256:` contract + decline row, as pinned by the matrix suite. +4. **The fingerprint string format**, the `SHA256:` contract format of `crate::fingerprint()` (the crate's single fingerprint path): both the auth bridge's identity resolution and the host-key policy's exact-string pin comparison consume it; changing the format breaks every pin store and is a one-way door. -5. **The russh pin's wire posture** — russh 0.65.0 with +5. **The russh pin's wire posture**, russh 0.65.0 with `aws-lc-rs` (no compression, `rsa` off, `des`/`dsa` never): the kex/algorithm negotiation surface crosses the crate boundary, so - the pin is wire-relevant (ADR-002) — a bump is a deliberate, + the pin is wire-relevant per ADR-002, and a bump is a deliberate, tested, recorded event. (The `alk/ssh` ALPN pin, ADR-001, is already its own ledger one-way door; the auth surface ships as publickey-only, exactly one attempt.) @@ -213,8 +214,8 @@ Release readiness landed. Crate docs: warning-free × builds (default + `--all-features`), the 9-warning doc-link debt resolved in place (`client_ops.rs`'s private-link class among them), the docs-honesty pass riding the published crate-root rustdoc (§Known -limits: the five recorded dispositions — `write_all` no-deadline, PTY -rule-5, port-0-only mint echo, no-reconnect policy, no-accept-all — +limits: the five recorded dispositions (`write_all` no-deadline, PTY +rule-5, port-0-only mint echo, no-reconnect policy, no-accept-all) with module-doc mirrors verified) and crate-level docs stating the feature graph + fail-closed invariant; two `no_run` usage doctests (server mount + dial) keep the README examples compile-checked in @@ -223,7 +224,7 @@ founding entry (wave-by-wave, honest advertisement). The feature matrix is green end to end (six test builds, clippy ×2, fmt, doc) + the fuzz corpus gate (11 invariants). `cargo publish --dry-run --allow-dirty` clean (76 files packaged + verified; the file list -checked against the package policy — architecture docs ship, the +checked against the package policy, so that architecture docs ship and the process surface does not; manifest sanity recorded). The freeze inventory (mapping contracts, honest advertisement, decline-matrix behavior, fingerprint format, russh pin posture) is recorded above diff --git a/tasks/implementation/w6-sibling-adoption.md b/tasks/implementation/w6-sibling-adoption.md index b80ffb2..3a76196 100644 --- a/tasks/implementation/w6-sibling-adoption.md +++ b/tasks/implementation/w6-sibling-adoption.md @@ -1,6 +1,6 @@ --- id: w6-sibling-adoption -name: Wave 6 — the sibling-adoption wave (alkvault 0.2 + alktunnels 0.5.0 gate + alktty 0.8.2 docs) +name: "Wave 6: the sibling-adoption wave (alkvault 0.2 + alktunnels 0.5.0 gate + alktty 0.8.2 docs)" status: completed depends_on: [w5-publish] scope: moderate @@ -17,11 +17,11 @@ The three alkssh-filed upstream reviews all shipped: alkvault 0.2.0 (review 004, the PTY exit-signal fidelity fix; docs-only adoption here). Adopt all three and publish as alkssh 0.2.0: -- **alkvault 0.2** — the breaking req bump; the adapter seam's +- **alkvault 0.2**, the breaking req bump; the adapter seam's `Zeroizing` wrap dropped (the known-gap ADR-005 decision 3 recorded is closed upstream); ADR-005 §3 + OQ-07 amended per the entry's own predicted one-line doc touch. -- **alktunnels 0.5.0 + the W2 gate adoption** — the forwarded serving +- **alktunnels 0.5.0 + the W2 gate adoption**, the forwarded serving surface registers `register_tunnel_forwarded_openable_with_establisher` with a `ForwardedGate` capturing the `ListenRouter`; stale-token arrivals @@ -30,7 +30,7 @@ here). Adopt all three and publish as alkssh 0.2.0: cap-admission teardown-at-birth fallback. mapping.md's `forwarded-tcpip` row updated; gate rows added (miss typed / pass unchanged / residual-miss unit pin). -- **alktty 0.8.2** — patch-level; pin text refreshed; OQ-10's +- **alktty 0.8.2**, patch-level; pin text refreshed; OQ-10's upstream PTY constraint retired (mapping.md rule 5 amended); the real-tool PTY signal-fidelity e2e row deferred (booked follow-up). - Version 0.1.0 → 0.2.0 (dep-req changes are semver-significant for