diff --git a/.opencode/agents/architect.md b/.opencode/agents/architect.md index 836546b..ef0e9b2 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, ...): a stable reference +- **Origin**: which spec doc(s) the question appeared in +- **Status**: one of open, resolved, or partially resolved +- **Priority**: high, medium, or 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). @@ -190,7 +190,7 @@ When you encounter a decision that genuinely can't be made: 1. Mark the OQ as `deferred(scope)` with a concrete blocking condition 2. Create a blocker task in `tasks/architecture/` naming the dependency -3. Continue to decisions that *can* be made — do not stall on one question +3. Continue to decisions that *can* be made. Do not stall on one question. ### 6. Request Architecture Review @@ -216,12 +216,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 - split threshold -- **Suggestion**: Consider — minor clarity improvements + terms, circular deferrals) +- **Warning**: Should fix things like missing cross-references and documents + approaching the split threshold +- **Suggestion**: Consider minor clarity improvements Iterate until zero critical issues. @@ -264,7 +264,7 @@ last_updated: 2026-05-29 answer is resolved, not left "open" with hedging language like "v1 default" 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. @@ -272,7 +272,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. @@ -281,17 +281,18 @@ 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 — - centralize in `open-questions.md` -3. **Monolithic documents**: 2000-line architecture files — split by component +1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs should be + extracted to ADRs +2. **Inline open questions**: Scattered per-doc "Open Questions" sections; + centralize them in `open-questions.md` +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 @@ -310,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. @@ -318,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: @@ -336,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 the composition (how they fit 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. @@ -352,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." @@ -363,27 +364,27 @@ 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: alknet-agent crate spec exists" or "investigation: work through 2+ example outbound-dial use cases (hub→worker, worker→hub) to see how verifier-selection + provider + connector compose." -3. **State the impacts** — what does this block downstream? Be +3. **State the impacts.** What does this block downstream? Be specific: "blocks the first hub deployment because the hub dials workers" not "blocks the hub 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. @@ -396,10 +397,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,12 +411,12 @@ A decision should be `deferred(unclear)` when: - 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, such as 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 0fe1017..733a96f 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", so 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; 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, since 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, because without it, the deferral's urgency is invisible and triage is guesswork. - Is it specific? "Blocks the first hub deployment because the hub dials workers" is useful. "Blocks the hub 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 — - if it's high priority, it blocks something specific; say what. + 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 **suggestion**. @@ -259,5 +259,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, because 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 dfe3129..ef96343 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 `anyhow::Result` (application) / `thiserror` (library) — no +- Error handling uses `anyhow::Result` (application) / `thiserror` (library), with no panics in library code -- Feature flags are used correctly (`tls`, `iroh`, `acme`) — base crate compiles +- Feature flags are used correctly (`tls`, `iroh`, `acme`); the base crate compiles lean - 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 2327646..1a3bcfa 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, and 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, so 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, so 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,14 +179,14 @@ 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, +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.) -5. **Done signal** — Use `worktree({action: "notify", ...})` when complete +5. **Done signal**: use `worktree({action: "notify", ...})` when complete Example prompt template: @@ -200,7 +200,7 @@ Your task: {{task}} 3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit 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"}}) @@ -230,7 +230,7 @@ For example, if Generation 2 has tasks A (depends on X), B (depends on Y), and C When spawning parallel tasks, check if they modify overlapping source files. 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 @@ -255,7 +255,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 ``` @@ -266,7 +266,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, so trust the notifications. Check `worktree({action: "sessions"})` when: @@ -312,7 +312,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 @@ -321,7 +321,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 @@ -400,12 +400,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. @@ -418,7 +418,7 @@ 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 + The `remote: true` flag handles remote branch deletion automatically (no separate `git push origin --delete` needed. Don't let stale branches accumulate. diff --git a/.opencode/agents/implementation-specialist.md b/.opencode/agents/implementation-specialist.md index dc256a9..33d21b2 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, so 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) @@ -178,7 +178,7 @@ 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; that's fine) 4. **Push your branch** so the coordinator can inspect your work if needed 5. **Exit** - coordinator handles escalation @@ -194,13 +194,13 @@ If NOT in worktree: 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: "..."}})` to review previous assistant messages if you lose track -- `memory({tool: "search", args: {query: "..."}})` — search past conversations +- `memory({tool: "search", args: {query: "..."}})` to 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. @@ -209,67 +209,67 @@ This is especially important for complex tasks that span many file operations. Read `AGENTS.md` at project root for full details. Key rules: -1. **No comments in code** — Per project convention. Doc comments (`///`, `//!`) +1. **No comments in code**: per project convention. Doc comments (`///`, `//!`) are fine and expected on public API. Inline `//` comments only when the user asks or when a non-obvious safety/correctness constraint would otherwise be missed (e.g., "a two-pump tunnel must shut down the opposite sink on pump - completion — `try_join!` alone deadlocks"). -2. **Error handling** — `thiserror` for library error types (`TunnelError`; + completion; `try_join!` alone deadlocks"). +2. **Error handling**, with `thiserror` for library error types (`TunnelError`; `HandlerError`/`StreamError` come from alkcall::core). No panics in library code. No `unwrap()` or `expect()` outside tests. For poisoned `RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())`. -3. **`tokio` is the async runtime** — all I/O is async. The tunnel pumps, the +3. **`tokio` is the async runtime**: all I/O is async. The tunnel pumps, the channels integration, and the consumer session type are all async. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for lifecycle correlation and per-direction data flow. -4. **WASM target is load-bearing** — the default crate (protocol-only) MUST +4. **WASM target is load-bearing**. The default crate (protocol-only) MUST compile to `wasm32-unknown-unknown`. Use the wasm-clean tokio subset (`rt`, `sync`, `io-util`, `macros`, `time`); **do NOT use `features = ["full"]`**. Substrate backends (local TCP/UDP sockets, process listeners) are feature-gated and never imported from the shared/producer/consumer modules. -5. **Wire format is stable** — the tunnel payload rides inside channels data +5. **Wire format is stable**: the tunnel payload rides inside channels data channels as raw bytes (channels strips its 8-byte header transparently; the tunnel protocol owns whatever framing it puts inside the `BiStream`). Any negotiation/setup frame is self-contained (length-prefixed JSON per alktty ADR-006 precedent), not alkcall's `EventEnvelope` framing. Wire-format changes after the first consumer are additive-only. -6. **Producer/consumer, not server/client** — both sides of a channels +6. **Producer/consumer, not server/client**: both sides of a channels connection can initiate. Use "producer"/"consumer" or "accept side"/"connect side," not "server"/"client." -7. **Substrate-agnostic by construction** — the protocol layer must not know - whether bytes come from TCP, UDP, a Unix socket, or stdio. Target +7. **Substrate-agnostic by construction** (the protocol layer must not know + whether bytes come from TCP, UDP, a Unix socket, or stdio). Target addressing, direction, and lifecycle bookkeeping must not hardcode a substrate. -8. **Two-pump shutdown-on-completion is a contract** — each pump MUST shut +8. **Two-pump shutdown-on-completion is a contract**: each pump MUST shut down the opposite sink when it completes; `tokio::try_join!` alone deadlocks (POC-validated, alknet ADR-078). Emit EOF sentinels (zero-length chunks) on clean sink shutdown. -9. **No forced local binding** — a tunnel must not require the producer (or +9. **No forced local binding**. A tunnel must not require the producer (or consumer) to bind a local port. Support both SSH `-L` and `-R` style directions and unbound/listen-optional flows; the binding decision belongs to the caller. -10. **Backpressure and limits are inherited** — bounded per-channel buffers, +10. **Backpressure and limits are inherited**: bounded per-channel buffers, the 256-channel cap, monotonic IDs, and zero-length-sentinel EOF are alkcall channels invariants. Do not build a second demux/mux or re-derive limits. -11. **Vendored core types come from alkcall** — `Connection`, +11. **Vendored core types come from alkcall** (`Connection`, `ProtocolHandler`, `BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`, `IdentityProvider`, `AccessControl`, `OwnershipProvider`, `HandlerError`, `StreamError` come from `alkcall::core`. Do not vendor copies. Pin `alkcall = "0.4.0"`; bump deliberately; fix issues upstream. -12. **BAST document for the wire format** — binary framing (if any beyond +12. **BAST document for the wire format**. Binary framing (if any beyond pass-through) carries a BAST document under `docs/architecture/` conforming to `https://alk.dev/bast/v1/schema`. alktunnels does not depend on alktype; hand-rolled codecs are fine for trivial formats. -13. **Access control** — scope-gate tunnel opens (`TUNNEL_OPEN_SCOPE`, shape +13. **Access control**: scope-gate tunnel opens (`TUNNEL_OPEN_SCOPE`, shape following alktty's `TTY_OPEN_SCOPE`); the channels path gets authorization via `ChannelCore::register_openable`, which runs the ACL before the open handler. Optionally consult `OwnershipProvider` - (`provider.owns(id_ref, kind, &id, "tunnel")` — the 4-arg shape). -14. **Naming conventions** — Rust standard: `snake_case` for functions/variables/ + (`provider.owns(id_ref, kind, &id, "tunnel")`, the 4-arg shape). +14. **Naming conventions**: Rust standard, with `snake_case` for functions/variables/ modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants. -15. **Module structure** — one module per file under `src/`, re-exported from +15. **Module structure**: one module per file under `src/`, re-exported from `src/lib.rs`. Public API surface is `lib.rs` re-exports. Producer half (adapter / open-handler), consumer half (typed session/client), shared wire/target-addressing modules; backends feature-gated and never imported diff --git a/.opencode/agents/poc-specialist.md b/.opencode/agents/poc-specialist.md index f5fae5f..318b65b 100644 --- a/.opencode/agents/poc-specialist.md +++ b/.opencode/agents/poc-specialist.md @@ -10,10 +10,10 @@ approaches. ## Your Environment **You are in a research worktree.** The open-coordinator plugin auto-injects -your working directory for all bash commands — you do NOT need to specify +your working directory for all bash commands; you do NOT need to specify `workdir` manually. -- 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 @@ -56,11 +56,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**, because it doesn't persist and the plugin handles routing. ## When You Are Spawned diff --git a/AGENTS.md b/AGENTS.md index 12c6450..66215bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ 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 matching the repo style (see `git log --oneline -10`). For multi-point changes, use a summary line plus a @@ -25,20 +25,20 @@ The workflow: 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) - The user is actively reviewing the diff and may ask for changes - The change touches the wire format or a trait shape that backends or - consumers implement (one-way doors — see "Wire formats are stable" and + consumers implement (one-way doors; see "Wire formats are stable" and "Backend trait shapes are one-way doors" below; once consumers exist, those signatures are wire-stable contracts) - You'd be force-pushing, amending a published commit, creating an empty 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 `). Do @@ -46,11 +46,11 @@ not change `git config`, skip hooks, or use `git commit -i`. ## Project Conventions (Rust / tunnel protocol crate) -This is the tunnels protocol crate — arbitrary bidirectional tunnels in +This is the tunnels protocol crate: arbitrary bidirectional tunnels in the `ssh -L` / `ssh -D` sense (TCP, UDP, unix sockets, and other stream or datagram substrates), riding alkcall channels the same way alktty -does. It is the sibling of alktty (`alk/tty` — terminal sessions) and -alkcall (call + channels — the substrate both build on). Where alktty +does. It is the sibling of alktty (`alk/tty`, terminal sessions) and +alkcall (call + channels, the substrate both build on). Where alktty multiplexes one service with a fixed five-stream channel structure, this crate generalizes the tunnel handler shape: the producer side dials or accepts on some local substrate and pumps bytes to/from a channels data @@ -65,82 +65,82 @@ 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., "a two-pump tunnel must shut down the opposite sink on - pump completion — `try_join!` alone deadlocks; see alkcall ADR-050's + pump completion, since `try_join!` alone deadlocks; see alkcall ADR-050's `pump_bidi` / alknet ADR-078"). -2. **Error handling** — `thiserror` for library error types +2. **Error handling**: `thiserror` for library error types (`TunnelError`; `HandlerError`/`StreamError` come from `alkcall::core`). 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 what should actually happen. For + wasn't specified, so 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. The tunnel +3. **`tokio` is the async runtime**: all I/O is async. The tunnel pumps, the channels integration, and the consumer session type are all async. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for lifecycle correlation and per-direction data flow. Any non-wasm backend module (local TCP/UDP sockets, process-spawned listeners) lives behind a feature flag like alktty's `local`. -4. **WASM target is load-bearing (by default)** — the default crate - (protocol-only) should compile to `wasm32-unknown-unknown`, following +4. **WASM target is load-bearing (by default)**, meaning the default + crate (protocol-only) should compile to `wasm32-unknown-unknown`, following alktty's precedent. This is what keeps the downstream TS/Python adapter story viable (a wasm-compiled alktunnels is the protocol layer for a sandboxed adapter). Use the wasm-clean tokio subset - (`rt`, `sync`, `io-util`, `macros`, `time`) — **do NOT use `features - = ["full"]`** — and keep socket/platform I/O feature-gated. If a + (`rt`, `sync`, `io-util`, `macros`, `time`); **do NOT use `features + = ["full"]`**; and keep socket/platform I/O feature-gated. If a design decision ever forces the default crate off wasm (e.g. the wire format embeds a substrate-specific type), that is an ADR-worthy decision, not an accident. -5. **Wire format is stable** — the tunnel wire format is a one-way door +5. **Wire format is stable**: the tunnel wire format is a one-way door once consumers exist. Follow the alknet ADR-093/alktty ADR-008 precedent: the tunnel payload rides inside channels data channels as raw bytes (channels strips its 8-byte header transparently; the tunnel protocol owns whatever framing it puts inside the `BiStream`). If a negotiation/setup frame is needed (like alktty's - `NegotiateRequest`), make it self-contained per alktty ADR-006 — + `NegotiateRequest`), make it self-contained per alktty ADR-006: length-prefixed JSON, not alkcall's `EventEnvelope` framing. Any wire-format ADR must be written before the first consumer exists; after that, changes are additive-only. -6. **Producer/consumer, not server/client** — both sides of a channels - connection can initiate. A producer exposes tunnels (registers +6. **Producer/consumer, not server/client**, because both sides of a + channels connection can initiate. A producer exposes tunnels (registers openable channels via `ChannelCore::register_openable`); a consumer opens tunnel channels (`ChannelClient`). Both sides can be both - simultaneously — connection direction (who opened it) is independent + simultaneously; connection direction (who opened it) is independent of tunnel direction (who dials the target, who serves it). Avoid "server" and "client" framing in docs and API names; use "producer" and "consumer," or "accept side" / "connect side" for the connection-establishment half specifically. See alkcall ADR-022, ADR-037. -7. **Substrate-agnostic by construction** — the protocol layer must not +7. **Substrate-agnostic by construction**: the protocol layer must not know whether the bytes come from TCP, UDP, a Unix socket, or a stdio pipe. The handler sees a `Connection`/`BiStream` from alkcall core and pumps bytes; the substrate-specific types (`TcpStream`, `UdpSocket`, etc.) are confined to backend modules behind feature - flags, injected at the assembly layer — the same inversion-point + flags, injected at the assembly layer, the same inversion-point pattern as alktty's `TtyBackend`. "Arbitrary tunnels" means the bookkeeping (target addressing, direction, lifecycle) must not hardcode a substrate. See alknet ADR-078 §Where the pattern lives and the channels-adapter spec. -8. **Two-pump shutdown-on-completion is a contract** — a tunnel is the +8. **Two-pump shutdown-on-completion is a contract**: a tunnel is the canonical two-pump handler (one pump per direction). Each pump MUST shut down the opposite sink when it completes; `tokio::try_join!` alone deadlocks. This was POC-validated in the alknet-channels POC (Target 3), pinned as alknet ADR-078, and the helper is pinned upstream as alkcall `channels::pump_bidi` (ADR-050, review 007 - R-03) — use it; do not hand-roll the two-pump shape. The channels - layer drops all per-channel senders on transport EOF — rely on + R-03), so use it; do not hand-roll the two-pump shape. The channels + layer drops all per-channel senders on transport EOF; rely on that for teardown, and emit EOF sentinels (zero-length chunks) on clean sink shutdown. Await `pump_bidi` inline inside the - `OpenHandler`'s task — the returned `JoinHandle` must track the + `OpenHandler`'s task, because the returned `JoinHandle` must track the data-plane lifetime (R-02; early return = teardown-at-birth). -9. **No forced local binding** — a tunnel must not require the producer +9. **No forced local binding**: a tunnel must not require the producer (or consumer) to bind a local port. The POC's TCP-tunnel shape dialed a target from the handler; SSH `-R` style flows need the reverse (the remote side asks the local side to dial or listen). @@ -150,7 +150,7 @@ just spawned implementation agents. requirement for the architecture docs, not just an implementation preference. -10. **Backpressure and limits are inherited, not redefined** — bounded +10. **Backpressure and limits are inherited, not redefined**: bounded per-channel buffers, the 256-channel per-connection cap, monotonic channel IDs, and the zero-length-sentinel EOF convention are alkcall channels invariants (see alkcall ADR-040/041/034). The @@ -160,12 +160,12 @@ just spawned implementation agents. backpressure; datagram-boundary preservation, if required, is a wire-format decision for an ADR, not an accident. -11. **Vendored core types come from alkcall** — `Connection`, +11. **Vendored core types come from alkcall**: `Connection`, `ProtocolHandler`, `BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`, `IdentityProvider`, `AccessControl`, `OwnershipProvider`, `HandlerError`, `StreamError` come from `alkcall::core`. Do not vendor copies into this crate. alkcall is - v0.10.x — breaking changes are expected at this major-zero stage; + v0.10.x, where breaking changes are expected at this major-zero stage; this is an early consumer, so we find and fix issues upstream rather than working around them. Pin `alkcall = "0.10.0"` and bump deliberately. The 0.6.0 establishment surface (ADR-049 + amendment @@ -173,50 +173,50 @@ just spawned implementation agents. `register_openable_with_establisher` so a refused target dial is a typed `channel:open_failed` call error, never a phantom channel, and the establisher returns the dialed handle via - `Establishment::new(plan)` (typed-opaque `ChannelPlan` — its + `Establishment::new(plan)` (typed-opaque `ChannelPlan`, whose `Send + Sync` bound constrains plan payloads; socket handles carry - `+ Sync`) — no side-channel handoff. The 0.7.0 identity surface + `+ Sync`); no side-channel handoff. The 0.7.0 identity surface (ledger CF-005/CF-006) is load-bearing for the serving side: a connect-side serving op resolves the caller identity in - precedence order — payload `auth_token` > + precedence order (payload `auth_token` > `ServingConfig.identity` > transport identity (set via - `Connection::set_identity` before dialing) — and the establisher/ + `Connection::set_identity` before dialing), and the establisher/ pump handler receive the per-call opener identity, not the install-time context. -12. **BAST document for the wire format** — when the tunnel wire format +12. **BAST document for the wire format**: when the tunnel wire format gains binary framing (if any beyond pass-through), it carries a BAST (Binary Abstract Syntax Tree) document as its machine-readable spec under `docs/architecture/`, conforming to the BAST meta-schema - at `https://alk.dev/bast/v1/schema`. BAST is plain JSON — no + at `https://alk.dev/bast/v1/schema`. BAST is plain JSON; no dependency required to author or consume it. alktunnels does not depend on alktype; hand-rolled codecs are fine for trivial formats, with the BAST doc as the contract. Do not roll your own offset map - or validator for complex formats — use alktype (as an optional dep) + or validator for complex formats: use alktype (as an optional dep) when that surfaces. -13. **Access control** — the producer's openable channels get +13. **Access control**: the producer's openable channels get authorization for free via `ChannelCore::register_openable`, which wires `AccessControl` into the operation spec (the registry runs the ACL before the open handler). Scope-gate tunnel opens (e.g. `TUNNEL_OPEN_SCOPE`, shape following alktty's `TTY_OPEN_SCOPE`) and optionally consult `OwnershipProvider` for resource ownership - (`provider.owns(id_ref, kind, &id, "tunnel")` — the 4-arg shape; + (`provider.owns(id_ref, kind, &id, "tunnel")`, the 4-arg shape; `OwnershipStore::record` is the 3-arg shape). Tunnels reach local - networks — treat the open gate as the security boundary. See + networks; treat the open gate as the security boundary. See alknet ADR-024 (registry layering, alkcall ADR-019), alknet ADR-050 (ownership, alkcall ADR-011). -14. **Feature flags** — substrate backends may be feature-gated if the +14. **Feature flags**: substrate backends may be feature-gated if the need arises. The base crate should compile lean (no socket/platform deps unless the feature is on). Verify both `cargo test` (default) and `cargo test --all-features` pass if features are added. -15. **Naming** — Rust standard: `snake_case` for functions/variables/ +15. **Naming**: Rust standard, `snake_case` for functions/variables/ modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants. -16. **Module structure** — one module per file under `src/`, re-exported +16. **Module structure**: one module per file under `src/`, re-exported from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The shape is pinned by the architecture spec (ADR-004, ADR-005): a producer half (adapter / open-handler for `alk/tunnel`-family @@ -225,12 +225,28 @@ just spawned implementation agents. modules are feature-gated and never imported from the shared/producer/consumer modules. -17. **ALPN naming** — this crate owns the `alk/tunnel`-family ALPN(s). +17. **ALPN naming**: this crate owns the `alk/tunnel`-family ALPN(s). One ALPN per protocol per alkcall ADR-004; keep the `alk/` prefix convention. If multiple tunnel flavors need distinct ALPNs (e.g. - stream vs datagram), decide via ADR before the first consumer — + stream vs datagram), decide via ADR before the first consumer; ALPN strings are wire-stable once published. +18. **No em dashes in prose.** Do not use the em dash (U+2014) in + source doc comments, markdown docs (including `docs/` and `tasks/`), + agent prompts under `.opencode/agents/`, or commit messages. + Restructure instead: split the sentence, use parentheses, commas, a + colon, or a semicolon, whichever reads naturally, and vary the + choice rather than converting every instance the same way. A spaced + hyphen (` - `) is not an acceptable substitute; it reads as a + different but equally recognizable writing tell. Inside fenced code + blocks the rule applies to prose-y content only: line comments, + ASCII-diagram label separators, and template placeholders are + rewritten too, while wire data, JSON/BAST examples, and tool + transcripts survive verbatim. The same rule covers en dashes used as + clause separators, though en dashes inside numeric ranges keep their + normal meaning. Hyphens in compound words (`wasm-clean`, + `tty-wire`, `alk/tunnel`) are untouched. + ## Verification Commands Run these before committing. All must pass. @@ -247,70 +263,70 @@ cargo publish --dry-run --allow-dirty # before a release ``` The wasm check is the structural guard for the "default crate is -wasm-clean" invariant (same as alktty) — run it whenever a +wasm-clean" invariant (same as alktty), and run it whenever a non-backend module changes. ## Architecture Context -- `docs/architecture/` — the authoritative spec (Phase 1 of the SDD +- `docs/architecture/` holds the authoritative spec (Phase 1 of the SDD process, complete): overview/producer/consumer/wire spec docs, ADRs 001..006 (all Accepted), the wire-format BAST document, and `open-questions.md` (the authoritative OQ tracker). Read it before non-trivial changes. Phase 0 research remains in `docs/research/` (its OQ ledger is superseded by the tracker). - The prior art for this crate: - - **alknet-channels POC** — + - **alknet-channels POC**, with its summary at `/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md` (§POC Target 3: tunnel handler). Validates the demux→Connection→ handler→mux path and the two-pump tunnel shape over a stand-in transport. POC code at `/workspace/alknet-channels-poc/`. - - **alkcall** — `/workspace/@alkdev/alkcall` (v0.10.x, crates.io). + - **alkcall** (`/workspace/@alkdev/alkcall`, v0.10.x, crates.io). The substrate: call protocol + channels multiplexing. The tunnel crate consumes `alkcall::core` types and the channels `ChannelCore`/`ChannelClient`/`register_openable` surface. - - **alktty** — `/workspace/@alkdev/alktty` (v0.1, crates.io). The + - **alktty**, found at `/workspace/@alkdev/alktty` (v0.1, crates.io). The sibling pattern to follow: a producer/consumer protocol crate on alkcall channels, with the backend inversion point, wasm-clean default crate, and feature-gated local backend. - Key upstream ADRs that inform this crate's design (alknet numbers; alkcall's ports live in `/workspace/@alkdev/alkcall/docs/architecture/ decisions/`): - - alknet ADR-093 / alkcall ADR-035 — channels pure channel + - alknet ADR-093 / alkcall ADR-035: channels pure channel multiplexing (8-byte header, no `stream_type`); the tunnel payload is raw bytes inside the `BiStream` - - alknet ADR-078 / alkcall ADR-050 — two-pump shutdown-on-completion + - alknet ADR-078 / alkcall ADR-050: two-pump shutdown-on-completion (the tunnel handler pattern); the helper is pinned upstream as - `alkcall::channels::pump_bidi` — use it, do not hand-roll - - alkcall ADR-049 (amendment 2) — the establishment phase; the + `alkcall::channels::pump_bidi` (use it, do not hand-roll) + - alkcall ADR-049 (amendment 2): the establishment phase; the establisher returns the dialed handle via `Establishment::new` (typed-opaque `ChannelPlan`), replacing the POC's side-channel handoff - - alknet ADR-074 / alkcall ADR-038 — `ChannelConnection` as a + - alknet ADR-074 / alkcall ADR-038: `ChannelConnection` as a `BidiStreamSource`; every handler (TTY, tunnel, call) receives a `Connection` - - alknet ADR-075 / alkcall ADR-039 — `ChannelsAdapter` / + - alknet ADR-075 / alkcall ADR-039: `ChannelsAdapter` / `ChannelManager` (substrate-agnostic demux; `params` is - ALPN-specific — for tunnels, the target resource) - - alknet ADR-071 / alkcall ADR-034 — channels wire format (one-way + ALPN-specific, i.e. for tunnels the target resource) + - alknet ADR-071 / alkcall ADR-034: channels wire format (one-way door) - - alkcall ADR-037 — channel lifecycle operations (`channel/open` on + - alkcall ADR-037: channel lifecycle operations (`channel/open` on channel 0; `params` carries the tunnel target) - - alkcall ADR-042 — hub relay (byte-for-byte data-channel forwarding - with ID rewrite — tunnels traverse relays transparently) - - alkcall ledger CF-005/CF-006 (0.7.0) — the connect-side serving + - alkcall ADR-042: hub relay (byte-for-byte data-channel forwarding + with ID rewrite, so tunnels traverse relays transparently) + - alkcall ledger CF-005/CF-006 (0.7.0), the connect-side serving identity seam: caller identity precedence (token > `ServingConfig.identity` > transport), per-call opener identity on the open-op hooks (the reverse-flow POC's W1, resolved) - - alknet ADR-085 — workspace scope: `alknet/tunnel` was flagged + - alknet ADR-085, workspace scope: `alknet/tunnel` was flagged "POC-validated, minimal spec needed, not yet specced"; this crate is that spec -- What the POCs settled (Phase 0 complete — 2026-09-07): both POCs +- What the POCs settled (Phase 0 complete as of 2026-09-07): both POCs ran clean (forward UDP POC + reverse-flow POC; see `docs/research/poc-summary.md` and `reverse-poc-summary.md`). Phase 1 is complete (2026-09-07): the OQ ledger is promoted, and the params, ALPN, codec, and API-surface decisions are pinned by ADR-001..006 - (including consumer-session teardown ownership — reverse POC W3). + (including consumer-session teardown ownership from reverse POC W3). Phase 2 (implementation) is decomposed in `tasks/`. - If a TODO references a design direction that an ADR has since decided - against, the TODO is stale — remove it and align with the ADR. Do not + against, the TODO is stale; remove it and align with the ADR. Do not implement the rejected design. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 450074d..450c798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,13 @@ this crate adheres to [Semantic Versioning](https://semver.org/). ### Added -- **The forwarded-op establisher variant (`register_tunnel_forwarded_openable_with_establisher` + `ForwardedGate` + `ForwardedPlan`, ADR-008 Amendment 2)** — the serving side of `channels/tunnel/forwarded` gains an establishment phase: the assembly injects a correlation gate (`ForwardedGate` — a sync closure over the parsed `TunnelForwardedParams`) and the crate wraps it into the alkcall `OpenEstablisher` dance, so a stale/missing `listen` token fails the open as a typed `channel:open_failed` with `details.reason == "unknown_resource"` BEFORE any channel is born (no phantom channel — ADR-049's posture extended to the forwarded op's serving side; the wire vocabulary is unextended). An establisher parse failure is likewise typed (`handler_error`) — an improvement over the base shape's success-then-silent-birth-teardown for malformed ingress. Gate order pinned: the registry runs the `tunnel:forwarded` ACL before the establisher (pinned by test with a gate-invocation witness). The base `register_tunnel_forwarded_openable` stays byte-for-byte compatible as the compatibility + residual-race-fallback posture (the narrow gate-pass→handler-run `correlation-miss` race keeps teardown-at-birth); the establisher variant is the recommended shape and new assemblies take it by default. The wire delta is empty. Five gate tests pin the typed miss/no-phantom/no-handler, the unchanged gate-pass serving flow (full byte round-trip), the residual-race reap, the scope-gate ordering, and the malformed-params delta. +- **The forwarded-op establisher variant (`register_tunnel_forwarded_openable_with_establisher` + `ForwardedGate` + `ForwardedPlan`, ADR-008 Amendment 2)**: the serving side of `channels/tunnel/forwarded` gains an establishment phase: the assembly injects a correlation gate (`ForwardedGate`, a sync closure over the parsed `TunnelForwardedParams`) and the crate wraps it into the alkcall `OpenEstablisher` dance, so a stale/missing `listen` token fails the open as a typed `channel:open_failed` with `details.reason == "unknown_resource"` BEFORE any channel is born (no phantom channel, extending ADR-049's posture to the forwarded op's serving side; the wire vocabulary is unextended). An establisher parse failure is likewise typed (`handler_error`), an improvement over the base shape's success-then-silent-birth-teardown for malformed ingress. Gate order pinned: the registry runs the `tunnel:forwarded` ACL before the establisher (pinned by test with a gate-invocation witness). The base `register_tunnel_forwarded_openable` stays byte-for-byte compatible as the compatibility + residual-race-fallback posture (the narrow gate-pass→handler-run `correlation-miss` race keeps teardown-at-birth); the establisher variant is the recommended shape and new assemblies take it by default. The wire delta is empty. Five gate tests pin the typed miss/no-phantom/no-handler, the unchanged gate-pass serving flow (full byte round-trip), the residual-race reap, the scope-gate ordering, and the malformed-params delta. ## [0.4.1] - 2026-10-22 The dependency-patch release: the alkcall substrate bumped 0.10.0 → 0.10.1 (the adoption-window ordering fix). No alktunnels API change, -no wire change — the only file-level contacts with the bump are +no wire change; the only file-level contacts with the bump are `Cargo.toml`/`Cargo.lock`. ### Changed @@ -32,7 +32,7 @@ no wire change — the only file-level contacts with the bump are channels through the same adopt path), though no change in this crate's own surfaces was needed. No API, wire-format (`EventEnvelope`, the 8-byte chunk header), or documented-cap - behavior change upstream — the fix surface is private; the behavior + behavior change upstream: the fix surface is private; the behavior only strengthens. 136 tests pass; wasm-clean verified (check + clippy on `wasm32-unknown-unknown`). @@ -40,7 +40,7 @@ no wire change — the only file-level contacts with the bump are The dependency-major release: the alkcall substrate bumped 0.9.0 → 0.10.0 (the gateway ownership-seam release). No alktunnels API -change, no wire change; zero source changes — the only file-level +change, no wire change, and zero source changes; the only file-level contacts with the bump are `Cargo.toml`/`Cargo.lock`. Minor rather than patch for the 0.3.0 reason: the semver-incompatible dependency bump plus this crate's public re-exports of alkcall types @@ -52,11 +52,11 @@ the producer API) make it semver-visible to downstream consumers. - **alkcall 0.9.0 → 0.10.0** (crates.io). The gateway ownership-seam release (review 012): `GatewayDispatch` gains `with_ownership_provider` / `with_static_ownership_fallback`, and - the previously-silent static fallback becomes loud — an unwired + the previously-silent static fallback becomes loud: an unwired spine refuses (an error envelope, `FORBIDDEN`, pre-dispatch) an op whose spec carries `access_control.resource_type`. None of it is on this crate's paths: alktunnels does not use the `gateway` feature - or `GatewayDispatch` — tunnel opens serve via + or `GatewayDispatch`; tunnel opens serve via `ChannelCore::register_openable`/`register_openable_with_establisher` registry dispatch on the native wire, which 0.10.0 leaves untouched. All of this crate's alkcall contact points @@ -72,7 +72,7 @@ the producer API) make it semver-visible to downstream consumers. The dependency-major release: the alkcall substrate bumped 0.8.1 → 0.9.0 (the stub-sweep release). No alktunnels API change, no wire change; the only code contact with the 0.9.0 breaking surface was -test-side (`Establishment` no longer derives `Debug` — three +test-side (`Establishment` no longer derives `Debug`; three `expect_err`/`panic!` sites restructured), which is why this is a minor bump rather than a patch: the semver-incompatible dependency bump plus this crate's public re-exports of alkcall types make it @@ -90,7 +90,7 @@ ride on 0.2.0). enumerator use, no generic `op/register` announcements), so no behavior change here. Of the 0.9.0 breaking list, `Establishment` dropping its `Debug` derive was the only surface - this crate touched — two `bindfirst_establisher` unit tests' + this crate touched: two `bindfirst_establisher` unit tests' `expect_err` calls and one integration test's untyped `panic!("{other:?}")` fall-through restructured (the error type itself remains `Debug`; the `Ok` arm now formats via `panic!("got @@ -108,7 +108,7 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change. ### Added -- **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)** — three +- **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)**: three cargo-fuzz targets over this crate's own attacker-reachable surfaces (the envelope/chunk/manager layer is upstream and fuzzed there per alkcall 0.8.1): `datagram_codec` (the ADR-003 codec: no-panic, @@ -120,7 +120,7 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change. exec, the `TunnelSession` data planes mirrored, no-wedge + shutdown-on-completion + lossless p2c + EOF-sentinel teardown + the `pending` backlog bound asserted). Invariant logic lives in - `fuzz/shared/` — a stable-toolchain crate replayed as plain + `fuzz/shared/`: a stable-toolchain crate replayed as plain `cargo test`, so the committed corpora (96 + 8 seeds, plus the campaign-promoted `seed-900`) stay executable without nightly. `fuzz/` is excluded from the workspace and from the published @@ -128,12 +128,12 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change. waves 1–2, zero crashes/hangs/OOMs; all findings were harness-model defects, no crate bugs. -- **`TunnelSession::datagram_backlog()`** — the UDP `pending` count +- **`TunnelSession::datagram_backlog()`**: the UDP `pending` count (the datagrams decoded from a chunk beyond the one returned), the wave-2 harness's asserted backlog bound made caller-visible; `None` for stream sessions or after the halves were taken. -- **`DatagramReader::retained_bytes()`** — the decoder-state byte +- **`DatagramReader::retained_bytes()`**: the decoder-state byte count (leftover buffer + the in-flight partial datagram); post-feed bounded by one frame (≤ `MAX_DATAGRAM_LEN` + 2), pinned by the `datagram_codec` target. @@ -143,7 +143,7 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change. - **alkcall 0.8.0 → 0.8.1** (crates.io). The bug-fix release: a duplicate `adopt_channel`/`open_channel` no longer destroys the live channel it collided with (found by alkcall's own - `manager_routing` fuzz target) — directly relevant to this crate's + `manager_routing` fuzz target), directly relevant to this crate's reverse-path adopt flow. No alktunnels API or wire change in this bump; the full suite passes unchanged. @@ -155,98 +155,98 @@ alksocks (the SOCKS5 layer) and the planned `alknodes` assembly. ### Added -- **`TunnelListener` — the listen-flow consumer session (ADR-008 §4)** — +- **`TunnelListener`: the listen-flow consumer session (ADR-008 §4)**, the listen-flow sibling of `TunnelSession`: `TunnelListener::open` (rides `ChannelClient::open_channel_with_reply`, alkcall 0.8.0's - U-2 surface — the full reply arrives and the bind-first `bound`/ + U-2 surface: the full reply arrives and the bind-first `bound`/ `listen` fields project through the lenient - `listen_reply_fields`; adopt-free construction — the client already - adopted the channel), `bound()`/`listen()` (the Option posture — + `listen_reply_fields`; adopt-free construction, since the client already + adopted the channel), `bound()`/`listen()` (the Option posture: `None` for dial/accept-first producers and old producers), and the listen channel's teardown ownership (ADR-005's posture extended to the listen flow): `close` (the graceful EOF sentinel via `shutdown`, then the reap) and `Drop` (the held write half drops → the mux pump's implicit-EOF chunk → the far accept dispatcher's - teardown signal; sync reap — never leaks). The held `BiStream`'s + teardown signal; sync reap, so nothing leaks). The held `BiStream`'s write half stays IDLE and private (a listen channel has no data-plane role; pumping one is meaningless). No `Clone` (compile-asserted). Forwarded sessions are NOT owned by the - listener — they arrive as ordinary adopted-side `TunnelSession`s on + listener; they arrive as ordinary adopted-side `TunnelSession`s on the opener's manager (`adopt` gains nothing, pinned by test); the assembly correlates them via the `listen()` token (the receipt queue is assembly-side bookkeeping). - **The `local` feature's bind-first substrate half (ADR-008 §1, - shape 3)** — the real-socket sources behind the committed + shape 3)**, the real-socket sources behind the committed `BindFn`/`BindFirstAcceptLoop` contracts (behind `local`; the default crate stays wasm-clean): `bind_tcp_first` (port 0 resolved - via `local_addr` — the `bound` reply field's data) + - `TcpListenerHalves::accept_pair` (one accept → `(peer, halves)` — + via `local_addr`, the `bound` reply field's data) + + `TcpListenerHalves::accept_pair` (one accept → `(peer, halves)`; the peer rides `accept()`'s return, the forwarded op's `peer` carrier); unix bind-first parity shipped (OQ-TN-14): `bind_unix` + `UnixListenerHalves` (`local_addr`, the queue-feeding `accept_loop`, - the bind-first `accept_pair` — an unnamed (autobind) peer fails + the bind-first `accept_pair`, where an unnamed (autobind) peer fails loud: the forwarded `peer` param requires an address) + `bind_unix_first`; the assembly conveniences `local_bind_first()` - (tcp/unix dispatch; **udp fails loud — a UDP socket has no accept, + (tcp/unix dispatch; **udp fails loud: a UDP socket has no accept, the bind-first shape is not defined there, never a fake listener**) and `local_bindfirst_accept_loop()` (downcasts the plan's opaque listener to the module's wrappers; a foreign listener type fails loud as `handler_error`). The accept-FIRST shapes (`accept_loop` + `AcceptQueue`) are untouched. The direct-op `DialFn` reuse is verified + pinned: `local_dial` is shared unchanged (the UDP framed - wrap happens inside the closure — review 001 U-1); a + wrap happens inside the closure, review 001 U-1); a `TunnelSession::open_direct` + `Substrate::Udp` test drives a REAL UDP echo socket through the direct op's establisher path. - **The forwarded op's SERVING side + the lenient reply projection - (ADR-008 §3 as amended)** — the listen-opener half, the receipt + (ADR-008 §3 as amended)**, the listen-opener half, the receipt counterpart to the bind-first producer (the opener SERVES the op; the producer CALLS it per accept): `ForwardedHandler` (the injected - assembly handler — the parsed params + the channel `BiStream`; the + assembly handler taking the parsed params + the channel `BiStream`; the assembly owns the pump, since the local halves are assembly state) + `make_forwarded_op_handler` (the built-in wrapper: deny-unknown params parse (a malformed open fails LOUD → the wrapper returns - early → teardown-at-birth reaps the channel — no phantom, the + early → teardown-at-birth reaps the channel; no phantom, the correct shape for a malformed ingress), `accept_bi` FIRST (R-02 accepted-flag telemetry), then wrapper→assembly bridge, the injected handler's `JoinHandle` awaited inline); + - `register_tunnel_forwarded_openable` (`register_openable` — no + `register_tunnel_forwarded_openable` (`register_openable`, so no establisher, the data plane starts immediately; scope gate - `tunnel:forwarded`, ingress — a separate grant from + `tunnel:forwarded`, ingress; a separate grant from `tunnel:open`/`tunnel:direct`, never implied by either). Plus `listen_reply_fields` (the lenient, infallible session-surface - projection of the bind-first reply — unknown extra reply fields + projection of the bind-first reply: unknown extra reply fields tolerated, a malformed shape logs loud and surfaces both-`None`; shared with `TunnelListener`). Correlation-miss teardown semantics documented + test-pinned: the assembly holds the map, its error path drops the stream, the channel reaps (the producer's accept - loop drops the accepted connection on the open's failure — the + loop drops the accepted connection on the open's failure, and the shapes mirror). - **The bind-first establisher + accept dispatcher (shape 3, ADR-008 - as amended)** — the `-R`/ingress establisher whose establishment + as amended)**, the `-R`/ingress establisher whose establishment phase BINDS (the address-first-vs-accept-first deadlock dissolves; the accept wait lives in the teardown-governed pump phase): - `BindFn` (the substrate bind function — the platform boundary, the + `BindFn` (the substrate bind function, the platform boundary; the protocol never binds) + `BoundListener` (the opaque listener + the real OS-chosen bound address); `bindfirst_establisher` (registry namespace gate → token mint → bind → `bound` + `listen` reply fields via `Establishment::with_reply_field` → the `BindFirstPlan` - plan payload; the token mint is the pinned discipline — non-zero- + plan payload; the token mint is the pinned discipline: non-zero- seeded `AtomicU32`, per-open fresh, per-producer unique, token 0 never minted); `BindFirstAcceptLoop` (the type-erased accept - closure — receives the opaque listener, yields `(peer, halves)` per - accept); `ForwardedOpener` (the per-accept forwarded-open caller — + closure; it receives the opaque listener, yields `(peer, halves)` per + accept); `ForwardedOpener` (the per-accept forwarded-open caller, the call plane: `call_with_payload` on channel 0 + `manager.adopt_channel`, the reverse POC's shape); `make_bindfirst_accept_handler` (the dispatcher: `accept_bi` first per R-02, the idle listen-stream EOF cancels the loop and tears the listener down, per-accept `pump_bidi` tasks tracked by the handler's - `JoinHandle` — a forwarded pump never outlives its listener; - forwarded-open failures are per-accept isolated — log + drop, the + `JoinHandle`, meaning a forwarded pump never outlives its listener; + forwarded-open failures are per-accept isolated: log + drop, the loop continues, v1 no retry); `register_tunnel_bindfirst_openable` - (same spec + `tunnel:open` scope gate as the dial/listen variants — + (same spec + `tunnel:open` scope gate as the dial/listen variants, the per-registration establisher posture; the injected seams ride `BindFirstInjections {bind, accept_loop, open_forwarded}` + the optional CF-006 identity witness). The opener-side forwarded-op @@ -254,36 +254,36 @@ alksocks (the SOCKS5 layer) and the planned `alknodes` assembly. `tunnels/forwarded-receipt`. - **The forwarded op + the bind-first params surface (ADR-008 as - amended)** — the wire-stable types pin first (the ordering + amended)**: the wire-stable types pin first (the ordering `tunnels/params` used for the base op): `OP_TUNNEL_FORWARDED` / `TUNNEL_FORWARDED_SCOPE` constants; `TunnelForwardedParams {listen, substrate, peer}` (deny-unknown; `listen` is the - producer-minted correlation token, positive by wire contract — - schema `minimum: 1` + a serde-level pin) with a per-substrate + producer-minted correlation token, positive by wire contract, + i.e. schema `minimum: 1` + a serde-level pin) with a per-substrate `peer` sub-schema (malformed peers fail the registry's schema gate before the opener-side handler); `tunnel_forwarded_spec()` - (`Sub`-typed, External, `alk/tunnel` ALPN marker, no establisher — + (`Sub`-typed, External, `alk/tunnel` ALPN marker, no establisher, so the forwarded data plane starts immediately); `TunnelListenReply` + `parse_listen_reply` (the typed projection of the bind-first - listen open's additive `bound`/`listen` reply fields — unknown + listen open's additive `bound`/`listen` reply fields: unknown reply fields tolerated, malformed ones fail loud via the new `TunnelError::MalformedReply`); `BindFirstPlan` (the opaque - assembly-typed listener + the token — the plan payload the + assembly-typed listener + the token, the plan payload the bind-first establisher/accept-dispatcher task consumes). -- **The direct op (`channels/tunnel/direct`, ADR-007)** — dynamic- +- **The direct op (`channels/tunnel/direct`, ADR-007)**, dynamic- target egress (the ssh `direct-tcpip` / SOCKS5 CONNECT shape), riding the same `alk/tunnel` ALPN: `SubstrateAddr` (the untagged substrate-shaped address object, also the forwarded op's `peer` shape) + `TunnelDirectParams {substrate, target}` with a per-substrate `input_schema` (malformed targets fail the registry's - schema gate — `INVALID_INPUT` — before the establisher), + schema gate as `INVALID_INPUT` before the establisher), `tunnel_direct_spec()`, `direct_establisher` (+ witness variant; - no registry lookup — `unknown_resource` can never fire; targets + no registry lookup, so `unknown_resource` can never fire; targets render to the same backing-string form the registry backings use and ride the SAME injected `DialFn`), `register_tunnel_direct_ openable` (same pump handler + plan-flow as the base op), and - `TunnelSession::open_direct` (separate constructor — `tunnel:direct` + `TunnelSession::open_direct` (separate constructor; `tunnel:direct` is a separate grant from `tunnel:open`, never implied by it; the session type, data planes, and teardown API are identical). An old producer rejects the unknown op loudly (`NOT_FOUND`). @@ -293,20 +293,20 @@ alksocks (the SOCKS5 layer) and the planned `alknodes` assembly. - **alkcall 0.7.1 → 0.8.0** (crates.io). The two upstream asks the graduation ADRs depend on landed (alkcall review 008): the establisher → reply projection (`Establishment::with_reply_field`, - ADR-049 amendment 3 — the bind-first `bound` carrier) and - flavor-form open-op ids in discovery (ADR-047 amendment 3 — + ADR-049 amendment 3, the bind-first `bound` carrier) and + flavor-form open-op ids in discovery (ADR-047 amendment 3: `channels/tunnel/direct`-shaped ops reconstruct their `channel_open` marker through discovery, so hub relay wraps them as channels, not plain forwarding stubs). Plus the bonus: - `ChannelRelay` + `HubLegTemplate` (ADR-051) — the hub re-produce + `ChannelRelay` + `HubLegTemplate` (ADR-051), the hub re-produce machinery the graduation's re-produce story assumed would be - assembly-layer is now in-tree, with `bound` flowing end-to-end + assembly-layer, is now in-tree, with `bound` flowing end-to-end per-hop-truthful (ADR-051 §6, ADR-008 §6's sentence realized). No alktunnels API or wire change in this bump; 71/91 tests pass, wasm-clean verified. - **`BindFirstPlan` gains `substrate: Substrate` (review 002 M-1 - remediation)** — the accept dispatcher's silent + remediation)**; the accept dispatcher's silent `unwrap_or(Substrate::Tcp)` fallback (the second `TunnelParams` re-parse of wire input) is removed: the establisher stamps the parsed substrate into the plan (one parse, one source @@ -333,8 +333,8 @@ complete (Phase 2, review 001 findings remediated). - **Data-plane codec** (ADR-003): raw pass-through for stream substrates (`tcp`, `unix`); mandatory `[len: u16 BE]` boundary- preserving framing for `udp` (`len = 0` a legal empty datagram, - never EOF; oversize rejected at frame time; truncation fails loud - — OQ-TN-13). The BAST contract: `docs/architecture/bast.md`. + never EOF; oversize rejected at frame time; truncation fails loud per + OQ-TN-13). The BAST contract: `docs/architecture/bast.md`. - **Producer half** (ADR-004): `ResourceRegistry`, `tunnel_establisher` (dial shape) and `listen_establisher` (the `-R` far-side accept shape) over alkcall's establishment surface @@ -342,7 +342,7 @@ complete (Phase 2, review 001 findings remediated). contract, alkcall ADR-050), and the `register_tunnel_openable` / `register_tunnel_listen_openable` registration helpers. -- **Consumer half** (ADR-005): `TunnelSession` — `open` (forward) / +- **Consumer half** (ADR-005): `TunnelSession` with `open` (forward) / `adopt` (reverse), substrate-shaped data planes (`stream_halves`, `take_halves`, `send_datagram`/`recv_datagram`), pump ownership (`pump_against`), teardown ownership (`close`/`join`/`Drop`); @@ -350,9 +350,9 @@ complete (Phase 2, review 001 findings remediated). - **`local` feature** (non-wasm): `dial_tcp`, `bind_tcp` + `TcpListenerHalves` accept loop, `connect_udp` wrapped in the FRAMED `UdpHalf` adapter (the codec composes at the substrate - boundary — review 001 U-1), `dial_unix`, and the all-substrate + boundary, per review 001 U-1), `dial_unix`, and the all-substrate `local_dial` closure. Stdio intentionally out of scope (alktty's - pipe mode owns process stdio — OQ-TN-14). + pipe mode owns process stdio; OQ-TN-14). - **Identity seam** (alkcall 0.7.0 CF-005/CF-006): payload-token > `ServingConfig.identity` > transport identity precedence proven end-to-end; the per-call opener identity reaches the establisher. diff --git a/README.md b/README.md index 710f7e7..82f3246 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Arbitrary bidirectional tunnels over [alkcall](https://crates.io/crates/alkcall) channels: TCP, UDP, and unix-socket tunnels in the `ssh -L` / `ssh -R` sense (and the `-D`/SOCKS flavor via composition), without SSH. A producer/consumer protocol crate riding alkcall channels the same way -[alktty](https://crates.io/crates/alktty) does — no networking, no -transport dependencies; the transport (QUIC, websocket, stdio, ...) +[alktty](https://crates.io/crates/alktty) does. It ships no networking +and no transport dependencies; the transport (QUIC, websocket, stdio, ...) is whatever alkcall runs over. Downstream crates compose on top of it: wire your substrate's sockets @@ -20,7 +20,7 @@ bind-first establisher + the ## Quick start -### Producer — register produced resources and serve the open op +### Producer: register produced resources and serve the open op ```rust use std::sync::Arc; @@ -32,7 +32,7 @@ use alktunnels::params::Substrate; use alktunnels::producer::{register_tunnel_openable, ResourceRegistry}; // The producer's registry: (resource, substrate) → backing. The -// backing string is whatever your dial closure understands — a +// backing string is whatever your dial closure understands: a // `host:port`, a socket path, an in-process id. It never rides the // wire; the consumer only ever names the resource. let registry = ResourceRegistry::new(); @@ -52,7 +52,7 @@ let core = ChannelCore::new(manager, default_policy()); // registry's ACL (scope `tunnel:open`) gates the open op, the // establisher dials the backing, and `pump_bidi` bridges the channel // to the dialed halves. Wire `core` + `op_registry` into your -// channels serving stack (the alkcall recipe) — this call is the +// channels serving stack (the alkcall recipe); this call is the // tunnel-specific step: register_tunnel_openable( &core, @@ -64,7 +64,7 @@ register_tunnel_openable( )?; ``` -### Producer — listen shape (the `-R` far side accepts) +### Producer: listen shape (the `-R` far side accepts) The same open op with a listen establisher: instead of dialing a target, the establisher pops an already-accepted local connection. @@ -82,7 +82,7 @@ let accept: AcceptFn = /* pops from the queue (or your own source) */; register_tunnel_listen_openable(&core, ®istry, &op_registry, auth, accept)?; ``` -### Producer — bind-first listen shape (`-R` with address + peer fidelity) +### Producer: bind-first listen shape (`-R` with address + peer fidelity) The same open op with a bind-first establisher: establishment BINDS (the reply carries the real OS-chosen `bound` address + a correlation @@ -114,7 +114,7 @@ register_tunnel_forwarded_openable( )?; ``` -### Consumer — open a tunnel channel (the `-L` path) +### Consumer: open a tunnel channel (the `-L` path) ```rust use alkcall::channels::client::ChannelClient; @@ -131,13 +131,13 @@ let mut session = TunnelSession::open( TunnelParams { resource: "postgres-primary".into(), substrate: Substrate::Tcp }, ).await?; -// Stream substrates (tcp/unix): raw pass-through — the halves ARE +// Stream substrates (tcp/unix): raw pass-through; the halves ARE // the tunnel, zero tunnel-level framing. let (read, write) = session.stream_halves().expect("stream session"); // ... use like any tokio duplex half (or take_halves() for owned) ... // Datagram substrate (udp): boundary-preserving, framed -// (`[len: u16 BE]`, ADR-003) — an empty datagram is legal and is +// (`[len: u16 BE]`, ADR-003); an empty datagram is legal and is // NOT EOF. let payload = session.recv_datagram().await?; // Some(Bytes) | None on EOF @@ -146,7 +146,7 @@ let payload = session.recv_datagram().await?; // Some(Bytes) | None on EOF let (client_to_target, target_to_client, reaped) = session.join().await; ``` -### Consumer — reverse flow (the `-R` path) +### Consumer: reverse flow (the `-R` path) The consumer initiates (it may hold the hub's call surface while the producer serves); the producer-allocated channel ID is adopted into a @@ -173,14 +173,14 @@ let session = TunnelSession::adopt( session = session.pump_against(accepted_local_stream).await; ``` -### Consumer — dynamic target (the direct op, `ssh -W`/CONNECT shape) +### Consumer: dynamic target (the direct op, `ssh -W`/CONNECT shape) ```rust use alktunnels::params::SubstrateAddr; use alktunnels::TunnelSession; // `tunnel:direct` is a SEPARATE grant from `tunnel:open` (never -// implied by it) — the caller states which capability it holds. A +// implied by it); the caller states which capability it holds. A // malformed target fails the schema gate; an old producer rejects // the unknown op loudly (NOT_FOUND). let mut session = TunnelSession::open_direct( @@ -190,25 +190,25 @@ let mut session = TunnelSession::open_direct( ).await?; ``` -### Consumer — the listen flow (bind-first reply fields) +### Consumer: the listen flow (bind-first reply fields) ```rust use alktunnels::TunnelListener; -// The same `channels/tunnel/sub` open op — the establisher shape is -// the producer's per-registration choice, invisible here. A bind-first +// The same `channels/tunnel/sub` open op: the establisher shape +// is the producer's per-registration choice, invisible here. A bind-first // producer's reply carries `bound` (the real OS-chosen address) and a // correlation token; both are `None` for dial/accept-first producers // or old producers (the lenient, additive posture). let listener = TunnelListener::open(&client, params).await?; -let bound = listener.bound().cloned(); // Option — informational, NOT addressing -let token = listener.listen(); // Option — the forwarded op's correlation key +let bound = listener.bound().cloned(); // Option; informational, NOT addressing +let token = listener.listen(); // Option, the forwarded op's correlation key // Teardown: `close` sends the EOF sentinel (the far accept dispatcher // tears its listener down), then reaps; `Drop` is the sync best-effort // form. Forwarded sessions arrive as separate adopted `TunnelSession`s -// on the manager — the assembly correlates them via the token; the -// listener does not own them. +// on the manager; the assembly correlates them via the token, and +// the listener does not own them. listener.close().await; ``` @@ -219,12 +219,12 @@ both sides of a connection can be either; role follows the resource): | Role | Via `alk/channels` | |------|--------------------| -| **Producer** (owns the backing: local ports, sockets, in-process services) | [`register_tunnel_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_openable.html) / [`register_tunnel_listen_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_listen_openable.html) / [`register_tunnel_bindfirst_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_bindfirst_openable.html) — the open op (`channels/tunnel/sub`) with a **dial establisher** (target dialing), an **accept-first listen establisher** (pops an already-accepted connection), or a **bind-first establisher** (binds; the reply carries `bound` + token, per-accept forwarded opens carry the peer); the pump handler bridges the channel via [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) | -| **Producer** (dynamic-target egress) | [`register_tunnel_direct_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_direct_openable.html) — the direct op (`channels/tunnel/direct`, ADR-007) with its own scope (`tunnel:direct`); the target rides the params, no registry lookup | -| **Consumer** (wants the bytes) | [`TunnelSession`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html) — [`open`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open) (forward) / [`open_direct`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open_direct) (dynamic target) / [`adopt`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.adopt) (reverse), substrate-shaped data planes, teardown ownership; [`TunnelListener`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelListener.html) — the listen flow with the `bound`/`listen` reply fields | +| **Producer** (owns the backing: local ports, sockets, in-process services) | [`register_tunnel_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_openable.html) / [`register_tunnel_listen_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_listen_openable.html) / [`register_tunnel_bindfirst_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_bindfirst_openable.html): the open op (`channels/tunnel/sub`) with a **dial establisher** (target dialing), an **accept-first listen establisher** (pops an already-accepted connection), or a **bind-first establisher** (binds; the reply carries `bound` + token, per-accept forwarded opens carry the peer); the pump handler bridges the channel via [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) | +| **Producer** (dynamic-target egress) | [`register_tunnel_direct_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_direct_openable.html), registering the direct op (`channels/tunnel/direct`, ADR-007) with its own scope (`tunnel:direct`); the target rides the params, no registry lookup | +| **Consumer** (wants the bytes) | [`TunnelSession`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html), with [`open`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open) (forward) / [`open_direct`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open_direct) (dynamic target) / [`adopt`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.adopt) (reverse), substrate-shaped data planes, teardown ownership; [`TunnelListener`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelListener.html), the listen flow with the `bound`/`listen` reply fields | One connection hosts many tunnels (one per channel); sessions are -independent. Both roles are callable from either connection side — +independent. Both roles are callable from either connection side; `-L` and `-R` are the same open op with the entry point on different machines (OQ-TN-03; the reverse POC validated the worker-serves / hub-initiates shape). @@ -241,9 +241,9 @@ address-free: The producer's registry maps the resource to its backing; the consumer never learns an address. Rich addressing (SOCKS5 ATYP, per-datagram remotes) enters only through the `-D`/dynamic composition path, -speaking its protocol inside the tunnel payload — never in the wire. +speaking its protocol inside the tunnel payload, never in the wire. -The exception is the direct op (ADR-007) — a *different capability* +The exception is the direct op (ADR-007), a *different capability* on its own op + scope, so the ACL separation is structural: ```json @@ -251,25 +251,25 @@ on its own op + scope, so the ACL separation is structural: ``` Scope-gated separately (`tunnel:direct` ≠ `tunnel:open` ≠ -`tunnel:forwarded` — never implied by each other); dial-only (no +`tunnel:forwarded`, never implied by each other); dial-only (no listen/direct-BIND form); the same `DialFn` the base op uses. The composed `-R` metadata flow rides the forwarded op -(`channels/tunnel/forwarded`, `{listen, substrate, peer}` — the peer +(`channels/tunnel/forwarded`, `{listen, substrate, peer}`; the peer is producer-asserted metadata, not transport truth). ### Data plane -- **Stream substrates (`tcp`, `unix`)**: raw pass-through — the +- **Stream substrates (`tcp`, `unix`)**: raw pass-through. The channel's read/write halves are the tunnel; zero tunnel-level framing, zero-length reads are EOF (ADR-003). - **Datagram substrate (`udp`)**: mandatory `[len: u16 BE]` per-datagram framing (boundary preservation; `len = 0` is a legal - empty datagram, never EOF — the F-2 layering; >65535 fails at frame + empty datagram, never EOF, per the F-2 layering; >65535 fails at frame time). The machine-readable contract is the BAST document at `docs/architecture/bast.md`. - **Sentinels and backpressure** are alkcall channels invariants - (zero-length-chunk EOF, bounded buffers, the 256-channel cap) — - this crate re-derives none of them. + (zero-length-chunk EOF, bounded buffers, the 256-channel cap). + This crate re-derives none of them. The `local` feature's [`UdpHalf`](https://docs.rs/alktunnels/latest/alktunnels/local/struct.UdpHalf.html) composes the codec at the substrate boundary: the pump-facing side @@ -278,38 +278,38 @@ datagrams. Truncation fails loud, never silent (OQ-TN-13). ### WASM target -The default crate (no features) compiles to `wasm32-unknown-unknown` -— the protocol layer for a sandboxed TS/Python adapter. The `local` +The default crate (no features) compiles to `wasm32-unknown-unknown`, +the protocol layer for a sandboxed TS/Python adapter. The `local` feature (real sockets) needs a real OS and is non-wasm by design; enabling it on wasm is a compile error. -## Version 0.2 — the graduation +## Version 0.2: the graduation 0.2 rides alkcall 0.8.0 and graduates the proxy surface (`docs/research/tunnels-graduation.md`, ADRs 007/008): -- **The direct op** (`channels/tunnel/direct`, scope `tunnel:direct`) - — dynamic-target egress; `TunnelSession::open_direct`. +- **The direct op** (`channels/tunnel/direct`, scope `tunnel:direct`), + dynamic-target egress; `TunnelSession::open_direct`. - **The bind-first establisher + the forwarded op** - (`channels/tunnel/forwarded`, scope `tunnel:forwarded`) — the + (`channels/tunnel/forwarded`, scope `tunnel:forwarded`), the composed `-R` metadata flow: `bound` + correlation token in the listen reply, `{listen, substrate, peer}` per accept; `TunnelListener` + `parse_listen_reply`/`listen_reply_fields`. -- **alkcall 0.7.1 → 0.8.0** — `Establishment::with_reply_field` + +- **alkcall 0.7.1 → 0.8.0** brings `Establishment::with_reply_field` + `open_channel_with_reply` (the bind-first carriers) and flavor-form op ids in discovery (hub relay wraps them as channels); bonus: in-tree `ChannelRelay`/`HubLegTemplate` (alkcall ADR-051). 0.2.1 is the hardening release: the `fuzz/` workspace (three cargo-fuzz targets over the datagram codec, the params parsers, and a -stateful session-pump target; corpora replay as plain `cargo test`) — -`docs/research/fuzzing.md` — and the alkcall bump 0.8.0 → 0.8.1 -(duplicate adopt/open no longer destroys the live channel — relevant +stateful session-pump target; corpora replay as plain `cargo test`, +documented in `docs/research/fuzzing.md`) and the alkcall bump 0.8.0 → 0.8.1 +(duplicate adopt/open no longer destroys the live channel, relevant to the reverse-path adopt flow). Two additive diagnostics: `TunnelSession::datagram_backlog()` and `DatagramReader::retained_bytes()`. No API break, no wire change. -0.3.0 rides the alkcall bump 0.8.1 → 0.9.0 (the stub-sweep release — +0.3.0 rides the alkcall bump 0.8.1 → 0.9.0 (the stub-sweep release: the four registered-but-always-failing stubs became real implementations; none were on this crate's paths). No alktunnels API change, no wire change; minor rather than patch because the @@ -317,14 +317,14 @@ semver-incompatible dependency bump + this crate's alkcall type re-exports make it semver-visible downstream. 0.4.0 rides the alkcall bump 0.9.0 → 0.10.0 (the gateway -ownership-seam release — the loud ownership-wiring default on the +ownership-seam release; the loud ownership-wiring default on the `GatewayDispatch` spine, behind the opt-in `gateway` feature; none of it is on this crate's paths, which serve via `ChannelCore::register_openable*` dispatch). No alktunnels API change, no wire change; same minor-not-patch reasoning as 0.3.0. 0.4.1 rides the alkcall bump 0.10.0 → 0.10.1 (the adoption-window -ordering fix — a chunk routed inside the `adopt_channel` install→drain +ordering fix, where a chunk routed inside the `adopt_channel` install→drain window can no longer overtake earlier-parked early arrivals). No alktunnels API change, no wire change; patch because the dependency bump is backwards-compatible. @@ -333,10 +333,10 @@ bump is backwards-compatible. | Feature | Contents | wasm | |---------|----------|------| -| *(default)* | params, wire codec, open-op specs (`sub`/`direct`/`forwarded`), establishers (dial + accept-first listen + bind-first), `TunnelSession`, `TunnelListener`, registration helpers — protocol only | yes | +| *(default)* | params, wire codec, open-op specs (`sub`/`direct`/`forwarded`), establishers (dial + accept-first listen + bind-first), `TunnelSession`, `TunnelListener`, registration helpers (protocol only) | yes | | `local` | [`dial_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_tcp.html) / [`bind_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp.html) / [`connect_udp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.connect_udp.html) (the framed `UdpHalf`) / [`dial_unix`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_unix.html) / [`local_dial`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_dial.html), the TCP accept loop; the bind-first halves: [`bind_tcp_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp_first.html) / [`bind_unix_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_unix_first.html) + [`local_bind_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_bind_first.html) + [`local_bindfirst_accept_loop`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_bindfirst_accept_loop.html) | no | -Stdio is intentionally out of scope — a spawned process's stdio is +Stdio is intentionally out of scope: a spawned process's stdio is alktty's pipe mode (exit codes + signals are tty semantics); remote command execution composes via alktty (OQ-TN-14). diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 9527457..498ba67 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -108,17 +108,17 @@ establisher + the forwarded op (ADR-008). ## Dependencies ``` -alktunnels (default — wasm-clean) +alktunnels (default: wasm-clean) ├── alkcall 0.10.1 (core types, channels: ChannelCore/ChannelClient/manager, -│ pump_bidi, ServingConfig identity seam — CF-005/006, +│ pump_bidi, the ServingConfig identity seam (CF-005/006), │ establisher reply projection + flavor-form discovery -│ derivation + ChannelRelay/HubLegTemplate — review 008/ADR-051) +│ derivation + ChannelRelay/HubLegTemplate (review 008/ADR-051)) ├── tokio (wasm-clean subset: rt, sync, io-util, macros, time) ├── bytes, futures, serde/serde_json, thiserror, tracing, async-trait -└── (no backend deps — sockets/process live in feature-gated modules) +└── (no backend deps: sockets/process live in feature-gated modules) -alktunnels (local feature) — non-wasm by design -└── adds: tokio/net (TCP dial, UDP connect, unix sockets — unix per OQ-TN-14) +alktunnels (local feature): non-wasm by design +└── adds: tokio/net (TCP dial, UDP connect, unix sockets; unix per OQ-TN-14) ``` Same posture as alktty: the default crate is protocol-only and @@ -138,22 +138,22 @@ though it is trivial here, since the codec is a 2-byte length prefix). ``` src/ - lib.rs — re-exports; crate docs - params.rs — TunnelParams {resource, substrate} + open-op input schema (ADR-001); + lib.rs:re-exports; crate docs + params.rs:TunnelParams {resource, substrate} + open-op input schema (ADR-001); TunnelTarget {substrate, target} + direct-op schema (ADR-007); TunnelForwardedParams {listen, substrate, peer} (ADR-008 as amended); the three scope constants - wire.rs — the data-plane codec: frame_datagram / DatagramReader (ADR-003) - producer.rs — the open ops: tunnel_open_spec + direct/forwarded spec builders + wire.rs:the data-plane codec: frame_datagram / DatagramReader (ADR-003) + producer.rs:the open ops: tunnel_open_spec + direct/forwarded spec builders (scope-gated), establisher shapes (dial + listen + bind-first), pump handler (pump_bidi / accept dispatcher), register_tunnel_*_openable (ADR-004/007/008 shapes) - consumer.rs — TunnelSession: open / open_direct / adopt / stream_halves / + consumer.rs:TunnelSession: open / open_direct / adopt / stream_halves / take_halves / send_datagram / recv_datagram / pump_against / close / join / Drop (ADR-005); TunnelListener: open / bound / forwarded-receipt / close / Drop (ADR-008) - error.rs — TunnelError + the typed open-error surface (ADR-049 §4) - local/ — the `local` feature module: real socket halves functions + error.rs:TunnelError + the typed open-error surface (ADR-049 §4) + local/:the `local` feature module: real socket halves functions ``` Public API surface is `lib.rs` re-exports (AGENTS.md convention 16). diff --git a/docs/plans/003-correlation-miss-resolve.md b/docs/plans/003-correlation-miss-resolve.md index b4bffb5..109084a 100644 --- a/docs/plans/003-correlation-miss-resolve.md +++ b/docs/plans/003-correlation-miss-resolve.md @@ -1,13 +1,13 @@ -# Plan — resolve review 003: the correlation-miss arrival class +# Plan: resolve review 003 (the correlation-miss arrival class) -Status: W1 landed (2026-11-03) — ADR-008 Amendment 2, the +Status: W1 landed (2026-11-03), covering ADR-008 Amendment 2, the `…_with_establisher` surface, the gate tests, and the review/checkpoint + doc sync done. W2 (alkssh) / W3 (alksocks) are open: sibling-repo sessions, unticked until those repos land. Resolution of `docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md` (also kept: it records the tri-vocabulary decision's ground), including the verification caveats found in this session (see §Verified -findings — they extend the review, do not contradict it). +findings, which extend the review and do not contradict it). ## Verified findings (the ground the plan stands on) @@ -28,17 +28,17 @@ the resolution scope: the producer-side pump EOFs with no log at all. The generic arm (`src/producer.rs:838-854`) only fires for the connection-gone/NOT_FOUND/ACL-refused subclass. -- All three are the phantom-channel shape — the ADR-049 posture +- All three are the phantom-channel shape, i.e. the ADR-049 posture exception ("a refused open is a typed call error, never a phantom channel") that the forwarded op's serving side currently rides. ## Decisions (settled this session, pending ADR ink) -1. **Route the miss through an establisher** — the serving side gains +1. **Route the miss through an establisher**: the serving side gains an establisher variant so a no-route token returns a typed `channel:open_failed` before the channel is born. The residual post-open decline (a token missing between establisher pass and - handler run — a narrow race) keeps teardown-at-birth as the + handler run, a narrow race) keeps teardown-at-birth as the fallback shape. 2. **Reuse `unknown_resource`** as the typed reason for a stale/missing token. The waiting session *is* a resource that is gone; the wire @@ -46,8 +46,8 @@ the resolution scope: already pins it. No wire-vocabulary extension, no reason-class ADR (framing option 2's "new class" question is moot). 3. **Adopt `correlation-miss` as the canonical event name** for the - residual post-open decline class (alkssh's shipped vocabulary wins — - it is published); alksocks' prose variant retires. Level posture: + residual post-open decline class (alkssh's shipped vocabulary wins, + as it is published); alksocks' prose variant retires. Level posture: assembly-side declines at `debug`; the producer-side generic arm stays `warn` (it now carries a typed reason instead of a prose one). 4. **Scope of the change is additive-only** in this crate: a new @@ -59,7 +59,7 @@ the resolution scope: ## Work items -### W1 — alktunnels: ADR + additive API (this repo; ~1 session) +### W1 (alktunnels): ADR + additive API (this repo; ~1 session) - ADR-008 amendment (or a small successor ADR): the forwarded op's serving side gains an establishment phase; misses are typed failures @@ -79,11 +79,11 @@ the resolution scope: `unknown_resource`, no channel allocated; gate-pass → existing serving flow unchanged; the residual race shape (gate passes, route gone at handler time) still reaps cleanly; scope gate unchanged - (`tunnel:forwarded`, runs before the establisher — pin the order). + (`tunnel:forwarded`, runs before the establisher; pin the order). - Update the in-code docs that describe the miss as assembly-side-only (`make_forwarded_op_handler` doc, `Register…forwarded` doc). -### W2 — alkssh: adopt the establisher (dedicated session; alkssh repo) +### W2 (alkssh): adopt the establisher (dedicated session; alkssh repo) - Move `pump_forwarded_arrival`'s route lookup into the new gate (the `ListenRouter` lookup becomes the establisher's check); the @@ -91,13 +91,13 @@ the resolution scope: the narrow post-open race only. - Verify the fabric-side silence fix: a stale-token arrival now fails the open (alkssh-side typed error; producer-side generic arm fires - with a parseable reason) — the race window no longer produces a + with a parseable reason), so the race window no longer produces a zero-signal pump. - Fold alkssh's em-dash doc cleanup into its release for this change - ("cleanup rides the code-update release" — the user-owned task, out + ("cleanup rides the code-update release"; the user-owned task, out of this plan). -### W3 — alksocks: adopt the establisher (dedicated session; alksocks repo) +### W3 (alksocks): adopt the establisher (dedicated session; alksocks repo) - Move `CorrelationRegistry` lookup into the new gate; retire the prose warn (the miss becomes a typed failure at the open; residual @@ -133,10 +133,10 @@ and is otherwise out of scope here. - `docs/architecture/decisions/008-listen-metadata-forwarded-op.md` §3 (the serving shape this amends); alkcall ADR-049 (the establisher posture being extended); `docs/architecture/wire.md` §The Open Ops - (the pinned `details.reason` vocabulary — unextended by this plan) + (the pinned `details.reason` vocabulary, unextended by this plan) - `src/producer.rs` (the generic arm; the forwarded-op serving half); alkssh `src/tunnel_map.rs` (`pump_forwarded_arrival`, `ListenRouter`); alksocks `src/composed/mod.rs` (`make_forwarded_handler`, `CorrelationRegistry`) - alkssh `docs/plans/implementation.md` ("Still booked with their - owners" — the booking this filing unblocks) \ No newline at end of file + owners" (the booking this filing unblocks) \ No newline at end of file diff --git a/docs/research/fuzzing.md b/docs/research/fuzzing.md index 086aa1b..b9dfb79 100644 --- a/docs/research/fuzzing.md +++ b/docs/research/fuzzing.md @@ -1,28 +1,28 @@ # Fuzzing alktunnels: Proposal and Plan -**Status:** adopted — wave-1 complete (`datagram_codec` + +**Status:** adopted; wave-1 complete (`datagram_codec` + `params_parse` implemented, seeds committed, two 10-min campaigns -clean, two harness-model findings fixed — see §7); wave-2 -(`session_pumps`) implemented and campaigning — see §3.3/§7. -Campaigns run via the detached runner (§6) — **never as a foreground +clean, two harness-model findings fixed; see §7); wave-2 +(`session_pumps`) implemented and campaigning (see §3.3/§7). +Campaigns run via the detached runner (§6), **never as a foreground child of an agent session** (§5). **Date:** 2026-09-28 **Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` -(0.8.0 — 0.8.1 campaign; one real finding: duplicate adopt/open destroying +(0.8.0 to 0.8.1 campaign; one real finding: duplicate adopt/open destroying the live channel, fixed in 0.8.1). Tooling, corpus policy, runner, and the -no-hosted-CI posture are inherited from that doc wholesale — this doc does +no-hosted-CI posture are inherited from that doc wholesale: this doc does not re-litigate them; it scopes them to this crate. --- ## 1. Should this crate be fuzzed? -**Yes — but scoped smaller than alkcall's.** The decision follows from +**Yes, but scoped smaller than alkcall's.** The decision follows from three lines, all pointing the same way: 1. **alkcall's fuzz doc already assigned this to us.** Its §5 "non-surfaces (deliberately)" note ends: *the channels layer carries - opaque `Bytes` and the handler owns sub-stream framing (ADR-035) — + opaque `Bytes` and the handler owns sub-stream framing (ADR-035); those downstream parsers are downstream crates' fuzz targets.* Our `wire.rs` datagram codec IS that named downstream target. Fuzzing it completes alkcall's fuzz scope rather than duplicating it. @@ -30,11 +30,11 @@ three lines, all pointing the same way: 2. **We own one hand-rolled parse surface of exactly the covered class.** `DatagramReader::feed` (`src/wire.rs:100`) is an incremental length-prefix state machine reassembling `[len: u16 BE][payload]` - frames across arbitrary chunk boundaries — length arithmetic and + frames across arbitrary chunk boundaries: length arithmetic and boundary handling, the dominant rust-fuzz trophy-case classes. It is sync, pure, allocation-light, and infallible **by contract** (the - decode direction cannot fail; oversize is rejected at frame time) — - a fuzz target is how that contract is *demonstrated* rather than + decode direction cannot fail; oversize is rejected at frame time), + and a fuzz target is how that contract is *demonstrated* rather than believed. 3. **The stateful DoS class applies to us directly.** One pre-fuzz @@ -43,19 +43,19 @@ three lines, all pointing the same way: (`src/consumer.rs:78`) queues every datagram decoded from a chunk beyond the one returned to the caller. A single 16 MiB chunk of `[00 00]` pairs = ~8.4 M empty datagrams → ~8.4 M `VecDeque` - entries (≈ 400+ MiB) from ONE peer chunk — a ~25–50× memory + entries (≈ 400+ MiB) from ONE peer chunk, a ~25–50× memory amplification riding a bounded channel buffer. Example-based tests structurally cannot find this; a byte-sequence target with a pending-bytes assertion will. **The honest caveat** (mirroring alkcall §1): the highest-value alkcall -targets — envelope framing, chunk header, demux, manager — are upstream +targets (envelope framing, chunk header, demux, manager) are upstream and now fuzzed (0.8.1). Our params parsing is serde + registry-side jsonschema (upstream's `spec_parse` target covers attacker-shaped schemas compiling at registration). Expected yield here: datagram-codec pathologies, the pending-queue bound (§1.3), serde/untagged-enum edge cases in params, and lifecycle invariants in the two-pump shape. That -is worthwhile, not five-targets-worth of new machinery on day one — +is worthwhile, not five-targets-worth of new machinery on day one, hence the two-wave scope in §4. If the first campaigns find nothing, that is the successful-negative-result posture alkcall's §1 codified: "we think the codec is robust" becomes a demonstrated property, and the @@ -69,7 +69,7 @@ nightly confined to the `fuzz/` subtree via `fuzz/rust-toolchain.toml`, the crate itself stays stable at MSRV 1.88. AFL optional/deferred; bolero deferred (same async-friction argument); libafl/honggfuzz/OSS-Fuzz not adopted. Sanitizer: `--sanitizer=address` with debug assertions -(cargo-fuzz defaults) — no MSAN (no `unsafe` in this crate). +(cargo-fuzz defaults); no MSAN (no `unsafe` in this crate). **Layout (the alkcall pattern, load-bearing):** @@ -81,7 +81,7 @@ fuzz/ │ ├── datagram_codec.rs │ ├── params_parse.rs │ └── session_pumps.rs -├── shared/ alktunnels-fuzz-shared — STABLE-toolchain library +├── shared/ alktunnels-fuzz-shared, the STABLE-toolchain library │ └── src/… invariant logic + corpus replay (§3) ├── corpus// committed hand-made seeds (§3) ├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern) @@ -90,16 +90,16 @@ fuzz/ ``` - **Invariant logic lives in `fuzz/shared/`**, not inline in the target - binaries — the stable-toolchain corpus replay + binaries: the stable-toolchain corpus replay (`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME invariant functions the fuzzer runs, on stable, in the release verification checklist. This is CI tier-3's deliverable (alkcall §7.2) minus automation; per alkcall §7.9 this repo has no hosted CI - and will not get one (same gitea posture) — corpus replay as plain + and will not get one (same gitea posture); corpus replay as plain `cargo test` is the fuzz gate. - **Root `Cargo.toml` needs the explicit `[workspace]` table** (`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the - publish exclude list — without it cargo auto-discovers `fuzz/shared/` + publish exclude list; without it cargo auto-discovers `fuzz/shared/` into the main workspace and the MSRV toolchain tries to build the nightly-consumed dev-deps (the exact failure alkcall hit). - **Corpus policy:** commit hand-made seeds, gitignore grown corpora @@ -120,12 +120,12 @@ arbitrary). Drives `DatagramReader::feed` + `frame_datagram` - **no-panic on any byte sequence** (the baseline; the one `unreachable!` in `feed` is a structural invariant of the state - machine — the target is how it stays true). -- **infallibility**: `feed` never errors (contract — oversize is + machine; the target is how it stays true). +- **infallibility**: `feed` never errors (contract: oversize is rejected at frame time; the decode direction is infallible by design, `src/wire.rs` docs). - **datagram-size bound**: every yielded datagram is - `<= MAX_DATAGRAM_LEN` (u16 frame field makes this structural — the + `<= MAX_DATAGRAM_LEN` (u16 frame field makes this structural; the target pins it against future refactors). - **no partial yields**: a datagram completes only when exactly `declared` payload bytes are buffered (`is_mid_datagram` observable @@ -144,15 +144,15 @@ arbitrary). Drives `DatagramReader::feed` + `frame_datagram` `MAX_DATAGRAM_LEN`, echoing the sizes. - **pending-queue bound (the §1.3 finding-class assertion)**: total bytes retained across `buf` + `current` + `pending` after a feed is - bounded by the fed chunk size plus the one in-flight frame — + bounded by the fed chunk size plus the one in-flight frame, so an adversarial peer chunk cannot force unbounded queue growth in the decoder state. (NOTE: this is the *decoder-state* bound; the consumer-side `DatagramHalves.pending` backlog between chunk-read and `recv_datagram` pulls is a session-shape question tracked in - §4/§8 — the wave-2 stateful target, or a code fix if the bound is + §4/§8 (the wave-2 stateful target, or a code fix if the bound is judged unacceptable. The fuzzer documents the amplification factor; the remediation decision is ours. **Wave-2 resolution (§3.3):** the - session-shape backlog is now an asserted invariant — the harness's + session-shape backlog is now an asserted invariant: the harness's per-op drain holds the pending queue to the routed-batch remainder (≤ one chunk's worth of batched datagrams, drained by `recv_datagram` pulls), so the amplification question is bounded @@ -162,7 +162,7 @@ arbitrary). Drives `DatagramReader::feed` + `frame_datagram` ### 3.2 `params_parse` (wave 1) Input: raw bytes → `serde_json` (with alkcall's bounded -`arbitrary_value` approach — depth/width-capped `Value` generation) → +`arbitrary_value` approach, depth/width-capped `Value` generation) → `TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` / `SubstrateAddr` deserialization + `parse_listen_reply` / `listen_reply_fields` (`src/params.rs`). Invariants: @@ -176,7 +176,7 @@ Input: raw bytes → `serde_json` (with alkcall's bounded constructed params of every substrate. - **deny-unknown posture**: unknown top-level fields reject; untagged-variant leniency (extra fields inside a `SubstrateAddr` - variant) is the documented asymmetry — asserted, not "fixed". + variant) is the documented asymmetry (asserted, not "fixed"). - **the `listen: 0` pin**: zero rejects at serde level. - **projection agreement**: `listen_reply_fields` yields both-`Some` iff `parse_listen_reply` is `Ok`; it never panics and never refuses @@ -186,16 +186,16 @@ Input: raw bytes → `serde_json` (with alkcall's bounded ### 3.3 `session_pumps` (wave 2) -Input: op sequences (`#[derive(Arbitrary)]` `SessionSequence` — the +Input: op sequences (`#[derive(Arbitrary)]` `SessionSequence`, the `manager_routing` pattern) over one live channels stack per exec: MuxRunner → raw chunk stream → the real `run_demux_loop_for_client` demux → `ChannelManager` per side (producer Connect / consumer -Accept over one duplex transport — the reverse POC's topology). Ops: +Accept over one duplex transport, the reverse POC's topology). Ops: `Route` (producer→consumer framed datagrams / raw stream bytes via `route_payload`), `AdoptDatagram` / `AdoptStream` (the -`TunnelSession` data-plane mirrors — codec reader + `pending`, and +`TunnelSession` data-plane mirrors: codec reader + `pending`, and `pump_bidi` against in-process peer halves), `Pump` (peer→channel; -`len == 0` drops the p2c source — the EOF path), `Teardown`, +`len == 0` drops the p2c source, the EOF path), `Teardown`, `ClearAll`. Invariants: - **no-panic / no-wedge on any op sequence** (the baseline; the @@ -203,23 +203,24 @@ Accept over one duplex transport — the reverse POC's topology). Ops: and §5's `-timeout=25` fork containment is the outer net). - **two-pump shutdown-on-completion (convention 8)**: after the p2c source drops, the pump's channel-side shutdown must propagate and - the pump must complete once the teardown EOFs the c2p source — + the pump must complete once the teardown EOFs the c2p source, pinned by the `reconcile` join with the 5 s bound. - **lossless p2c copy**: the pump's `p2c` count equals exactly what the harness pushed into the peer half (copy never truncates). - **EOF-sentinel teardown semantics**: after a sentinel (teardown / `clear_all`), the reassembled stream drains its queue before - reporting EOF — every routed datagram consumable, nothing lost; + reporting EOF, with every routed datagram consumable and nothing + lost; the consumer map empties; the codec's `len == 0` sentinel layering holds at the session layer (a routed empty datagram is data; the channels EOF is a separate mechanism). - **FIFO reconciliation**: every complete frame routed through the real demux path arrives as exactly one datagram of the declared - length, in order (the model walks the framing as the codec does — - the wave-1 F-2 walk, reused at the session layer). + length, in order (the model walks the framing as the codec does, + the wave-1 F-2 walk reused at the session layer). - **pending-queue bound (the §1.3 finding-class assertion)**: the session-shape `DatagramHalves.pending` backlog never exceeds the - unrouted remainder — i.e. the backlog holds only datagrams decoded + unrouted remainder (i.e. the backlog holds only datagrams decoded from routed chunks beyond the ones consumed; with the harness's per-op drain the queue returns to zero after every op, so an amplification factor > 1× chunk size would trip the assert. (The @@ -230,12 +231,12 @@ Accept over one duplex transport — the reverse POC's topology). Ops: Bounds: 128 ops, ≤ 512-byte payloads (generator-clamped; the codec's `MAX_DATAGRAM_LEN` stays the wire bound). Even-id masking (ADR-047 -§5 — the consumer is the Accept side; the producer allocates even). +§5: the consumer is the Accept side; the producer allocates even). ## 4. Scope: two waves **Wave 1 (complete):** the two targets above. Both drive already- -`pub` APIs — no `#[cfg(fuzzing)]` exposure needed (matching alkcall's +`pub` APIs, with no `#[cfg(fuzzing)]` exposure needed (matching alkcall's experience where none of five targets needed it). **Wave 2 (this session):** `session_pumps`, the stateful op-sequence @@ -244,7 +245,7 @@ style (`#[derive(Arbitrary)]` op enum; live mux + demux runners; the alkcall harness lessons applied: even-id masking, drained-state asserts). Drives: the `TunnelSession` data-plane shapes (the datagram codec plane + the `pump_bidi` stream plane) over a REAL channels -stack — the two-pump shutdown-on-completion contract (AGENTS.md +stack: the two-pump shutdown-on-completion contract (AGENTS.md convention 8), the EOF-sentinel teardown semantics, and the §1.3 backlog bound as byte-accounting invariants (§3.3). The `forwarded_routing` correlation target stays deferred until the @@ -265,40 +266,44 @@ run is precisely the workload shape that kills its own host: - **OOM-killer shared fate**: the target's worst case IS unbounded allocation. If libFuzzer's soft RSS guard loses the race with the - kernel, the OOM killer fires — and it picks the largest-RSS process + kernel, the OOM killer fires, and it picks the largest-RSS process in the cgroup, which can be the opencode server hosting the session. That is *recoverable* (sessions are recorded in a DB) but hugely - derailing — the session's live environment access dies mid-task. + derailing, since + the session's live environment access dies mid-task. An OOM in a fuzz target must cost the fuzzer, never the session. - **Fork bombs / CPU saturation**: `-fork=N` workers; a runaway campaign starves the session host. - **Wedged targets**: a deadlock in an async harness would hang a foreground bash call forever. -Three layers of defense, cheapest first — **all mandatory**: +Three layers of defense, cheapest first, **all mandatory**: 1. **libFuzzer soft limits (always on)**: `-rss_limit_mb=2048` `-malloc_limit_mb=2048` make libFuzzer exit cleanly (artifact recorded) before the kernel gets involved. Note the RSS limit is *soft* (polls `/proc/self/statm`); a fast huge allocation can beat - it — that is what layers 2–3 are for. Never `ulimit -v` with ASAN + it, and that is + what layers 2–3 are for. Never `ulimit -v` with ASAN (it reserves terabytes of virtual address space); the ASAN-safe hard-limit recipe is `-fork=1`. 2. **Fork mode as blast-radius containment (default)**: `-fork=1` - (or a small number) runs each input in a short-lived child — a + (or a small number) runs each input in a short-lived child, so a crash/timeout/OOM/leak kills only that child; the harness survives, records the artifact, keeps fuzzing. Also the deadlock answer: the per-input timeout kills the child, not the session's bash call. 3. **Process detachment from the session tree (MANDATORY for agent-run campaigns)**: never run fuzzing as a foreground child of the session. The runner script wraps `cargo fuzz run` in `setsid` + - `nohup` + full redirection to a log file: (a) removes the fuzzer + `nohup` + full redirection to a log file, which (a) removes the + fuzzer from the session's controlling terminal and signal relation, (b) survives the session ending, (c) gives the agent a pollable log + artifact directory instead of a blocking call. Combined with fork mode, an OOM-class finding costs one child process. -**The runner** (`fuzz/run-detached.sh`, CWD-independent — resolves its +**The runner** (`fuzz/run-detached.sh`, CWD-independent, since it +resolves its own repo root): ```bash @@ -323,12 +328,12 @@ echo "pid=$! log=$log" ``` **Agent polling protocol** (never `wait` on the detached process from a -tool call — poll instead): +tool call; poll instead): -- `tail -n 50 fuzz/artifacts/-*.log` — campaign progress. -- `ls fuzz/artifacts/` — `crash-*` / `oom-*` / `timeout-*` / `leak-*` +- `tail -n 50 fuzz/artifacts/-*.log`: campaign progress. +- `ls fuzz/artifacts/`: `crash-*` / `oom-*` / `timeout-*` / `leak-*` artifacts are the findings. -- `pgrep -f "cargo fuzz run "` — still running? +- `pgrep -f "cargo fuzz run "`: still running? - One detached campaign per target at a time; log filenames carry the UTC timestamp. @@ -338,29 +343,29 @@ the params target (alkcall's `json.dict` transfers as-is). ## 6. No hosted CI -Per alkcall §7.9 — the standing policy applies to this repo identically: +Per alkcall §7.9, the standing policy applies to this repo identically: no hosted CI will exist; do not add Actions/Gitea-Actions workflow files. **Corpus replay is the fuzz gate as plain `cargo test`**: `cargo test --manifest-path fuzz/shared/Cargo.toml` replays every -committed seed through the invariant functions — add it to the release +committed seed through the invariant functions; add it to the release verification checklist (AGENTS.md). Campaigns run manually via the -detached runner (§6) — e.g. before a release, or after touching +detached runner (§6), e.g. before a release, or after touching `src/wire.rs`, the consumer data planes, or `src/params.rs`. ## 7. Implementation log -- **2026-09-28 — proposal adopted** (this doc). Wave-1 targets +- **2026-09-28, proposal adopted** (this doc). Wave-1 targets `datagram_codec` + `params_parse`; wave-2 deferred (§4). -- **2026-09-28 — `alkcall` bumped 0.8.0 → 0.8.1.** Bug-fix release, no +- **2026-09-28, `alkcall` bumped 0.8.0 → 0.8.1.** Bug-fix release, no API/wire change; the fix (duplicate adopt/open no longer destroying the live channel) is directly relevant to this crate's - `TunnelSession::adopt` path — the changelog's "open-op response + `TunnelSession::adopt` path: the changelog's "open-op response replayed / id re-announced" scenario is our reverse path. Verification: full suite (135 passed), clippy, fmt, wasm check (§8 commands). -- **2026-09-28 — wave-1 implemented.** `fuzz/` workspace laid out per +- **2026-09-28, wave-1 implemented.** `fuzz/` workspace laid out per §2 (nightly-pinned subtree, stable `fuzz/shared` library, runner, dict, generator); `datagram_codec` + `params_parse` targets over - already-`pub` APIs — no `#[cfg(fuzzing)]` exposure needed. One new + already-`pub` APIs, with no `#[cfg(fuzzing)]` exposure needed. One new public diagnostic: `DatagramReader::retained_bytes()` (the decoder-state byte count the §3.1 bound asserts against, in the `is_mid_datagram` diagnostic style). Seeds committed: 27 + @@ -368,99 +373,103 @@ detached runner (§6) — e.g. before a release, or after touching input promoted to `datagram_codec/seed-900` (see the finding below). Stable side: corpus replay green, shared crate clippy/fmt clean; main suite untouched and green; `cargo fuzz build` clean. -- **2026-09-28 — first campaign finding: a HARNESS-model bug, not a +- **2026-09-28, first campaign finding: a HARNESS-model bug, not a crate bug.** The `datagram_codec` campaign crashed in its first minute on input `00 2b 01 00 00 05` (`fuzz/artifacts/datagram_codec/ crash-e9ba284e…`, exit 77, fork-mode containment worked as - designed — the harness parent survived and persisted the artifact). - Analysis: the codec is CORRECT — `00 2b` declares a 43-byte + designed: the harness parent survived and persisted the artifact). + Analysis: the codec is CORRECT, since `00 2b` declares a 43-byte datagram; `01 00 00 05` is its in-flight payload; nothing is yielded. The invariant that tripped ("an empty datagram must come only from a [00 00] length header") was mis-modeled: it counted `00 00` byte pairs anywhere in the RAW stream, but a `00 00` pair sitting INSIDE a frame's payload region is payload, not a length - header — exactly the same class as alkcall §7.7's probe (the + header, which is exactly the same class as alkcall §7.7's probe (the harness claimed `4 + len` when the prefix counts only the body). Fix: the count now walks the framing as the decoder does (complete frames from the start; the walk stops where the decoder would be stuck mid-frame). The crash input is committed as seed-900 and replays clean; the campaign relaunched clean. -- **2026-09-28 — second harness finding (caught by corpus replay, +- **2026-09-28, second harness finding (caught by corpus replay, pre-campaign): `establishment_reason` is a pass-through extractor, not a validated table.** The params.rs doc names the pinned reason strings, but the upstream implementation (alkcall `channels/client.rs` `establishment_reason`) extracts - `details.reason` AS-IS whenever the code is `channel:open_failed` — + `details.reason` AS-IS whenever the code is `channel:open_failed`, + so a made-up reason is surfaced as-is by design, and validation is the consumer's job. The harness first asserted a closed set of reason strings and corpus replay tripped it on `made_up_reason` before any campaign ran. Invariant corrected to assert the extraction shape (Some iff code == `channel:open_failed` and reason is a string). -- **2026-09-28 — second harness finding (same replay pass): +- **2026-09-28, second harness finding (same replay pass): `is_mid_datagram` means a partial DATAGRAM, not buffered header bytes.** A dangling 1-byte length prefix at input end is buffered header state (no frame in flight, `is_mid_datagram == false`); the harness first asserted `true` for that case. Corrected: mid-datagram iff the framing walk leaves a remainder ≥ 2 bytes (a parsed-but-unfinished payload), not `!= 0`. -- **2026-09-28 — wave-1 campaigns run (10-min detached runs, §6 runner; +- **2026-09-28, wave-1 campaigns run (10-min detached runs, §6 runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25`; codec with `-max_len=65536`, params with `-dict=json.dict -use_value_profile=1`). Both exited 0.** - - `params_parse` — 3.14M execs (~5.3k exec/s), 33 fork jobs, + - `params_parse`: 3.14M execs (~5.3k exec/s), 33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage 2049 edges / 15.6k features; in-memory corpus 69 committed seeds → 3018 - entries — the serde/untagged-enum shape space was still expanding + entries; the serde/untagged-enum shape space was still expanding at budget end, so longer campaigns keep paying here. - - `datagram_codec` (post-harness-fix) — 827k execs, 33 fork jobs, + - `datagram_codec` (post-harness-fix): 827k execs, 33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 221 - edges (ft 1198) — the 2-byte-header state machine is fully + edges (ft 1198); the 2-byte-header state machine is fully enumerated, matching alkcall §7.7's `chunk_header` saturation profile. Exec/s reads low (~400) because each exec replays the input through a ~35-feed 3-byte-sliced sequence alongside the whole-input feed; the state space is small and exhausted early. - The pre-fix `datagram_codec` run (the §7 finding above) crashed at - job 4 and exited 77; fork-mode containment behaved as designed — - the harness survived, persisted the artifact, and the run wrapped - up. That artifact is the promoted seed-900. + job 4 and exited 77; fork-mode containment behaved as designed, + with the harness surviving, persisting the artifact, and the run + wrapping up. That artifact is the promoted seed-900. - Grown corpora (3018 params entries) were **not** merged into the - committed seeds — per the §2 policy, grown corpora stay + committed seeds, since + per the §2 policy, grown corpora stay gitignored. - Operational note for future sessions: `cargo fuzz run` returns before its last fork-mode children fully exit; a `pkill -f "cargo fuzz run "` followed immediately by another `pgrep` of the same pattern can wedge a bash tool call on its own - process match — prefer `pgrep -af` to LIST, kill by explicit pid + process match. Prefer `pgrep -af` to LIST, kill by explicit pid from the runner's echoed `pid=`, and never pattern-kill-and-recheck in one call. - **NO crate bugs found by wave 1.** The two findings this session were both harness-model defects (§7 above), each invisible to - example-based review and caught by fuzzing/replay — the alkcall §7.7 + example-based review and caught by fuzzing/replay: the alkcall §7.7 "fuzzer as a harness-checker" pattern, confirmed. The §1.3 decoder-state bound held everywhere: `retained_bytes()` never exceeded fed + one frame on any adversarial input. The session-shape `DatagramHalves.pending` amplification remains a documented - §1.3/§4 note (wave-2's motivation), not a campaign finding — the + §1.3/§4 note (wave-2's motivation), not a campaign finding, since + the wave-1 targets assert the decoder, not the session pump. -- **2026-09-28 — wave-2 implemented: `session_pumps` (the stateful +- **2026-09-28, wave-2 implemented: `session_pumps` (the stateful two-pump/EOF-sentinel target, §3.3).** The harness mirrors `TunnelSession`'s data-plane shapes over one REAL channels stack per exec (MuxRunner → raw chunk stream → the real `run_demux_loop_for_client` demux → `ChannelManager` per side over - one duplex transport; producer Connect / consumer Accept — the + one duplex transport; producer Connect / consumer Accept, the reverse POC's topology). Ops: Route/AdoptDatagram/AdoptStream/ Pump/Teardown/ClearAll; invariants per §3.3. One diagnostic addition to the crate: `TunnelSession::datagram_backlog()` (the `DatagramHalves.pending` count the §3.3 bound asserts against, in the `retained_bytes()` diagnostic style) + a suite test pinning its rest states. Seeds: 8 committed (deterministic decode-scan - hex patterns, the `manager_routing` precedent — arbitrary ships + hex patterns, the `manager_routing` precedent, since + arbitrary ships no encoder) + 2 in-source semantic fixtures (the §1.3 batched- backlog probe; the W4 half-close shape). Two harness-model findings caught by corpus replay pre-campaign: - **the p2c-EOF pump-join wedge is the DESIGNED half-close shape, not a wedge.** The first `reconcile` draft awaited the - pump after dropping only the p2c source — but the c2p pump's + pump after dropping only the p2c source, but the c2p pump's source (the channel read half) EOFs only via teardown, so the join legitimately blocked (the 5 s bound tripped). `pump_bidi` completes when BOTH pumps finish; the half-close contract is @@ -468,46 +477,46 @@ detached runner (§6) — e.g. before a release, or after touching observable), and the pump completes once the c2p source EOFs too. The harness now models exactly that: drop p2c source → teardown the entry → join with the 5 s bound. (The alkcall §7.7 - harness-checker pattern again — the fuzzer harness's first + harness-checker pattern again: the fuzzer harness's first campaign-found crash in wave 1 and this replay catch are both harness-model defects, not crate bugs.) - - **the stream-plane c2p accounting is asynchronous** — the pump + - **the stream-plane c2p accounting is asynchronous**: the pump writes channel→peer into the peer duplex while a far-end reader task counts; the reconcile join (after the pump completes) is when the count is final. First draft read it early. Fixed by reading after the reader task's join. -- **2026-09-28 — third harness finding (campaign-found, fork-mode +- **2026-09-28, third harness finding (campaign-found, fork-mode containment as designed): duplicate adopt is a LEGAL manager outcome, not a finding.** The first campaign crashed at ~49 s on `AdoptDatagram { channel_id: 0 }` after the id had already been - adopted — `ChannelExists` is the manager's documented + adopted. `ChannelExists` is the manager's documented id-collision error, and my adopt arms treated every error as a finding. Fix: both adopt arms model the documented error table (`ChannelExists` / `TooManyChannels` are no-ops, exactly the alkcall `manager_routing` harness's treatment). The crash input is committed as seed-900 and replays clean; the campaign relaunched clean. -- **2026-09-28 — fourth harness finding (second campaign): the +- **2026-09-28, fourth harness finding (second campaign): the harness modeled the producer→consumer path through the WRONG primitive.** The second campaign crashed at ~5 s on `Route`: raw-id addressing (`even_id` masked arbitrary ids; collisions + an `UnknownChannel`-class state the model didn't track), `len == - 0` raw routes doubling as EOF sentinels mid-model, and — the - structural one — `route_payload` was used as the producer's + 0` raw routes doubling as EOF sentinels mid-model, and + (the structural one) `route_payload` was used as the producer's forward write. `route_payload` routes into the LOCAL manager's channel entry (the demux direction); the producer pump handler writes the channel's SEND half (the mux pump frames onto the transport). Bytes never reached the consumer. Rewrite (the real protocol shape, matching `manager_routing`'s op-vocabulary lesson): producer `Open` (`open_channel` allocates the id, the - harness holds the send half — the pump handler's write side), + harness holds the send half, the pump handler's write side), session-INDEX addressing (no masking, no collisions), `Adopt` consumes the producer-allocated id (the early-arrival drain is the real adopt-race path), `Route` writes the producer send half (the real wire path: mux pump → transport → consumer demux → reassembly), `Pull` (the `recv_datagram` pull pops the backlog + the model), and a STATEFUL framing model (partial frames persist - across ops — the wave-1 walk carried across ops). EOF sentinels + across ops, with the wave-1 walk carried across ops). EOF sentinels now arise only from teardown/sender-drop (the real mechanisms), never from `len == 0` routes (F-2: a routed empty datagram is DATA). All committed session_pumps seeds regenerated by the @@ -515,22 +524,22 @@ detached runner (§6) — e.g. before a release, or after touching the semantic fixtures re-pinned. Verification of the fix: the campaign's 0-exec crash went away; batched frames now reconcile FIFO through the real two-manager stack. -- **2026-09-28 — fifth + sixth harness findings (third campaign's +- **2026-09-28, fifth + sixth harness findings (third campaign's first minutes): the two ClearAll modeling defects.** (5) A - double-adopt of a live session panicked the harness's own guard — + double-adopt of a live session panicked the harness's own guard, but a re-adopt of a LIVE id is the manager's documented `ChannelExists` outcome (a legal no-op; alkcall 0.8.1's insert-replace fix leaves the live entry intact). Modeled as a no-op. (6) A `ClearAll` followed by `Route` on the same session - tripped the backlog reconciliation — the harness retired only the + tripped the backlog reconciliation. The harness retired only the sessions whose ids appeared in `clear_all`'s RETURN value, but that value is the OPENER-LEDGER records (ADR-047 §7): adopted channels carry no ledger entry, so the set is empty while the map itself empties. The alkcall §7.7 lesson ("`drained.len() == open_count()` is not an invariant"), learned again at this - crate's layer — the map-empties assert is the ground truth; the + crate's layer: the map-empties assert is the ground truth; the harness now retires EVERY session on `ClearAll`. -- **2026-09-28 — wave-2 campaign run (10-min detached run, §6 +- **2026-09-28, wave-2 campaign run (10-min detached run, §6 runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 -max_len=4096 -use_value_profile=1`): exited 0.** 228,676 execs (~380 exec/s; each exec replays up to 128 ops on a @@ -538,7 +547,7 @@ detached runner (§6) — e.g. before a release, or after touching `oom/timeout/crash: 0/0/0` throughout. Coverage 3427 edges / 17,529 features / 1184 in-memory corpus entries and STILL EXPANDING at budget end (the op-interleaving space is much larger - than the parser targets') — longer campaigns keep paying. The + than the parser targets'), so longer campaigns keep paying. The two-pump contract held across every adversarial sequence: no wedged pump (the 5 s reconcile join), no lost datagram (the FIFO/backlog reconciliation), no premature EOF (the sentinel @@ -547,14 +556,14 @@ detached runner (§6) — e.g. before a release, or after touching routed-but-unpulled remainder. - **NO crate bugs found by wave 2.** All six findings across waves 1–2 were harness-model defects (four this session), each - invisible to example-based review and caught by fuzzing/replay — + invisible to example-based review and caught by fuzzing/replay: the alkcall §7.7 "fuzzer as a harness-checker" pattern, re-confirmed. The stateful thesis is validated from the harness side: the harness defects themselves (double-adopt semantics, the clear_all ledger/map distinction, the route_payload direction) are exactly the interleaving-shape mistakes the alkcall §7.8 campaigns caught in their own drivers. -- (progress appended as implementation proceeds — see git log) +- (progress appended as implementation proceeds; see git log) ## 8. Verification commands @@ -579,9 +588,9 @@ Campaign flags per target: `datagram_codec -max_len=65536`; - Upstream campaign + operational model (authoritative for tooling, runner, corpus policy, no-CI posture): `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` -- alkcall 0.8.1 changelog — the finding our adopt path is exposed to +- alkcall 0.8.1 changelog: the finding our adopt path is exposed to - This crate: ADR-003 (codec framing), ADR-002 (ALPN), ADR-007/008 (direct/forwarded ops), `docs/architecture/wire.md` -- External: RUSTSEC-2026-0037 (quinn-proto — the maintainers' +- External: RUSTSEC-2026-0037 (quinn-proto, the maintainers' fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust Fuzz Book (all linked from the upstream doc's §9) \ No newline at end of file diff --git a/docs/research/phase-0-findings.md b/docs/research/phase-0-findings.md index 2c422c0..4537a94 100644 --- a/docs/research/phase-0-findings.md +++ b/docs/research/phase-0-findings.md @@ -3,7 +3,7 @@ status: draft last_updated: 2026-09-05 --- -# alktunnels — Phase 0 Research Findings +# alktunnels: Phase 0 Research Findings This document captures Phase 0 (Exploration) findings and open design questions for the `alktunnels` crate. The objective of Phase 0 per @@ -13,16 +13,17 @@ input to Phase 1 (Architecture), where the Architect will produce `docs/architecture/` specs, ADRs, and open questions. Drafted 2026-09-05, emerging from the initial setup discussion. The crate is -the sibling of alktty (`alk/tty` — terminal sessions) on the alkcall +the sibling of alktty (`alk/tty`, terminal sessions) on the alkcall substrate: where alktty multiplexes one service with a fixed five-stream channel structure, alktunnels generalizes the tunnel handler shape to -arbitrary bidirectional tunnels in the `ssh -L` / `ssh -D` sense — TCP, UDP, +arbitrary bidirectional tunnels in the `ssh -L` / `ssh -D` sense, meaning +TCP, UDP, unix sockets, and other stream or datagram substrates. The 2026-09-05 first revision adds the tun2proxy UDP gateway prior art (`OQ-TN-01`, `OQ-TN-02`, `OQ-TN-07`, `OQ-TN-10`) and the transport story -clarification (TCP vs QUIC at the channels layer, invisible to this crate — -§What is already settled). This prior art was discussed with the POC agent +clarification (TCP vs QUIC at the channels layer, invisible to this crate; +see §What is already settled). This prior art was discussed with the POC agent around the alknet-channels POC but never documented; it is captured here so it survives into the spec. @@ -31,34 +32,34 @@ it survives into the spec. The foundation is POC-validated and ADR-pinned; this crate is not starting from zero. It inherits: -- **The demux→Connection→handler→mux path** — validated by the +- **The demux→Connection→handler→mux path**, validated by the alknet-channels POC (Target 3), now production alkcall channels. The tunnel payload is raw bytes inside a channels data channel; channels strips its 8-byte header transparently (alknet ADR-093 / alkcall ADR-035). -- **The two-pump handler shape** — one pump per direction, each pump MUST +- **The two-pump handler shape**: one pump per direction, and each pump MUST shut down the opposite sink on completion (`try_join!` alone deadlocks; alknet ADR-078). POC-validated with a 1 MiB backpressure test. This crate is the *second* two-pump consumer the ADR deferred helper extraction for (the first was the POC's tunnel handler; SSH `direct-tcpip` would be a later third). -- **The producer/consumer model** — producer registers openable channels +- **The producer/consumer model**: the producer registers openable channels via `ChannelCore::register_openable` (authorization for free via - `AccessControl`); consumer opens tunnel channels via `ChannelClient` + `AccessControl`); the consumer opens tunnel channels via `ChannelClient` (alkcall ADR-037, ADR-043). Connection direction is independent of tunnel direction. -- **The backend inversion point pattern** — substrate-specific types - (`TcpStream`, `UdpSocket`, unix sockets) confined to feature-gated +- **The backend inversion point pattern**: substrate-specific types + (`TcpStream`, `UdpSocket`, unix sockets) are confined to feature-gated backend modules, injected at the assembly layer, never imported from the shared/producer/consumer modules (alktty `TtyBackend` precedent). -- **The wasm-clean default crate** — protocol-only code compiles to +- **The wasm-clean default crate**: protocol-only code compiles to `wasm32-unknown-unknown`; socket/platform I/O is feature-gated (alktty precedent). -- **The relay story** — tunnels traverse alkcall hub relays +- **The relay story**: tunnels traverse alkcall hub relays transparently via byte-for-byte data-channel forwarding with ID rewrite (alkcall ADR-042). No tunnel-specific relay work. -- **The transport story** — the underlying channels transport is TCP or - QUIC (QUIC preferred) but that is the alkcall layer's concern, not +- **The transport story**: the underlying channels transport is TCP or + QUIC (QUIC preferred), but that is the alkcall layer's concern, not this crate's: the tunnel protocol sees a `BiStream` and is transport-agnostic like the sibling crates. UDP tunneling (below) rides the same chunk stream; the stream-vs-datagram question is @@ -69,7 +70,7 @@ from zero. It inherits: Discussed 2026-09-05. The standing stance across the alk* crates: **the hub owns the connection and explicitly proxies to expose resources for -others.** A produced resource is not a socket — it is an ACL-scoped +others.** A produced resource is not a socket, it is an ACL-scoped *virtual resource* on the side that can reach the real target. Applied to tunnels, this dissolves the SSH direction model: @@ -79,11 +80,11 @@ to tunnels, this dissolves the SSH direction model: wants the bytes is the consumer (opens channels via `ChannelClient`). This holds for both SSH `-L` and `-R`: in both, the entry-point side opens the channel (consumer) and the target side handles the open - (producer) — the only difference is which machine hosts the entry + (producer), and the only difference is which machine hosts the entry point, which is assembly-layer wiring, not protocol. - **The "exposed port" is a virtual port.** The far side may *think* it exposed a port on the remote end; what actually exists is an - ACL-scoped resource in the connection/identity registry — the same + ACL-scoped resource in the connection/identity registry, the same shape as operation registration, not a bind. Example: a self-hosted gitea (HTTP in a docker container, really binding a port) is tunneled to the hub; the hub proxies the tunnel to consumers whose ACL grants @@ -92,12 +93,12 @@ to tunnels, this dissolves the SSH direction model: - **The hub proxy is a producer wrapping a consumer.** The proxy is an assembly-layer construct. On leg 1 (gitea side ↔ hub): gitea side is producer, hub is consumer. On leg 2 (hub ↔ service consumer): the hub - re-produces the resource ACL-scoped — producer wrapping its leg-1 - consumer role. Data path per hop is the same two-pump shape; the + re-produces the resource ACL-scoped (producer wrapping its leg-1 + consumer role). Data path per hop is the same two-pump shape; the consumer opens a channel naming the resource, the producer's open handler dials the target. This means the hub *terminates and - re-produces* rather than relaying byte-for-byte — unlike alkcall - ADR-042's transparent data-channel relay — because ACL must apply + re-produces* rather than relaying byte-for-byte, unlike alkcall + ADR-042's transparent data-channel relay, because ACL must apply per hop. (ADR-042 relays remain valid for transports that don't need per-hop ACL; the hub's tunnel proxy is the explicit-ACL path.) - **`-D` (dynamic/SOCKS) composes.** A SOCKS5 server at some assembly @@ -108,7 +109,7 @@ to tunnels, this dissolves the SSH direction model: - **No forced binding, everywhere (OQ-TN-04 resolved).** Binding is always assembly-layer and optional, on either side. The protocol never binds: a producer "produces a resource" (which may or may not - correspond to a local bind — a docker container's port, a unix + correspond to a local bind: a docker container's port, a unix socket, an in-process service); a consumer "consumes it." The SSH mental image of "expose a port" is an illusion the assembly layer may create locally; the protocol only ever carries @@ -120,10 +121,10 @@ it; direction, bind, and ACL scoping are assembly-layer concerns wrapped around that pair. **Residue (folded into other OQs):** -- How does a consumer *discover* produced resources — resolved +- How does a consumer *discover* produced resources? Resolved 2026-09-05: the existing bidirectional ACL-filtered ops listing (openable channels are operations, alkcall ADR-047). See OQ-TN-08. -- Dynamic-target policy for `-D`-style opens — dissolved 2026-09-05: +- Dynamic-target policy for `-D`-style opens. Dissolved 2026-09-05: `-D` is "just tunnel a socks5 connection"; target selection lives in the socks5 protocol at the producing side, governed by the same op-level ACL. See OQ-TN-08. @@ -135,42 +136,42 @@ wrapped around that pair. ## Prior art: tun2proxy UDP gateway (`OQ-TN-01`, `OQ-TN-02`, `OQ-TN-07`, `OQ-TN-10`) `/workspace/tun2proxy/src/udpgw.rs` implements a UDP gateway over a TCP -stream — structurally the same problem this crate faces for UDP tunnels +stream; structurally it is the same problem this crate faces for UDP tunnels over channels data channels. Discussed with the alknet-channels POC agent as the example of "UDP over a stream substrate," but never documented. Key mechanics, all of which generalize: -- **Per-datagram length framing over the stream** — the packet format is +- **Per-datagram length framing over the stream**: the packet format is `LEN(u16 BE) | FLAGS(u8) | CONN_ID(u16) | [SOCKS5 address] | DATA` (`udpgw.rs:82-88`). Boundary preservation is re-added by the protocol, not by the substrate: exactly the "length-prefix each datagram inside the channel" half-answer in OQ-TN-02, proven in production. -- **SOCKS5 address format travels per data packet** — `ATYP` +- **SOCKS5 address format travels per data packet**: `ATYP` (0x01 IPv4 / 0x03 domain / 0x04 IPv6) + variable address + port (`udpgw.rs:68-76`). This is concrete prior art for OQ-TN-01's addressing: a scheme-tagged addressing encoding with v4/v6/domain coverage already standardized. Note the asymmetry with TCP tunnels: for UDP, the remote endpoint is per-datagram, not per-channel. -- **One stream carries many UDP flows** — `CONN_ID(u16)` multiplexes +- **One stream carries many UDP flows**: `CONN_ID(u16)` multiplexes associations over a single gateway connection (`udpgw.rs:66`), with `keepalive` (0x01) and `error` (0x20) flag packets as the only non-data frame types (`udpgw.rs:21-26`). This is the "one channel = one association, per-endpoint multiplexing inside" half-answer in OQ-TN-02, with the refinement that the per-endpoint multiplexing key (`CONN_ID`) is protocol-level, allocated by the client, u16. -- **Flow lifecycle is packet-level** — `udp_timeout` idle expiry, +- **Flow lifecycle is packet-level**: `udp_timeout` idle expiry, `keepalive_time` heartbeats on idle connections (`UDPGW_KEEPALIVE_TIME = 30s`, `udpgw.rs:16`), and an MTU cap (`parse_udp_response` rejects `data.len() > udp_mtu`, `udpgw.rs:527`). Also `UDPGW_MAX_CONNECTIONS = 5` pooled gateway - connections *above* the packet layer — a throughput choice, not a + connections *above* the packet layer, a throughput choice, not a protocol requirement; channels gives us N channels already. - **Implications for alktunnels:** - UDP boundary preservation over the chunk stream is validated - prior art, not speculation — raises confidence in the OQ-TN-02 + prior art, not speculation, and it raises confidence in the OQ-TN-02 half-answer considerably. - The frame-type set (DATA/KEEPALIVE/ERR) is a useful minimal - vocabulary — historically mapped onto OQ-TN-09's establishment + vocabulary; it historically mapped onto OQ-TN-09's establishment question, now superseded there by alkcall ADR-049 (establishment is the open op's reply, not an in-stream frame). KEEPALIVE and ERR both drop (channels owns liveness; establishment is @@ -185,20 +186,20 @@ documented. Key mechanics, all of which generalize: underlying transport (QUIC/TCP) handles connection keepalive, and channel liveness is the channels layer's concern. The tunnel protocol likely needs only flow-level idle expiry, not - transport-level keepalive packets — TBD in the spec. + transport-level keepalive packets (TBD in the spec). ## Prior art: the alktty stream-splitting pattern (`OQ-TN-09`, `OQ-TN-05`) Discussed 2026-09-05. alktty tunnels io-like streams: the adapter splits the `BiStream` from `accept_bi` into read/write halves (`tokio::io::split`), wraps each half in a `ChunkReader`/`ChunkWriter` -(`wire.rs` — the 5-byte `[stream_type: u8][length: u32 be][payload]` +(`wire.rs`, the 5-byte `[stream_type: u8][length: u32 be][payload]` codec), and pumps chunks between the wire halves and the backend's `TtyHandle` halves. Strip packets on the way in, wrap packets on the way out; everything above the codec sees plain `AsyncRead`/`AsyncWrite`. The proposal: that same pattern (or close) may be *the* tunnel pattern. -A tunnel is then literally "tty without the five stream types" — split +A tunnel is then literally "tty without the five stream types": split the channel's `BiStream`, wrap the halves in a codec, pump to/from substrate halves. What differs per concern is only the codec and the far-end handle: @@ -215,12 +216,12 @@ Implications: establishment/error half is resolved at the call layer (the open op replies `channel:open_failed`; no in-stream establishment frame). What the ctrl-frame pattern still informs: whether a *mid-stream* - control frame is ever needed (v1 likely not — see OQ-TN-09's + control frame is ever needed (v1 likely not; see OQ-TN-09's resolved status), and the alktty zero-length-sentinel EOF semantics carry over unchanged either way. - **OQ-TN-05 (backend trait) gets a concrete shape to judge.** If tunnels are "split + wrap + pump," the far-end handle is just - "(AsyncRead + AsyncWrite) halves" for stream substrates — which is + "(AsyncRead + AsyncWrite) halves" for stream substrates, which is either a very thin trait or no trait (fn returning boxed halves). The UDP case's flow table is the real differentiator to design around, not the stream case. @@ -228,25 +229,25 @@ Implications: pump loop shape is identical to alktty's modulo the number of pumps, a shared helper extraction (this crate's second-consumer moment, alknet ADR-078) should be evaluated against alktty's `pump_session` - too — the convergence test the ADR asked for may be nearly free. -- **Wasm story intact** — the codec is pure byte manipulation; the + too; the convergence test the ADR asked for may be nearly free. +- **Wasm story intact**: the codec is pure byte manipulation; the split/pump plumbing is tokio-async, no substrate types in the protocol layer. Same as alktty. **Codec direction set 2026-09-06 (discussion): raw pass-through for -stream substrates, `u16` length-prefix for UDP — no 5-byte header.** +stream substrates, `u16` length-prefix for UDP; no 5-byte header.** Phase 1 ADR, before the first consumer. Rationale (structural, not stylistic): alktty's 5-byte `[stream_type:u8][len:u32]` exists because five logical streams share -one `BiStream` — the type byte is a sub-demux key. A tunnel has +one `BiStream`; the type byte is a sub-demux key. A tunnel has exactly one data stream per direction (the channel's own read/write halves), so there is nothing to demux and no per-chunk type byte. TCP needs no length prefix either: the channels layer already length-prefixes every chunk (8-byte header), so the codec is pure pass-through. Only UDP needs boundary re-framing (datagram boundaries -do not survive the chunk stream), and `u16` suffices — UDP's max -payload is 65507 < 65535. +do not survive the chunk stream), and `u16` suffices (UDP's max +payload is 65507 < 65535). Per-chunk wire overhead (data chunks, both directions): @@ -262,7 +263,7 @@ DNS-sized datagram (~100 B) pays ~3% for boundary framing. Trade-offs accepted: - **No in-band control path for TCP channels, ever** (any future - mid-stream signaling is a wire break). Post-ADR-049 this is clean — + mid-stream signaling is a wire break). Post-ADR-049 this is clean: establishment is call-level (`channel:open_failed`), the survey found no mid-stream control need, and the escape hatch is protocol-level (a new ALPN is cheap; a wire change is not). The @@ -275,19 +276,19 @@ Trade-offs accepted: - UDP-specific framing lives only in the UDP path; a hypothetical future multi-endpoint UDP gateway resource would carry its own self-describing framing *inside* the datagram payloads (udpgw - precedent, OQ-TN-02) — invisible to this base codec. + precedent, OQ-TN-02), invisible to this base codec. Remaining for the Phase 1 codec ADR: confirm `len=0` empty-datagram semantics (send allowed? receive maps to a zero-payload datagram), and whether the UDP length prefix rides `u16 BE` (leaned) or a varint -(rejected for v1 simplicity — datagrams are MTU-bounded anyway). +(rejected for v1 simplicity; datagrams are MTU-bounded anyway). ## Open Questions These are the design questions Phase 0 must resolve (or explicitly defer) before the architecture spec. They are numbered OQ-TN-01.. so they can be referenced, tracked, and promoted into `docs/architecture/open-questions.md` -in Phase 1. Half-answers and hunches are marked as such — the point of this +in Phase 1. Half-answers and hunches are marked as such; the point of this document is to hold them without forcing premature decisions. > **Promoted 2026-09-07 (Phase 1):** OQ-TN-01..10 now live in @@ -303,43 +304,43 @@ What does the tunnel `params` on `channel/open` look like? alknet ADR-071 addressing scheme was never decided. It must cover at minimum: - TCP dial (`host:port`) -- UDP (associate-style or endpoint-style — see OQ-TN-02) +- UDP (associate-style or endpoint-style; see OQ-TN-02) - Unix domain sockets (path) -- Direction (who dials the target — see OQ-TN-03, resolved) +- Direction (who dials the target; see OQ-TN-03, resolved) - Bind/listen vs dial semantics (see OQ-TN-04, mostly resolved) Considerations: - `params` is ALPN-specific JSON, interpreted by the open handler, not by the channels layer (alknet ADR-075 / alkcall ADR-039). alktty's - precedent is the `NegotiateRequest` shape — a self-contained JSON + precedent is the `NegotiateRequest` shape, a self-contained JSON object carried in the open op. - The addressing string is wire-stable once a consumer exists (one-way door). It must be substrate-extensible without format changes (a new substrate should be an additive `scheme` value, not a v2 format). - Prior art to survey: SSH forwarding models (`direct-tcpip`, `forwarded-tcpip`), SOCKS5 addressing (ATYP + addr + port), russh - (`/workspace/russh` — note: russh has no UDP channel type at all, so + (`/workspace/russh`; note: russh has no UDP channel type at all, so there is no `direct-udp`-style prior art there; still useful for channel-open framing and addressing-intel generally). - ALPN convention check (2026-09-05): the ALPN prefix swap `alknet/` → `alk/` happened alkcall v0.1.1, before the first published consumer. Everything in this crate's docs referencing `alknet/tunnel` means - `alk/tunnel` — a small thing, but docs/ADRs must not perpetuate the + `alk/tunnel`; it is a small thing, but docs/ADRs must not perpetuate the old prefix or consumers will bake it in. -**Reframe 2026-09-05 — this is partly an XY problem.** The rich +**Reframe 2026-09-05: this is partly an XY problem.** The rich "remote addressing" framing was chasing the wrong thing. Except in the `-D`/dynamic case (which composes at the assembly layer), a tunnel is either TCP or UDP, and `params` need only *identify a produced -resource* — not carry a general-purpose address. The producer owns +resource*, not carry a general-purpose address. The producer owns where the resource comes from (a local port, a docker container's -port, an in-process service, a unix socket — its problem, most likely +port, an in-process service, a unix socket; its problem, most likely a local port). So `params` reduce to: -1. Which resource (a produced resource identifier — stable name or +1. Which resource (a produced resource identifier: stable name or target address, still to decide), -2. Substrate discriminator (`tcp` / `udp` / extensible) — so UDP is +2. Substrate discriminator (`tcp` / `udp` / extensible), so UDP is structurally supported from day one even if the vast majority of use is TCP. @@ -350,18 +351,18 @@ enters only through the `-D`/dynamic-target composition path **Status:** mostly resolved 2026-09-05 (pending the resource-naming and discovery residue). Direction of travel: - `params` = self-contained JSON object in the open op (alktty - `NegotiateRequest` precedent) — accepted path. + `NegotiateRequest` precedent), the accepted path. - `params` identify a produced resource + substrate discriminator; producer owns the backing. No URL-style general addressing. - Residue: stable resource name vs target address as the identifier - shape; discovery resolved 2026-09-05 — see OQ-TN-08 (the existing + shape; discovery resolved 2026-09-05; see OQ-TN-08 (the existing ACL-filtered ops listing IS tunnel-resource discovery, since openable channels are operations per alkcall ADR-047); exact JSON - field layout (Phase 1 spec, ADR before first consumer — wire-stable + field layout (Phase 1 spec, ADR before first consumer, wire-stable once published). **Survey input 2026-09-06** (`ssh-socks5-survey.md`): SSH's -`direct-tcpip` payload reduces to "target + informational originator" — +`direct-tcpip` payload reduces to "target + informational originator"; the originator pair has no analogue here (ACL rides the channels open-op machinery), supporting the two-field reframe. OpenSSH's `direct-streamlocal` extension is the extensibility template: new @@ -370,20 +371,21 @@ string → new `substrate` value, not a format change. SOCKS5 ATYP is not needed in base params (dynamic-path addressing only). Minimal shape: `{ "resource": , "substrate": "tcp" | "udp" | }`. -### OQ-TN-02: Datagram substrates (UDP) — boundary preservation +### OQ-TN-02: Boundary preservation for datagram substrates (UDP) Does a UDP tunnel preserve datagram boundaries end-to-end, or does the tunnel present a byte-stream abstraction to the consumer (boundaries lost, re-chunked arbitrarily)? - Channels is a chunk stream with bounded buffers; the zero-length chunk - is the EOF sentinel — datagram boundaries are *not* preserved by the + is the EOF sentinel, so datagram boundaries are *not* preserved by the substrate (alknet ADR-071/093; the POC only exercised TCP). - **Corrected 2026-09-06** (`ssh-socks5-survey.md`): an earlier line - here claimed "SSH's `-D` UDP associate tunnels UDP as a stream" — a + here claimed "SSH's `-D` UDP associate tunnels UDP as a stream"; that was + a category error. SSH has *no UDP forwarding at all* (RFC 4254 defines only TCP/X11/session channels; russh is grep-confirmed UDP-free; - only SSH3 — a different HTTP/3 protocol — has `direct-udp`). SSH + only SSH3, a different HTTP/3 protocol, has `direct-udp`). SSH `-D` carries only the SOCKS5 *TCP* control connection. The real UDP-over-stream prior art is tun2proxy udpgw (§Prior art) and SOCKS5's own UDP relay. @@ -394,7 +396,7 @@ re-chunked arbitrarily)? document (AGENTS.md convention 12). Boundary loss is cheaper but changes what protocols can ride the tunnel (DNS? QUIC? game traffic?). - Datagrams also raise multiplexing questions TCP does not: one UDP - "association" carries many remote endpoints — does one tunnel channel + "association" carries many remote endpoints. Does one tunnel channel carry one endpoint or many, and how are per-endpoint replies routed? **Status: resolved as a split by path, 2026-09-06** (survey @@ -405,28 +407,28 @@ re-chunked arbitrarily)? association). Aligns with resource naming (OQ-TN-01); per-datagram addressing would reintroduce the "general addressing in params" the reframe removed. Boundary preservation inside the channel stays - per-datagram length framing (tun2proxy-proven) — endpoint-at-open is + per-datagram length framing (tun2proxy-proven); endpoint-at-open is about addressing, not about dropping the LEN prefix. - **Dynamic/`-D` UDP (SOCKS5-style): per-datagram addressing inside the tunnel payload** (SOCKS5 UDP header or udpgw format), composed at the assembly layer, never in base params. -- **The channel ID replaces udpgw's CONN_ID** — a conn-id inside the +- **The channel ID replaces udpgw's CONN_ID**: a conn-id inside the channel would be a second demux layer (AGENTS.md convention 10). One channel per UDP flow; no protocol-level flow key. -- **KEEPALIVE drops** — udpgw's heartbeats exist for NAT-traversed - long-lived TCP; channels transport liveness is the alkcall layer's +- **KEEPALIVE drops** because udpgw's heartbeats exist for NAT-traversed + long-lived TCP, while channels transport liveness is the alkcall layer's concern. Flow-level idle expiry remains producer-side bookkeeping. - A UDP gateway resource (multi-endpoint, udpgw-shaped) remains - possible *inside* a channel as self-describing framing — invisible - to base params, keeping OQ-TN-07 option A viable. A targeted POC + possible *inside* a channel as self-describing framing, invisible + to base params and keeping OQ-TN-07 option A viable. A targeted POC (OQ-TN-10 #1) remains +EV for the channels-layer fit (chunk-size vs datagram-size, MTU vs bounded buffers, idle expiry). **Mandate strengthened 2026-09-07 (reverse POC finding F-2):** the -length-framed codec is not just boundary preservation — it is +length-framed codec is not just boundary preservation; it is *mandatory for correctness* on UDP. In the raw pass-through pump shape, an empty datagram is a zero-byte read from the substrate -adapter — indistinguishable from EOF (`tokio::io::copy` treats `Ok(0)` +adapter, indistinguishable from EOF (`tokio::io::copy` treats `Ok(0)` as end-of-stream and shuts the pump down). An empty datagram and the zero-length EOF sentinel are the same wire shape at the pump level; the `[len: u16 BE]` prefix makes an empty datagram two bytes, @@ -442,12 +444,12 @@ genuinely EOF). Validated from a second angle by the reverse-flow POC protocol-level direction: role follows the resource. Whoever can reach the target is the producer (registers openable channels); whoever wants the bytes is the consumer (opens channels). SSH `-L` and `-R` are the -same producer/consumer pair with the entry point on different machines — -assembly-layer wiring, not protocol. The "exposed port" is a virtual, +same producer/consumer pair with the entry point on different machines, +which is assembly-layer wiring, not protocol. The "exposed port" is a virtual, ACL-scoped resource on the producing side; the hub proxy is a producer wrapping a consumer. `-D`/SOCKS composes as a consumer opening channels with per-connection dynamic targets, gated by the producer's target -policy (OQ-TN-08) — not a base-crate concern. Original question retained +policy (OQ-TN-08); it is not a base-crate concern. Original question retained below for context. Considerations (original): @@ -475,7 +477,7 @@ connection. The open questions: advertises listen targets)? Does it interact with `channel/open` at all, or is it a call-level operation ("please open a tunnel channel to me when a local accept happens")? -- Dynamic (-D) may not be a tunnel concern at all — it may compose as +- Dynamic (-D) may not be a tunnel concern at all; it may compose as "SOCKS5 server implemented over alktunnels dial primitives" in a separate crate. Keep or cut for v1? @@ -483,7 +485,7 @@ connection. The open questions: the last open thread (the `-R` advertisement/lifecycle surface) closed 2026-09-07 by the reverse-flow POC (`reverse-poc-summary.md`): **no advertisement op is needed.** The listener is the initiating side's -own local resource — it binds (assembly layer, OQ-TN-04) and opens +own local resource: it binds (assembly layer, OQ-TN-04) and opens tunnel channels toward the serving side per accept; a far-side listener ("expose a port on your end") is a producer-side listen establisher over the same open op, not a new mechanism. The hunch @@ -498,7 +500,7 @@ The POC's shape dialed a target from the handler; binding is optional and belongs to the caller (assembly layer), not the protocol crate. The API surface must support: -- Dial flows with no local bind (POC shape) — covered. +- Dial flows with no local bind (POC shape) are covered. - Listen flows where the binding happens on one side only. - Unbound/abstract flows (e.g. unix socketpair-style, stdio bridges, in-process pipes) where neither side binds. @@ -508,25 +510,26 @@ channel" without assuming either endpoint is a bound socket. Substrate modules (behind feature flags) own actual `bind()` calls; the protocol owns bookkeeping only. -**Status:** open — mostly resolved (2026-09-05, see §Prior art: the +**Status:** open but mostly resolved (2026-09-05, see §Prior art: the hub-owns-the-connection model): binding is always assembly-layer and optional, on either side; the protocol never binds. What remains is the -concrete API surface — who calls what to start a tunnel in each mode +concrete API surface: who calls what to start a tunnel in each mode (produce-with-dial, produce-without-dial/accept-style, consume). This is now a spec-shape task, not a research question. -### OQ-TN-05: Backend inversion point — is there a `TunnelBackend` trait? +### OQ-TN-05: Backend inversion point (is there a `TunnelBackend` trait?) alktty has `TtyBackend` because backends (local PTY, docker, SSH) produce handles and the adapter pumps them. For tunnels, the producer side's -substrate action is narrower — dial a target, or accept on a listener — +substrate action is narrower (dial a target, or accept on a listener), so the question: - Is a `TunnelBackend`-style trait needed at all, or is the two-pump handler + feature-gated substrate modules (dial/listen helpers) the whole story, with the assembly layer wiring substrate streams directly? - If a trait: what is the handle type? A tunnel "handle" is just an - `AsyncRead + AsyncWrite` stream (or a datagram endpoint) — much thinner + `AsyncRead + AsyncWrite` stream (or a datagram endpoint), which is much + thinner than `TtyHandle`'s stdin/stdout/stderr/exit-code quadruple. The trait may collapse to "produce a boxed stream for this target" plus a listener variant. @@ -537,19 +540,19 @@ so the question: wrapping a consumer). Does the proxy need a composition helper from this crate, or does it assemble from the public producer/consumer surface as-is? If a helper is warranted, it may share shape with the - substrate dial/listen trait — which would argue for the trait. + substrate dial/listen trait, which would argue for the trait. **Status: resolved for stream substrates, 2026-09-06 (POC).** -`pump_halves` is generic over `(AsyncRead, AsyncWrite)` halves — TCP +`pump_halves` is generic over `(AsyncRead, AsyncWrite)` halves: TCP contributes `into_split()` halves, UDP contributes the `UdpHalf` socket adapter, the pump never knows which. "Produce boxed halves for a resource" is a function, not a trait; the `TunnelBackend` trait question resolves to *no trait* for substrate access. The only -remaining thread is the hub re-produce composition question (above) — +remaining thread is the hub re-produce composition question (above), a Phase 1 spec question, and now decidable against the validated producer/consumer surface. -### OQ-TN-06: The two-pump helper — extract now? +### OQ-TN-06: Extracting the two-pump helper now? alknet ADR-078 deferred helper extraction until a second two-pump consumer exists ("a genuine deferral... the contract is decided (shutdown-on- @@ -565,29 +568,29 @@ consumer (POC tunnel was the first; SSH `direct-tcpip` would be a third). one consumer would bake in a wrong shape; with two consumers the shapes should be compared before extraction. -**Status:** open — decide when the first real tunnel handler is written; -not a blocker for the spec. Half-answer: the helper probably belongs +**Status:** open. Decide when the first real tunnel handler is written; +it is not a blocker for the spec. Half-answer: the helper probably belongs upstream (alkcall, near the channels-adapter handler-integration conventions) but only if the two shapes genuinely converge. **Convergence input 2026-09-06 (POC):** the producer pump (`pump_halves`) and the consumer pump (`take_halves` + copy) are the -same shape modulo channel side — the ADR-078 convergence test is +same shape modulo channel side, so the ADR-078 convergence test is satisfied; extraction is decidable in Phase 1. Also surfaced by the POC: the `OpenHandler` JoinHandle semantics (see `poc-summary.md` § Issues Surfaced #1) are where such a helper would pin the teardown contract. -**RESOLVED 2026-09-07 — the helper landed upstream in alkcall 0.6.0** +**RESOLVED 2026-09-07; the helper landed upstream in alkcall 0.6.0** (review 007 R-03, ADR-050): `alkcall::channels::pump_bidi(channel, -peer_read, peer_write) -> (u64, u64)`. Shape as this OQ sketched it — +peer_read, peer_write) -> (u64, u64)`. Shape as this OQ sketched it: two pumps, shutdown-on-completion wired in, copy counts returned for -observability, errors EOF-shaped (no `Err` state — a mid-stream error +observability, errors EOF-shaped (no `Err` state, since a mid-stream error is an abrupt close, so an `io::Result` would be dead code). The POC is cited in the ADR as convergence input alongside alktty's channels session and the assembly-layer copies. alktunnels consumes it; the POC's `pump_halves` is now prior art only. The JoinHandle lifetime contract (R-02) is documented on the `OpenHandler` type: await the -helper inline inside the handler's task — early return is +helper inline inside the handler's task; early return is teardown-at-birth. ### OQ-TN-07: ALPN strategy @@ -595,14 +598,14 @@ teardown-at-birth. This crate owns the `alk/tunnel`-family ALPN(s). alkcall ADR-004: one ALPN per protocol; `alk/` prefix. If stream (TCP/unix) and datagram (UDP) tunnels get distinct ALPNs, the split must be decided before the first -consumer — ALPN strings are wire-stable once published. +consumer, since ALPN strings are wire-stable once published. - Option A: single `alk/tunnel` ALPN; substrate is a `params` field (and datagram framing, if any, is self-describing inside the channel). - Option B: `alk/tunnel` (stream) + `alk/tunnel-dgram` (datagram), so the wire framing differs per ALPN cleanly. - Channels' `params` is ALPN-specific, and the open-handler registry - dispatches per ALPN — both options are cheap mechanically; the cost is + dispatches per ALPN, so both options are cheap mechanically; the cost is consumer-side API bifurcation (two session types vs one with a substrate enum). @@ -613,14 +616,14 @@ transport); SOCKS5 runs CONNECT and UDP ASSOCIATE over one control connection with a CMD discriminator; udpgw proves datagram framing self-describes over a stream. With OQ-TN-02 resolved as endpoint-at- open for base UDP resources, the substrate discriminator in `params` -tells the handler which framing to expect — exactly option A's shape. +tells the handler which framing to expect, which is exactly option A's shape. Option B (`alk/tunnel-dgram`) remains defensible only if Phase 1 wants structurally different framing from byte zero with no params-dependent dispatch; prior art gives no reason to prefer that. ### OQ-TN-08: Access control and ownership scope -Tunnels reach local networks — the open gate is the security boundary. +Tunnels reach local networks, so the open gate is the security boundary. Shape follows alktty: `TUNNEL_OPEN_SCOPE` scope-gate, and the channels path gets `AccessControl` wiring for free via `ChannelCore::register_openable`. Open sub-questions: @@ -634,22 +637,22 @@ path gets `AccessControl` wiring for free via sets), and does it live in `AccessControl` or in the open handler's params validation? -**Status: mostly resolved 2026-09-05** — this OQ pointed at a general +**Status: mostly resolved 2026-09-05.** This OQ pointed at a general conceptual tangle ("the host owns the resource and proxies on top of -that" — how does that manifest for tunnels?), and the resolution is +that"; how does that manifest for tunnels?), and the resolution is the same posture alktty already uses: - **Assume the resource is owned by the other side of the connection.** At these protocol-crate levels (alktunnels, like alktty), the protocol works under the assumption that a produced resource belongs - to the far side — so the ACL story is exactly alkcall's existing + to the far side, so the ACL story is exactly alkcall's existing op-level ACL. No new policy layer, no target allowlists, no tunnel-specific ownership machinery. The actual proxy/overlay mechanism (hub workers connecting in and exposing tunnels, the hub providing an overlay to those resources based on the other side's ACL) is a *downstream* (assembly-layer) concern. - **Discovery is the existing bidirectional ops listing.** alkcall - already has an underlying bidirectional discovery mechanism — each + already has an underlying bidirectional discovery mechanism: each side can obtain the list of ops available to it (ACL-filtered). Since openable channels *are* operations (alkcall ADR-047), that listing *is* tunnel-resource discovery: a consumer asks "what ops are @@ -657,13 +660,13 @@ the same posture alktty already uses: by identity. This is exactly how a consumer learns a socks5 tunnel, a postgres TCP tunnel, or a redis tunnel is an available resource. Examples of the pattern: services typically served over a VPN or - SSH tunnels (postgres, redis, gitea HTTP) — workers connect to a + SSH tunnels (postgres, redis, gitea HTTP): workers connect to a hub, expose those tunnels, and the hub proxies them per-ACL. - **`-D` simplifies to "just tunnel a socks5 connection."** The socks5 server lives on the producing side; the consumer opens an ordinary tunnel channel to that resource and speaks socks5 inside it. Target - selection happens in the socks5 protocol at the far side — *not* in - tunnel params — so the previously-tracked "dynamic-target policy + selection happens in the socks5 protocol at the far side, *not* in + tunnel params, so the previously-tracked "dynamic-target policy hook for `-D`-style opens" residue dissolves: whatever ACL governs the socks5 resource governs everything reachable through it, plus whatever policy the socks5 implementation itself applies downstream. @@ -681,14 +684,14 @@ upstream, and the rule is to make asks early. The E-01/E-02 sweep (alkcall review 006, filed from this crate's Phase 0) is the working proof: the establishment phase (ADR-049), the typed `ChannelOpenError`, and `OperationSpec.description` all landed in -0.5.0 within a day of being filed — alktunnels was the consumer that +0.5.0 within a day of being filed; alktunnels was the consumer that pulled them through. Future upstream asks follow the same path. ### OQ-TN-09: Lifecycle, teardown, and error reporting The two-pump shape gives byte-level teardown for free (EOF sentinels; -channels drops per-channel senders on transport EOF — alknet ADR-078, -POC issue #6). What's missing is the error/level above bytes: +channels drops per-channel senders on transport EOF, per alknet ADR-078 +and POC issue #6). What's missing is the error/level above bytes: - How does a failed target dial reach the consumer (e.g. "connection refused to 10.0.0.5:80")? Is there a structured error frame in the @@ -696,13 +699,13 @@ POC issue #6). What's missing is the error/level above bytes: error on the open op? - Is there a "tunnel established/failed" ack before byte pumping starts (alktty has the negotiation frame; the POC's tunnel handler had - nothing — it dialed and pumped)? + nothing and simply dialed and pumped)? - Half-open semantics: one direction EOFs, the other keeps pumping - (standard two-pump behavior) — is that always desired, or does the + (standard two-pump behavior). Is that always desired, or does the consumer need a "close both" control? **Status: resolved 2026-09-06 by alkcall ADR-049** (alkcall 0.5.0, -review 006 E-01 — the establishment gap filed from this crate's Phase +review 006 E-01: the establishment gap filed from this crate's Phase 0 pass). The in-band control-frame hunch below is **superseded**: establishment failure is now the open op's reply, not a frame on the channel stream. @@ -710,29 +713,29 @@ channel stream. - The producer's tunnel open op registers via `ChannelCore::register_openable_with_establisher`; the establisher (`OpenEstablisher`) runs as an awaited, bounded establishment phase - — semantically validate params, **dial the target** — before the + (semantically validate params, **dial the target**) before the open op replies. On failure the just-allocated channel is torn down (allocation and teardown balance; ledger un-increment) and the consumer receives `channel:open_failed` with `details: {reason, message}`, `reason ∈ dial_failed / - unknown_resource / resource_shortage / handler_error / timeout` — - the SSH contract consumer-visibly (a failed open never returns a + unknown_resource / resource_shortage / handler_error / timeout`, + which is the SSH contract consumer-visibly (a failed open never returns a `channel_id`). No phantom channel, no establishment frame needed on the data stream. - Implementation note (ADR-049 amendment): the establisher takes - `(input, auth)` only — the channel's yield-once `BiStream` belongs + `(input, auth)` only, because the channel's yield-once `BiStream` belongs exclusively to the pump handler. **Amendment 2 (alkcall 0.6.0, review 007 R-01) filled the reserved `Establishment` field:** `Establishment::new(plan)` carries the dialed handle to the pump - handler via its `plan` parameter (`ChannelPlan` — typed-opaque + handler via its `plan` parameter (`ChannelPlan`, a typed-opaque `Arc`; downcast happens in this crate). The - POC's side-channel `HandleHandoff` is dead — no handoff map, no + POC's side-channel `HandleHandoff` is dead: no handoff map, no same-resource race; the establisher just returns `Ok(Establishment::new(dialed))`. - What remains for this crate's Phase 1 ADR (narrowed from the original frame-vocabulary question): - Half-open semantics: one direction EOFs, the other keeps pumping - (standard two-pump behavior) — the only establishment/teardown + (standard two-pump behavior): the only establishment/teardown question not answered upstream. Dial errors are fully establishment-phase now; byte-level EOFs stay per-direction (unchanged). @@ -740,7 +743,7 @@ channel stream. (post-establishment). Per ADR-049 §6's pinned posture, pump-phase failures are EOF-shaped by design; the SSH/SOCKS5 survey found no prior art for in-stream control after - establishment beyond udpgw's KEEPALIVE (dropped — channels owns + establishment beyond udpgw's KEEPALIVE (dropped; channels owns liveness). v1 likely needs none; if a UDP flow table ever needs in-band signaling, that is a substrate-framing decision, not a base-wire one. @@ -755,42 +758,42 @@ The alknet-channels POC validated TCP only. Candidate targeted POCs Phase 0 may need (in rough priority order, per the SDD process's "validate promising approaches"): -1. **UDP tunnel POC** — boundary-preserving length framing over a +1. **UDP tunnel POC**: boundary-preserving length framing over a channels channel, per-endpoint multiplexing inside one association, backpressure behavior. Partially derisked by the tun2proxy prior - art (§Prior art) — the POC now mainly validates *channels-layer* + art (§Prior art); the POC now mainly validates *channels-layer* fit: chunk-size vs datagram-size interaction, MTU cap against the channels bounded buffers, idle-expiry mapping, and the chosen endpoint-addressing shape. Derisks OQ-TN-02 (and OQ-TN-07's option B). - **Done 2026-09-06** — see `poc-summary.md`. 17 tests pass; the + **Done 2026-09-06** (see `poc-summary.md`). 17 tests pass; the producer/consumer shape validated end-to-end over alkcall 0.5.0 (establisher + two-pump + codec + typed establishment errors). -2. **Reverse-flow POC** — `-R`-style: the accept side listens, the far - side carries. Derisks OQ-TN-03's advertisement/lifecycle shape. +2. **Reverse-flow POC** (`-R`-style: the accept side listens, the far + side carries). Derisks OQ-TN-03's advertisement/lifecycle shape. Template available: SSH's `tcpip-forward` global-request registration → per-accept `forwarded-tcpip` opens → cancel (`ssh-socks5-survey.md` §RFC 4254 §7.1). - **Done 2026-09-07** — see `reverse-poc-summary.md`. 14 tests pass + **Done 2026-09-07** (see `reverse-poc-summary.md`). 14 tests pass over alkcall 0.6.0; the review 007 non-finding trace confirmed by execution (`from_connection_with_serving` + `register_on` + serving-side allocation). The advertisement thread of OQ-TN-03 - closes: no advertisement op needed — the listener is the hub's own + closes: no advertisement op needed, since the listener is the hub's own local resource; a far-side listener is a producer-side listen establisher over the same open op. Findings: F-1 (plan payloads - must be `Send + Sync` — documented upstream in 0.7.0), F-2 (UDP - raw pass-through cannot carry empty datagrams — the length-framed + must be `Send + Sync`, documented upstream in 0.7.0), F-2 (UDP + raw pass-through cannot carry empty datagrams, so the length-framed codec is mandatory, second validation of the codec decision). W1 (connect-side serving path resolves identity only via payload - `auth_token`) filed for the alkcall ledger — **RESOLVED by alkcall + `auth_token`) filed for the alkcall ledger, **RESOLVED by alkcall 0.7.0 (CF-005/CF-006)**; the POC re-validated over 0.7.0 (16 tests): transport identity authorizes alone, `ServingConfig. identity` overrides, identity-less fails closed, and the establisher sees the per-call opener identity. -3. **Unix socket + stdio bridge POC** — cheap; validates "substrate - agnostic" beyond IP substrates. -4. **Two-pump helper extraction spike** — OQ-TN-06, only after 1–3. +3. **Unix socket + stdio bridge POC** (cheap; validates "substrate + agnostic" beyond IP substrates). +4. **Two-pump helper extraction spike** (OQ-TN-06, only after 1–3). POC placement conventions (2026-09-05): a POC that needs code from this repo runs in a worktree/branch (`.worktrees/research//` @@ -801,7 +804,7 @@ into `docs/research/` here. Both are valid; pick per the POC's dependency footprint. Findings always land in `docs/research/` regardless of where the code lives. -**Status:** open — pick 1 (and probably 2) after the research pass; +**Status:** open. Pick 1 (and probably 2) after the research pass; 3 is cheap enough to fold into whichever POC runs first. ## Survey / prior-art list @@ -811,14 +814,14 @@ Candidate reading for the research specialist (to be expanded): - SSH channel/forwarding model: RFC 4254 §7 (direct-tcpip / forwarded-tcpip), OpenSSH `-L`/`-R`/`-D` semantics, russh's `ChannelOpen` framing (russh is already in `/workspace/russh`; - confirmed 2026-09-05: no UDP channel support at all — no UDP prior + confirmed 2026-09-05: no UDP channel support at all; no UDP prior art there, but useful for open-op framing intel). - SOCKS5 (RFC 1928): addressing (ATYP), UDP ASSOCIATE framing, - per-endpoint multiplexing — the closest standardized "arbitrary + per-endpoint multiplexing, the closest standardized "arbitrary tunnel + UDP" model. Relevant to the `-D` composition path (OQ-TN-03 residue), not the base open-op params (OQ-TN-01 reframe). - tun2proxy (`/workspace/tun2proxy`, `src/udpgw.rs`): UDP gateway over - TCP — per-datagram length framing, SOCKS5 per-datagram addressing, + TCP: per-datagram length framing, SOCKS5 per-datagram addressing, CONN_ID flow multiplexing, keepalive/ERR flag packets, MTU cap, idle expiry. Analyzed in §Prior art. Its `socks.rs` / `proxy_handler.rs` are also relevant for the `-D` (dynamic/SOCKS) @@ -842,22 +845,22 @@ Candidate reading for the research specialist (to be expanded): ## Convergence checklist (what Phase 0 must produce) - [x] Survey notes: SSH/SOCKS5 addressing + UDP framing - (OQ-TN-01, OQ-TN-02) — tun2proxy UDP gateway (§Prior art) + + (OQ-TN-01, OQ-TN-02): tun2proxy UDP gateway (§Prior art) + `ssh-socks5-survey.md` (SSH/SOCKS5, error vocabularies, endpoint-vs-per-datagram, anti-prior-art list) -- [x] Reframe landed (OQ-TN-01) — params = self-contained JSON open-op +- [x] Reframe landed (OQ-TN-01): params = self-contained JSON open-op object identifying a produced resource + substrate discriminator (`tcp`/`udp`/extensible); producer owns the backing; no URL-style general addressing. Residue: resource naming shape, exact JSON layout (Phase 1 ADR); discovery resolved (OQ-TN-08) -- [x] Direction model resolved (OQ-TN-03) — hub-owns-the-connection +- [x] Direction model resolved (OQ-TN-03), the hub-owns-the-connection model: role follows the resource, no protocol-level direction; `-D` composes at the assembly layer -- [x] No-forced-binding requirement encoded (OQ-TN-04) — binding is +- [x] No-forced-binding requirement encoded (OQ-TN-04): binding is always assembly-layer and optional; remaining work is the concrete produce/consume API surface sketch (spec task) -- [x] Access control + discovery resolved (OQ-TN-08) — resource +- [x] Access control + discovery resolved (OQ-TN-08): resource ownership assumed on the far side; alkcall's existing ACL applies as-is; ops listing (ADR-047) is tunnel-resource discovery; `-D` = "tunnel a socks5 connection" (target selection @@ -866,19 +869,19 @@ Candidate reading for the research specialist (to be expanded): `OperationSpec.description` (round-trips through discovery); the live resource-enumeration half (OQ-40) stays deferred and is not needed for v1 -- [x] Lifecycle/error resolved (OQ-TN-09) — superseded 2026-09-06 by +- [x] Lifecycle/error resolved (OQ-TN-09): superseded 2026-09-06 by alkcall ADR-049 (0.5.0): establishment is the open op's awaited phase (`register_openable_with_establisher`); dial failure is a typed `channel:open_failed` call error (reason ∈ dial_failed / unknown_resource / resource_shortage / handler_error / timeout), never a phantom channel. Residual for Phase 1: half-open semantics only; v1 needs no mid-stream control frame -- [x] Codec direction set (from the alktty pattern prior art) — raw +- [x] Codec direction set (from the alktty pattern prior art): raw pass-through for stream substrates (0 B tunnel overhead), `[len:u16 BE]` per datagram for UDP (2 B); no 5-byte header (a - tunnel has one data stream per direction — no sub-demux key - needed). Residual for the Phase 1 ADR: empty-datagram - (`len=0`) semantics — **strengthened 2026-09-07 (F-2): the + tunnel has one data stream per direction, so no sub-demux key + is needed). Residual for the Phase 1 ADR: empty-datagram + (`len=0`) semantics. **Strengthened 2026-09-07 (F-2): the codec is mandatory for UDP** (empty datagram = zero-byte read = EOF collision in raw pass-through); the empty-datagram semantic under the codec is `len=0` = a legal empty datagram @@ -887,31 +890,31 @@ Candidate reading for the research specialist (to be expanded): OQ-TN-05 trait shape - [ ] Decision input: backend trait vs no-trait (OQ-TN-05), now including the hub re-produce composition question -- [x] Targeted POC(s) run + summary (OQ-TN-10) — UDP POC DONE +- [x] Targeted POC(s) run + summary (OQ-TN-10): UDP POC DONE (2026-09-06, `poc-summary.md`); reverse-flow POC DONE - (2026-09-07, `reverse-poc-summary.md` — the `-R` shape validated + (2026-09-07, `reverse-poc-summary.md`: the `-R` shape validated end-to-end over alkcall 0.6.0, re-validated over 0.7.0 with the CF-005/CF-006 identity remediation; findings F-1/F-2; W1 filed upstream and RESOLVED). Remaining items resolved in Phase 1: - #3 split — unix ships with the `local` feature (OQ-TN-14); - stdio is OUT (alktty's pipe mode owns process stdio — exit - codes + signals are tty semantics); #4 resolved by alkcall + #3 split, with unix shipping in the `local` feature (OQ-TN-14); + stdio is OUT (alktty's pipe mode owns process stdio, since + exit codes + signals are tty semantics); #4 resolved by alkcall 0.6.0 (ADR-050) -- [x] Two-pump helper convergence input gathered (OQ-TN-06) — the POC +- [x] Two-pump helper convergence input gathered (OQ-TN-06): the POC gives both shapes (producer `pump_halves`, consumer `take_halves` + copy): same shape modulo channel side; the alknet ADR-078 convergence test is satisfied, extraction now decidable in Phase 1. **RESOLVED 2026-09-07:** the helper landed upstream in alkcall 0.6.0 as `channels::pump_bidi` - (ADR-050, review 007 R-03) — this crate consumes it; see + (ADR-050, review 007 R-03), which this crate consumes; see OQ-TN-06 -- [x] OQ-TN-05 executable input (backend trait vs no-trait) — POC +- [x] OQ-TN-05 executable input (backend trait vs no-trait); the POC confirms stream substrates need no trait: `pump_halves` is generic over boxed halves (TCP `into_split`, UDP `UdpHalf` adapter); "produce halves for a resource" is a function. The hub re-produce composition question is the only remaining OQ-TN-05 thread - [x] Open questions promoted to Phase 1 - `docs/architecture/open-questions.md` with statuses — DONE + `docs/architecture/open-questions.md` with statuses; DONE 2026-09-07 (OQ-TN-01..10 promoted as OQ-TN-01..14, including the Phase 1 residues; the convergence checklist is complete) \ No newline at end of file diff --git a/docs/research/poc-summary.md b/docs/research/poc-summary.md index 0d5712f..9a42194 100644 --- a/docs/research/poc-summary.md +++ b/docs/research/poc-summary.md @@ -5,18 +5,18 @@ last_updated: 2026-09-07 # alktunnels: UDP Tunnel POC Research Summary -**Status:** Research complete — the producer/consumer shape validated +**Status:** Research complete. The producer/consumer shape validated end-to-end over alkcall 0.5.0 channels, including the ADR-049 establishment phase and the codec decision (raw pass-through for TCP, `[len: u16 BE]` for UDP). 17 tests pass (7 codec + 10 integration); clippy `-D warnings` clean; fmt clean. The remaining unknowns are spec-scope (Phase 1 ADRs), not feasibility. Both upstream findings -below landed in alkcall 0.6.0 (review 007) — see §Resolutions +below landed in alkcall 0.6.0 (review 007); see §Resolutions 2026-09-07. **Date:** 2026-09-06 (resolutions added 2026-09-07) -**Scope:** Validates the OQ-TN-10 #1 POC (UDP tunnel) — plus the +**Scope:** Validates the OQ-TN-10 #1 POC (UDP tunnel), plus the consumer-side of the establisher hand-off (ADR-049), the two-pump -pump-handler contract's `JoinHandle` semantics (a real finding — see +pump-handler contract's `JoinHandle` semantics (a real finding; see §Issues Surfaced), and the OQ-TN-05 "no backend trait for stream substrates" judgment in executable form. @@ -27,34 +27,34 @@ substrates" judgment in executable form. A POC (`alktunnels-udp-poc`, `/workspace/alktunnels-udp-poc`) validated the alktunnels producer/consumer shape end-to-end: -1. **The producer shape** — `channels/tunnel/sub` open op registered - via `ChannelCore::register_openable_with_establisher` (ADR-049): +1. **The producer shape**: `channels/tunnel/sub` open op registered + via `ChannelCore::register_openable_with_establisher` (ADR-049); the establisher validates params semantically (registry lookup of `{resource, substrate}` → backing address, producer-side), dials the substrate (TCP connect / UDP connect), and delivers the dialed handle to the pump handler via a handoff; the wrapper replies only after establishment. A refused dial is a typed `channel:open_failed` call error with - `details.reason ∈ {unknown_resource, dial_failed, resource_shortage}` - — the SSH contract consumer-visibly, verified from the consumer + `details.reason ∈ {unknown_resource, dial_failed, resource_shortage}`, + which is the SSH contract consumer-visibly, verified from the consumer side (`TunnelSession::open` resolving `Err` with the reason). -2. **The two-pump data plane** — the pump handler accepts the +2. **The two-pump data plane**: the pump handler accepts the channel's yield-once `BiStream`, splits it, and runs two `tokio::io::copy` pumps with shutdown-on-completion (alknet ADR-078) against the substrate halves. 1 MiB round-trips through the two-pump shape with the bounded-buffer backpressure path intact. -3. **The codec decision** — raw pass-through for TCP (zero +3. **The codec decision**: raw pass-through for TCP (zero tunnel-level framing; the channels 8-byte header is the only overhead) and `[len: u16 BE][datagram]` for UDP (2 bytes/datagram). Datagram boundaries survive chunk splitting, batching, and empty - datagrams (`len=0`) are legal payloads, NOT EOF — the EOF sentinel + datagrams (`len=0`) are legal payloads, NOT EOF. The EOF sentinel lives at the channels layer (`BiStream` EOF) and the two never collide (different layers). -4. **The consumer shape** — `TunnelSession::open` → +4. **The consumer shape**: `TunnelSession::open` → `ChannelClient::open_channel` → split the channel `BiStream` → present raw halves (TCP) or framed datagrams (UDP). One session = one channel = one tunnel; the channel ID is the flow key (no - conn-id — OQ-TN-02 resolution holds). + conn-id, so OQ-TN-02 resolution holds). **17 tests pass** (7 wire codec + 10 integration: TCP echo, TCP 1 MiB, UDP boundary preservation incl. empty datagrams, UDP-through-chunk- @@ -68,29 +68,29 @@ channels on one connection). Clippy clean, fmt clean. ``` alktunnels-udp-poc/ - Cargo.toml — alkcall 0.5.0; tokio wasm-clean subset + net (POC-local) + Cargo.toml : alkcall 0.5.0; tokio wasm-clean subset + net (POC-local) src/ - lib.rs — module docs; the POC overview - params.rs — TunnelParams {resource, substrate} (the OQ-TN-01 shape) - wire.rs — the codec: frame_datagram / DatagramReader + lib.rs : module docs; the POC overview + params.rs : TunnelParams {resource, substrate} (the OQ-TN-01 shape) + wire.rs : the codec: frame_datagram / DatagramReader ([len: u16 BE] framing; incremental decode across chunk boundaries; len=0 = empty datagram, not EOF) - producer.rs — tunnel_open_spec (scope-gated, Sub-typed, channel_open + producer.rs : tunnel_open_spec (scope-gated, Sub-typed, channel_open marker), tunnel_establisher (registry lookup + dial + handoff), HandleHandoff (resource-keyed), pump_halves (the ADR-078 two-pump shape, generic over halves), make_tunnel_pump_handler (the OpenHandler), register_tunnel_openable (the wiring), UdpHalf (UdpSocket as AsyncRead/AsyncWrite halves) - consumer.rs — TunnelSession (typed client): open / stream_halves / + consumer.rs : TunnelSession (typed client): open / stream_halves / send_datagram / recv_datagram / take_halves; establishment_reason (the ADR-049 typed-error branch) - harness.rs — wire_tunnel (the alktty testing.rs pattern: duplex + harness.rs : wire_tunnel (the alktty testing.rs pattern: duplex transport, ChannelsAdapter, install_channel_zero hook, register_tunnel_openable on the hook's registry, tunnel_identity with the tunnel:open scope) tests/ - tunnel_poc.rs — the 10 integration tests + tunnel_poc.rs : the 10 integration tests ``` The POC follows the `alknet-channels-poc` convention: standalone crate @@ -99,7 +99,7 @@ crate's `docs/research/`. ## Key findings -### 1. The OpenHandler JoinHandle contract — the pump handler MUST return the pump's own handle +### 1. The OpenHandler JoinHandle contract: the pump handler MUST return the pump's own handle The most important finding of the POC, and it was found by the POC hanging, not by reading: **the `OpenHandler`'s returned `JoinHandle` @@ -108,18 +108,18 @@ awaits it, then tears the channel down (drop the demux sender → EOF to the handler's read half → the pumps exit). A handler that spawns the pump as a *nested* task and returns the outer handle immediately completes the wrapper's await at birth → teardown → both pumps see -instant EOF with zero bytes — every tunnel appears to "connect and +instant EOF with zero bytes, so every tunnel appears to "connect and instantly close" while everything upstream looks healthy. ```rust -// WRONG — outer task returns immediately; wrapper tears down at birth: +// WRONG: outer task returns immediately; wrapper tears down at birth: tokio::spawn(async move { let bidi = conn.accept_bi().await?; tokio::spawn(pump_halves(bidi, read, write)); // fire-and-forget // <- handler ends here; wrapper's raw_task.await resolves NOW }) -// RIGHT — the returned JoinHandle tracks the pump's lifetime: +// RIGHT: the returned JoinHandle tracks the pump's lifetime: tokio::spawn(async move { let bidi = conn.accept_bi().await?; let _ = pump_halves(bidi, read, write).await; // awaited inline @@ -127,7 +127,7 @@ tokio::spawn(async move { ``` This is an API-sharpness finding for alkcall (the `OpenHandler` -contract's `JoinHandle` semantics are load-bearing and undocumented — +contract's `JoinHandle` semantics are load-bearing and undocumented; the type docs say "so the wrapper can record it for teardown (abort on channel/close / connection drop)" but not that an early return *is* an early teardown). Worth an upstream doc note; the shape itself is @@ -136,13 +136,13 @@ correct. Filed as a follow-up for the alkcall review ledger. The pump shape itself (ADR-078) is unchanged and correct: each pump shuts down the opposite sink on completion; `tokio::join!` on the two pumps; the wrapper's teardown on pump completion drops the opposite -direction's sender — half-open semantics fall out naturally (one +direction's sender, and half-open semantics fall out naturally (one direction EOFs, the other keeps pumping until its own EOF). ### 2. The establisher hand-off works; its shape is POC-grade only -The ADR-049 amendment says the establisher gets `(input, auth)` only — -the channel's `BiStream` belongs to the pump handler. The POC's +The ADR-049 amendment says the establisher gets `(input, auth)` only, +for the channel's `BiStream` belongs to the pump handler. The POC's `HandleHandoff` (a `Mutex>` + poll loop in `take`) works because the wrapper *guarantees* ordering: the establisher is awaited before the reply, and the pump handler is @@ -153,11 +153,11 @@ one open per resource at a time (concurrent opens of the same resource would race the slot). The real crate's mechanism should be the ADR-049 reserved extension point: **`Establishment` should carry the channel plan** (the struct is already documented as "Reserved for a -channel plan — today the wrapper consults only success/failure"). When +channel plan; today the wrapper consults only success/failure"). When alkcall extends `Establishment` to carry data, the handoff mechanism dies entirely and the establisher returns the dialed handle in its -result. That is the recommended upstream evolution (small, additive — -`Establishment {}` → `Establishment { plan: Option }` or a +result. That is the recommended upstream evolution (small, additive, +with `Establishment {}` → `Establishment { plan: Option }` or a generic), and it removes the last awkwardness of the establisher shape. Filed as a follow-up for the alkcall ledger. @@ -170,17 +170,17 @@ shape. Filed as a follow-up for the alkcall ledger. end-to-end with no deadlock (the alknet-channels POC's `tunnel_large_payload` result reproduces on alkcall 0.5.0). - **UDP: `[len: u16 BE]` framing.** `DatagramReader` is an - incremental decoder — datagrams split across chunks, datagrams + incremental decoder: datagrams split across chunks, datagrams batched in one chunk, partial headers, and empty datagrams all round-trip (verified at the codec level with maximally awkward 7-byte chunk splits, and end-to-end through the channels stack). - **Sentinel layering verified:** `len == 0` in the codec is an empty datagram (DNS-over-TCP-style; round-trips through a UDP echo); EOF is the channels-level `length=0` chunk on the `BiStream`. The - two coexist without ambiguity — the consumer's `recv_datagram` + two coexist without ambiguity, because the consumer's `recv_datagram` returns `None` only on stream EOF, never on an empty datagram. **Mandate strengthened 2026-09-07** (reverse POC F-2): without the - codec (raw pass-through), an empty datagram IS a zero-byte read — + codec (raw pass-through), an empty datagram IS a zero-byte read, colliding with EOF at the pump level. UDP must ride the codec. - **MTU discipline:** the codec rejects >65535 at frame time; the 1400-byte (max ethernet MTU payload) datagram round-trips through @@ -193,7 +193,7 @@ shape. Filed as a follow-up for the alkcall ledger. The consumer branches on `ChannelOpenError::CallFailed { error }`: `error.code == "channel:open_failed"` with `details.reason` ∈ `{unknown_resource, dial_failed, resource_shortage}` -(from the establisher) — plus the wrapper-level `timeout` reason and +(from the establisher), plus the wrapper-level `timeout` reason and the pre-allocation failures (`FORBIDDEN` for the scope gate, `channel:too_many_channels` for the cap). All verified in tests: @@ -208,10 +208,11 @@ The SSH contract holds end-to-end: a failed open never returns a `pump_halves` is generic over `(AsyncRead, AsyncWrite)` halves. TCP contributes `OwnedReadHalf`/`OwnedWriteHalf` (`TcpStream::into_split`); -UDP contributes `UdpHalf` (the POC's `UdpSocket` adapter — `poll_recv` +UDP contributes `UdpHalf` (the POC's `UdpSocket` adapter, with +`poll_recv` as `AsyncRead`, `poll_send` as `AsyncWrite`, connected-socket semantics). The pump never knows which. The "backend" question -collapses to "produce boxed halves for a resource" — a function, not a +collapses to "produce boxed halves for a resource", a function, not a trait, for everything the producer dials. The remaining OQ-TN-05 question (whether the *hub's* re-produce proxy needs a trait) is unaffected by this finding; it was always about composition, not @@ -239,14 +240,14 @@ substrate access. into a smaller caller buffer truncates the datagram silently (UDP semantics). For well-formed peers the codec's MTU bound makes this unreachable; the Phase 1 spec should pin whether the real - crate's UDP adapter errors on truncation instead (fail loud) — + crate's UDP adapter errors on truncation instead (fail loud); recommend erroring, since truncation would corrupt the length- framed stream invariants. → Phase 1 ADR note. 4. **Consumer-side pump symmetry** (alktunnels Phase 1): the consumer mirrors the producer's pump shape (`tokio::io::copy` between local substrate and channel halves, shutdown-on-completion). The POC's `TunnelSession::take_halves` exists precisely so the assembly - layer can pump generically — the OQ-TN-06 helper extraction + layer can pump generically, so the OQ-TN-06 helper extraction question can now be evaluated with both shapes in hand (producer `pump_halves`, consumer `take_halves` + copy): they are the same shape modulo channel side. The convergence test alknet ADR-078 @@ -257,7 +258,7 @@ substrate access. Both upstream findings above landed, with deviations recorded in the review and ADRs: -1. **JoinHandle semantics (→ R-02)** — documented on the `OpenHandler` +1. **JoinHandle semantics (→ R-02)**, documented on the `OpenHandler` type and both `register_openable*` methods: the returned `JoinHandle` must track the data-plane lifetime; the wrapper awaits it and its completion triggers channel teardown. Plus the POC's @@ -265,49 +266,52 @@ review and ADRs: `ChannelBidiStreamSource` and a `debug!` birth-teardown hint in the teardown task, so the hang-this-POC-found shape is diagnosable at runtime, not just in docs. -2. **`Establishment` carries the plan (→ R-01)** — filled as +2. **`Establishment` carries the plan (→ R-01)**, filled as `Establishment { plan: Option }` with `Establishment::new(plan)`; **typed-opaque** (`ChannelPlan = - Arc`), not the `Option` sketch here — - the payloads are live handles (dialed sockets, TTY handles) with no + Arc`), not the `Option` sketch here, + because the payloads are live handles (dialed sockets, TTY handles) + with no JSON representation. The `OpenHandler` gains a `plan` parameter and the wrapper threads it. The POC's `HandleHandoff` side-channel and its same-resource race are dead: the establisher returns the dialed handle directly (`Ok(Establishment::new(dialed))`). **Confirmed under concurrency 2026-09-07** by the reverse-flow POC (`reverse-poc-summary.md`): two concurrent opens of the same - resource, distinct channels, distinct dialed handles — the race + resource, distinct channels, distinct dialed handles, so the race this handoff had is structurally gone. (F-1 there: plan payloads must be `Send + Sync`.) -3. **Two-pump helper (→ R-03, bonus)** — `alkcall::channels::pump_bidi` +3. **Two-pump helper (→ R-03, bonus)**: `alkcall::channels::pump_bidi` pinned upstream (ADR-050), with `(u64, u64)` copy counts (no - `io::Result` — errors are EOF-shaped per the ADR-078 contract). The + `io::Result`, since errors are EOF-shaped per the ADR-078 + contract). The POC's `pump_halves` shape converged and became the helper; Phase 1 consumes it instead of hand-rolling. ## What the POC does NOT validate -1. **Real transports** — `tokio::io::duplex` stands in for +1. **Real transports**: `tokio::io::duplex` stands in for TCP/TLS/QUIC. The alkcall layer owns transport; unchanged from the alknet-channels POC scope. -2. **Unix-socket and stdio substrates** — the OQ-TN-10 #3 "cheap" +2. **Unix-socket and stdio substrates**: the OQ-TN-10 #3 "cheap" item. The pump is substrate-agnostic by construction (finding #5); these are wiring, not mechanics. Fold into the real crate's test suite rather than a separate POC. (Phase 1 resolution: unix in via - the `local` feature; stdio OUT — alktty's pipe mode owns process + the `local` feature; stdio OUT, since alktty's pipe mode owns + process stdio. OQ-TN-14.) -3. **The reverse-flow (`-R`) advertisement** — OQ-TN-10 #2 remains +3. **The reverse-flow (`-R`) advertisement**: OQ-TN-10 #2 remains open; the SSH `tcpip-forward` template in the survey is the lifecycle shape to validate there. -4. **Relay traversal** — hub relay (alkcall ADR-042) forwards data +4. **Relay traversal**: hub relay (alkcall ADR-042) forwards data channels byte-for-byte; the POC's single-hop shape is what relays see per leg. The terminate-and-re-produce hub proxy (the hub model) is an assembly-layer concern. -5. **Multi-endpoint UDP gateway resources** — the udpgw-shaped +5. **Multi-endpoint UDP gateway resources**: the udpgw-shaped framing-inside-a-channel variant (OQ-TN-02's "split by path" dynamic half). Not a v1 base-protocol concern; the codec keeps the door open (framing inside datagram payloads, self-describing). -6. **Wasm** — the POC uses `tokio/net` for real sockets; the +6. **Wasm**: the POC uses `tokio/net` for real sockets; the wasm-clean story is for the real crate's protocol-only core (the codec and pump are pure byte/async work; `UdpHalf` and the dial paths are the feature-gated parts). Not exercised here. @@ -339,22 +343,22 @@ test udp_and_tcp_concurrent ... ok ## References -- alkcall 0.5.0 — ADR-049 (establishment phase), +- alkcall 0.5.0: ADR-049 (establishment phase), `ChannelCore::register_openable_with_establisher`, `OpenEstablisher`/`Establishment`/`EstablishmentError`, `ChannelOpenError` (typed client error), `run_open_wrapper` (`src/channels/operations.rs`). **0.6.0 supersedes parts:** ADR-049 amendment 2 (`Establishment.plan`), ADR-050 (`pump_bidi`), review 007 (filed from this POC, all units landed). -- alknet ADR-078 — the two-pump shutdown-on-completion contract; +- alknet ADR-078, the two-pump shutdown-on-completion contract; `pump_halves` is its direct implementation. -- alktunnels `docs/research/phase-0-findings.md` — the OQ ledger this +- alktunnels `docs/research/phase-0-findings.md`, the OQ ledger this POC closes items against (OQ-TN-01 params shape, OQ-TN-02 split by path, OQ-TN-05 halves-not-trait, OQ-TN-09 ADR-049 supersession, OQ-TN-10 #1). -- `docs/research/ssh-socks5-survey.md` — the reason-code vocabulary +- `docs/research/ssh-socks5-survey.md`, the reason-code vocabulary the establisher errors surface as. -- alktty `src/testing.rs` — the harness pattern this POC's +- alktty `src/testing.rs`, the harness pattern this POC's `wire_tunnel` mirrors. -- tun2proxy `src/udpgw.rs` — the framing precedent the UDP codec +- tun2proxy `src/udpgw.rs`, the framing precedent the UDP codec improves on (no CONN_ID, no FLAGS; boundary framing only). \ No newline at end of file diff --git a/docs/research/reverse-poc-summary.md b/docs/research/reverse-poc-summary.md index df2b170..7520e2a 100644 --- a/docs/research/reverse-poc-summary.md +++ b/docs/research/reverse-poc-summary.md @@ -5,19 +5,19 @@ last_updated: 2026-09-07 # alktunnels: Reverse-Flow (`-R`) Tunnel POC Research Summary -**Status:** Research complete — the `-R` shape (the hub opens tunnel +**Status:** Research complete. The `-R` shape (the hub opens tunnel channels *toward* a connect-side worker that serves its own open op) validated end-to-end over alkcall 0.6.0 (2026-09-07 pass, 14 tests) and re-validated over alkcall 0.7.0 (16 tests) after the CF-005/CF-006 -identity remediation landed — the W1 `auth_token` workaround is dead; +identity remediation landed, so the W1 `auth_token` workaround is dead; the primary caller-identity path is transport identity. Clippy `-D warnings` clean; fmt clean. The review 007 non-finding trace -("no upstream mechanism is missing") is confirmed by execution — -and the POC surfaced two real findings the forward POC could not -(one an upstream sharpness question — F-1, documented upstream in -0.7.0; one a spec-mandate — F-2). +("no upstream mechanism is missing") is confirmed by execution, and +the POC surfaced two real findings the forward POC could not +(one an upstream sharpness question, F-1, documented upstream in +0.7.0; one a spec-mandate, F-2). **Date:** 2026-09-07 -**Scope:** Validates the OQ-TN-10 #2 POC — the SSH `tcpip-forward` +**Scope:** Validates the OQ-TN-10 #2 POC: the SSH `tcpip-forward` template (register → per-accept channel-open → cancel) mapped onto alkcall's both-sides serving semantics. Probes four wrinkles (W1..W4) selected from the mechanism trace; findings F-1/F-2 below. @@ -28,28 +28,28 @@ selected from the mechanism trace; findings F-1/F-2 below. A POC (`alktunnels-reverse-poc`, `/workspace/alktunnels-reverse-poc`) validated the reverse flow end-to-end over alkcall 0.6.0 (re-validated -over 0.7.0 — see §W1): +over 0.7.0; see §W1): -1. **The worker (connect side, serving side)** — dials the transport, +1. **The worker (connect side, serving side)** dials the transport, `ChannelClient::from_connection_with_serving` + `ChannelOperations::register_on` (generic channel ops on the serving registry, so the hub can `channel/close`) + post-hoc `register_openable_with_establisher` (W2). Its establisher dials the resource target and returns the handle via - `Establishment::new(plan)` — R-01's plan flow, no handoff map. + `Establishment::new(plan)`, which is R-01's plan flow with no handoff map. Its pump handler awaits `alkcall::channels::pump_bidi` inline (R-02/R-03). This is ADR-022 §2 both-sides semantics exercised for real: connect side AND serving side. -2. **The hub (accept side, reverse-flow initiator)** — - `ChannelsAdapter` + install hook that captures the hub's channel-0 +2. **The hub (accept side, reverse-flow initiator)** uses + `ChannelsAdapter` + an install hook that captures the hub's channel-0 `CallConnection` and `ChannelManager`. Per local accept: call the - worker's open op (`call_with_payload` — the caller identity rides - the transport identity by default (0.7.0); the payload + worker's open op (`call_with_payload`; the caller identity rides + the transport identity by default (0.7.0), and the payload `auth_token` is the optional hub-forwarding path), adopt the worker-allocated channel ID, pump the accepted socket against the adopted halves via `pump_bidi` spawned locally. 3. **The ADR-047 §5 orientation under test holds:** the reverse - channels are allocated by the worker (connect side — odd IDs) and + channels are allocated by the worker (connect side, odd IDs) and adopted by the hub (accept side). "Connection owner allocates" exercised in the Pub-like orientation. 4. **16 tests (0.7.0 pass):** TCP round trip, 1 MiB backpressure, @@ -64,53 +64,53 @@ over 0.7.0 — see §W1): late-registration visibility, and two finding-pinning tests (F-1 compiler-pinned, F-2 below). -## Wrinkle probes (W1..W4) — results +## Wrinkle probes (W1..W4): results -- **W1 identity on the serving path — CONFIRMED 2026-09-07 against +- **W1 identity on the serving path, CONFIRMED 2026-09-07 against 0.6.0; RESOLVED by alkcall 0.7.0 (ledger CF-005 + CF-006).** Original finding: `from_connection_with_serving` built channel 0 internally; its `Connection::identity()` was empty and - `ServingConfig` had no auth capture point — the serving dispatcher + `ServingConfig` had no auth capture point; the serving dispatcher resolved the caller identity ONLY from the payload `auth_token` → `ServingConfig.identity_provider`, and the establisher/pump handler received the install-time `AuthContext`, never the per-call opener. **The 0.7.0 remediation (validated from this POC's topology, 16 tests):** - - `ServingConfig.identity: Option` — the explicit + - `ServingConfig.identity: Option`, the explicit override (remediation (a)). - - The transport `Connection::identity()` (set before dialing — the + - The transport `Connection::identity()` (set before dialing, the mTLS/QUIC posture) propagates to channel 0 automatically (remediation (b)). Validated: the POC's default harness now - authorizes the scope-gated open on transport identity ALONE — no - token payload. + authorizes the scope-gated open on transport identity ALONE, with + no token payload. - Precedence (alkcall cf005 tests + POC probes): payload token > `ServingConfig.identity` > transport identity; none → fail-closed `FORBIDDEN` (validated). - CF-006 corollary: the establisher/pump handler now receive the - **per-call** opener identity (validated via an identity witness — + **per-call** opener identity (validated via an identity witness: the establisher saw `hub`, and the override identity under (a)). On hub-forwarded opens the establisher sees the end client, not the hub. - Status: resolved — the POC's `auth_token` workaround is dead code; + Status: resolved, with the POC's `auth_token` workaround dead code; the primary path is transport identity. -- **W2 post-hoc openable registration — works.** The worker registers +- **W2 post-hoc openable registration works.** The worker registers its openable AFTER `from_connection_with_serving` returns; the dispatcher reads through the shared `Arc` per dispatch. Proven by every open succeeding + a direct - `registration()` assertion. No finding — the assembly order + `registration()` assertion. No finding: the assembly order (construct client → register ops → serve) is viable as-is. -- **W3 adopter self-reaping — confirmed asymmetry, POC-local fix.** +- **W3 adopter self-reaping, confirmed asymmetry with a POC-local fix.** The serving side's channel is wrapper-managed (teardown cascades on pump completion or out-of-band close). The ADOPTING side has no such machinery: `adopt_channel` installs routing state nothing awaits. The hub must hold its pump handle and reap - (`teardown_channel`) itself — `ReverseTunnel::join_and_reap`/`close` - in the POC. Spec note: the real crate's consumer half needs a + (`teardown_channel`) itself (`ReverseTunnel::join_and_reap`/`close` + in the POC). Spec note: the real crate's consumer half needs a session type with the same ownership (the adopted channel + pump handle + reaper). Not an upstream gap (the hub's lifecycle is - assembly-layer by design, OQ-TN-04) — but it is the reason + assembly-layer by design, OQ-TN-04), but it is the reason `TunnelSession` in the spec must own teardown explicitly. -- **W4 EOF sentinel propagation — works both ways.** Half-close +- **W4 EOF sentinel propagation works both ways.** Half-close semantics test: local `shutdown()` → EOF sentinel into the mux → worker pump sees the zero-length read → shuts the target write down; the target's echo still flows back before the reverse leg @@ -119,11 +119,11 @@ over 0.7.0 — see §W1): ## Findings -### F-1 — `ChannelPlan`'s `Send + Sync` bound constrains plan payloads (compiler-pinned) +### F-1: `ChannelPlan`'s `Send + Sync` bound constrains plan payloads (compiler-pinned) `ChannelPlan = Arc` (ADR-049 amendment 2). A dialed-socket handle whose halves are -`Box` does NOT satisfy the bound — +`Box` does NOT satisfy the bound: `Arc` requires `T: Send + Sync`, and `dyn AsyncRead` is not `Sync` by default. The POC widened the halves to `+ Sync` (socket halves are `Sync`, so dial-shaped payloads are unaffected). @@ -132,26 +132,26 @@ be `Sync`, which is fine for socket-backed substrates but would exclude non-Sync handles (e.g. process pipes as boxed trait objects) without a wrapper. **Documented upstream 2026-09-07 (alkcall 0.7.0): the `ChannelPlan` type doc now carries the `Send + Sync` payload -constraint.** No further action needed — the bound is correct (the +constraint.** No further action needed; the bound is correct (the plan crosses the wrapper task boundary). -### F-2 — empty UDP datagrams collide with the EOF sentinel in raw pass-through (spec-mandate) +### F-2: empty UDP datagrams collide with the EOF sentinel in raw pass-through (spec-mandate) The raw pass-through pump shape (`tokio::io::copy` between the UdpHalf adapter and the channel) cannot carry an empty datagram: `poll_recv` -returning zero bytes is `Ok(0)` from the adapter — indistinguishable +returning zero bytes is `Ok(0)` from the adapter, indistinguishable from EOF for `copy`, which treats it as end-of-stream and shuts the pump down. An empty datagram and the zero-length EOF sentinel are the SAME wire shape at the pump level. Pinned by test (`reverse_udp_empty_datagram_finding` asserts the non-round-trip so the conclusion is executable, not anecdotal). -Consequence: the forward POC's codec decision — `[len: u16 BE]` -framing for UDP — is now validated from a SECOND angle: not only does +Consequence: the forward POC's codec decision (`[len: u16 BE]` +framing for UDP) is now validated from a SECOND angle: not only does it preserve boundaries, it is MANDATORY for correctness (an empty datagram is 2 bytes under the codec, unambiguous with EOF). The Phase 1 spec must state: UDP rides the length-framed codec, never raw -pass-through. Stream substrates (TCP, unix) keep raw pass-through — -a zero-length read there is genuinely EOF. +pass-through. Stream substrates (TCP, unix) keep raw pass-through, +since a zero-length read there is genuinely EOF. ## The `-R` registration template (SSH tcpip-forward analogue) @@ -159,24 +159,24 @@ SSH: global request `tcpip-forward` → per-accept `forwarded-tcpip` channel opens toward the forwarder → `cancel-tcpip-forward`. Mapped onto the validated surface (OQ-TN-03's role-follows-resource -model — no protocol-level direction): +model, no protocol-level direction): | SSH | alktunnels reverse shape (validated) | | --- | --- | | `tcpip-forward` registration | hub binds the listener locally (OQ-TN-04: binding is assembly-layer; no advertisement op needed for the hub's OWN listeners) | | server-side accept loop | hub accept loop (assembly layer) | | per-accept `forwarded-tcpip` open | hub calls the worker's served open op per accept (`call_with_payload`) + `adopt_channel` | -| target dial | worker's establisher dials the resource (establishment phase — typed errors) | +| target dial | worker's establisher dials the resource (establishment phase with typed errors) | | `cancel-tcpip-forward` | hub closes: pump teardown + `teardown_channel` (and/or `channel/close` out-of-band on the worker) | -| originator pair (informational) | not needed — ACL rides the channels open-op machinery | +| originator pair (informational) | not needed; ACL rides the channels open-op machinery | Key validation: **no advertisement surface is needed in the base crate.** The "please listen on your side" step of SSH's template -dissolves when the listener is the hub's own local resource — the hub +dissolves when the listener is the hub's own local resource: the hub does not ask the worker to listen; the worker serves open ops, and the hub opens channels whenever its own accepts fire. An advertisement op only becomes necessary if the LISTENER lives on the far side -("worker, expose port 8080 on your end") — which is the worker +("worker, expose port 8080 on your end"), which is the worker exposing a produced resource = the forward shape with the listener as the resource (producer-side listen is an establisher variant, not a new mechanism). This closes the last open thread of OQ-TN-03's @@ -186,55 +186,59 @@ original question set (the `-R` advertisement/lifecycle surface). ``` alktunnels-reverse-poc/ - Cargo.toml — alkcall 0.6.0; tokio wasm-clean subset + net (POC-local) + Cargo.toml : alkcall 0.6.0; tokio wasm-clean subset + net (POC-local) src/ - lib.rs — module docs; wrinkle overview - producer.rs — worker half: tunnel_open_spec, tunnel_establisher - (Establishment::new(plan) — R-01; per-call - identity witness — CF-006), make_tunnel_ - pump_handler (pump_bidi inline — R-02/03), + lib.rs : module docs; wrinkle overview + producer.rs : worker half: tunnel_open_spec, tunnel_establisher + (Establishment::new(plan), R-01; the per-call + identity witness is CF-006), make_tunnel_ + pump_handler (pump_bidi inline, R-02/03), register_tunnel_openable (post-hoc, W2) producer/ - params.rs — TunnelParams {resource, substrate} + the UdpHalf - adapter (Send + Sync — F-1) - consumer.rs — hub half: open_reverse_channel (call_with_payload - with optional auth_token — now the fallback + params.rs : TunnelParams {resource, substrate} + the UdpHalf + adapter (Send + Sync, F-1) + consumer.rs : hub half: open_reverse_channel (call_with_payload + with optional auth_token; this is now the fallback path; primary is transport identity, 0.7.0), ReverseTunnel::open_and_pump (adopt + pump_bidi), join_and_reap/close (W3), bi_stream_from_halves - harness.rs — wire()/wire_with(): worker = + harness.rs : wire()/wire_with(): worker = from_connection_with_serving (transport identity - + ServingConfig identity/provider — the 0.7.0 - precedence chain) + ChannelOperations:: + + ServingConfig identity/provider, which is the + 0.7.0 precedence chain) + ChannelOperations:: register_on + post-hoc openable; hub = adapter + capturing install hook (manager + CallConnection) tests/ - tunnel_poc.rs — 16 tests (see Executive summary + §W1) + tunnel_poc.rs : 16 tests (see Executive summary + §W1) ``` ## What the POC does NOT validate -1. **A real transport** — `tokio::io::duplex` stands in for +1. **A real transport**: `tokio::io::duplex` stands in for TCP/TLS/QUIC (unchanged from the forward POC scope). -2. **The advertisement op for far-side listeners** — resolved as +2. **The advertisement op for far-side listeners**, resolved as "not needed" (see the template table); if a deploy mode ever wants "worker, expose a port on YOUR side," that is a producer-side - listen establisher over the same open op — spec-shaped, not POC- + listen establisher over the same open op, which is + spec-shaped, not POC- shaped. -3. **Unix socket + stdio substrates** — OQ-TN-10 #3 (cheap; the pump +3. **Unix socket + stdio substrates**: OQ-TN-10 #3 (cheap; the pump is substrate-agnostic and both POCs now confirm it). Phase 1 - resolution: unix in via the `local` feature; stdio OUT — alktty's + resolution: unix in via the `local` feature; stdio OUT, since + alktty's pipe mode owns process stdio (OQ-TN-14). -4. ~~**W1 remediation**~~ — **resolved 2026-09-07 (alkcall 0.7.0)**: +4. ~~**W1 remediation**~~; **resolved 2026-09-07 (alkcall 0.7.0)**: the remediation IS now exercised from this POC's topology (transport identity, `ServingConfig.identity` override, precedence, fail-closed, CF-006 witness). Remaining un-exercised: fingerprint- - based identity resolution (`resolve_from_fingerprint` — the POC's + based identity resolution (`resolve_from_fingerprint`, since the + POC's duplex transport has no TLS layer to fingerprint). -5. **Multi-hop (hub relay traversal)** — single hop; relay forwarding +5. **Multi-hop (hub relay traversal)**: single hop, and relay + forwarding (alkcall ADR-042) is transparent per leg. -6. **Wasm** — POC-local `tokio/net`; the real crate's protocol-only +6. **Wasm**: POC-local `tokio/net`; the real crate's protocol-only core stays wasm-clean (unchanged conclusion). ## Verification @@ -248,21 +252,22 @@ alktunnels-reverse-poc/ ## References -- alkcall 0.6.0 — review 007 remediation as consumed here: ADR-049 +- alkcall 0.6.0, review 007 remediation as consumed here: ADR-049 amendment 2 (`Establishment::new(plan)`, R-01), ADR-050 (`channels::pump_bidi`, R-03), R-02 JoinHandle contract. -- alkcall review 007 §Part B — the reverse-flow non-finding trace +- alkcall review 007 §Part B: the reverse-flow non-finding trace (`from_connection_with_serving` + `register_on` + serving-side - allocation) — confirmed by execution. -- alkcall ADR-022 §2 — both-sides serving semantics (the worker's - shape). ADR-047 §5 — connection-owner allocation (the odd-ID - orientation). ADR-037/ADR-042 — channel ops, relay. -- alktunnels `docs/research/phase-0-findings.md` — OQ-TN-03 (direction - semantics — the advertisement thread this POC closes), OQ-TN-04 - (no forced binding — the hub binds, the worker never does), - OQ-TN-09 (half-open residual — validated W4), OQ-TN-10 #2 (this + allocation), confirmed by execution. +- alkcall ADR-022 §2, both-sides serving semantics (the worker's + shape). ADR-047 §5, connection-owner allocation (the odd-ID + orientation). ADR-037/ADR-042, channel ops, relay. +- alktunnels `docs/research/phase-0-findings.md`: OQ-TN-03 (direction + semantics, the advertisement thread this POC closes), OQ-TN-04 + (no forced binding, the hub binds while the worker never does), + OQ-TN-09 (half-open residual, validated W4), OQ-TN-10 #2 (this POC). -- alktunnels `docs/research/ssh-socks5-survey.md` §RFC 4254 §7.1 — +- alktunnels `docs/research/ssh-socks5-survey.md` §RFC 4254 §7.1: the `tcpip-forward` template mapped in the table above. -- forward POC (`poc-summary.md`) — the codec decision F-2 strengthens; +- forward POC (`poc-summary.md`), the codec decision that F-2 + strengthens; the UdpHalf adapter reused; the plan flow replaces `HandleHandoff`. \ No newline at end of file diff --git a/docs/research/ssh-socks5-survey.md b/docs/research/ssh-socks5-survey.md index e535fc1..e5e843b 100644 --- a/docs/research/ssh-socks5-survey.md +++ b/docs/research/ssh-socks5-survey.md @@ -13,7 +13,7 @@ tun2proxy UDP gateway) and answers the residue questions on OQ-TN-01, OQ-TN-02, OQ-TN-07, and OQ-TN-09. Sources: RFC 4254 §4–7 (SSH Connection Protocol), RFC 1928 (SOCKS5), -russh (`/workspace/russh` — Rust SSH implementation), tun2proxy's +russh (`/workspace/russh`, a Rust SSH implementation), tun2proxy's SOCKS5 client (`/workspace/tun2proxy/src/socks.rs`, `src/proxy_handler.rs`, `src/lib.rs`), alktty's negotiation/control frames (`/workspace/@alkdev/alktty/src/negotiation.rs`, `src/wire.rs`, @@ -22,7 +22,7 @@ frames (`/workspace/@alkdev/alktty/src/negotiation.rs`, `src/wire.rs`, ## SSH forwarding model -### RFC 4254 §5.1 — the channel-open envelope +### RFC 4254 §5.1: the channel-open envelope Every SSH channel open (RFC 4254 §5.1) is: @@ -41,7 +41,7 @@ SSH's "channel type" string plays the role of the ALPN/substrate discriminator; the trailing bytes play the role of the resource identification. -### RFC 4254 §7.2 — `direct-tcpip` and `forwarded-tcpip` +### RFC 4254 §7.2: `direct-tcpip` and `forwarded-tcpip` Both TCP forwarding channel types share one payload shape (`TcpChannelInfo` in russh, `russh/src/parsing.rs:116-153`): @@ -64,24 +64,25 @@ uint32 originator port `-R` data path. - **The originator pair is informational.** RFC 4254 §7.2: "the 'originator IP address' is the numeric IP address of the machine - from where the connection request originates" — it is for the + from where the connection request originates", and it is for the recipient's logging/policy, not routing. russh carries it verbatim (`channel_open_direct_tcpip(host, port, originator_address, originator_port)`, `russh/src/client/mod.rs:718-741`), and the example passes the real accepted peer address (`russh/examples/client_open_direct_tcpip.rs:135-143`). It is the one piece of SSH addressing that has no alktunnels analogue in the - base params — it is metadata, not routing. + base params; it is metadata, not routing. OpenSSH extension for unix sockets (present in russh, `russh/src/client/session.rs:90-100`): `direct-streamlocal@openssh.com` carries only `socket_path` plus two -reserved fields (`""` string, `0u32`) — the same 4-slot shape with the +reserved fields (`""` string, `0u32`), which is +the same 4-slot shape with the address slots emptied. This is prior art for "a new substrate = same open-op shape, degenerate address fields," which supports OQ-TN-01's substrate-discriminator reframe. -### RFC 4254 §7.1 — the `-R` registration (`tcpip-forward`) +### RFC 4254 §7.1: the `-R` registration (`tcpip-forward`) Remote forwarding is registered with a *global request*, not a channel: @@ -93,8 +94,8 @@ string address to bind ("" | "0.0.0.0" | "::" | "localhost" | "12 uint32 port number to bind ``` -Reply is `SSH_MSG_REQUEST_SUCCESS` (81) — carrying `uint32 port that -was bound` when the client passed port 0 (server allocates) — or +Reply is `SSH_MSG_REQUEST_SUCCESS` (81), carrying `uint32 port that +was bound` when the client passed port 0 (server allocates), or `SSH_MSG_REQUEST_FAILURE` (82). Cancellation is the `cancel-tcpip-forward` global request (§7.1). After registration, each accepted connection arrives as a `forwarded-tcpip` channel open from @@ -146,12 +147,12 @@ opener's side afterward.** - **`-L` (local forward):** the client accepts on a local port, and for each accept opens a `direct-tcpip` channel naming the target; the - server dials. Consumer opens, producer handles — the POC's shape. + server dials. Consumer opens, producer handles: the POC's shape. - **`-R` (remote forward):** the client sends `tcpip-forward` (global request) to register the listen; the *server* then opens `forwarded-tcpip` channels per accepted connection toward the client, which dials the real target. Producer-side accept, consumer-side - dial — the roles are the same pair, entry point on the other machine + dial; the roles are the same pair, with the entry point on the other machine (matches the phase-0 hub model resolution of OQ-TN-03). - **`-D` (dynamic/SOCKS):** the client runs a SOCKS server locally; for each SOCKS CONNECT it parses the target out of the SOCKS request and @@ -174,7 +175,7 @@ prior art is SSH3 (draft-michel-remote-terminal-http3), a different protocol over HTTP/3, not SSH-the-RFC. **Correction to the phase-0 doc:** OQ-TN-02's line "SSH's `-D` UDP associate tunnels UDP as a stream with per-datagram framing re-added by the tunnel protocol (e.g. -SOCKS5 UDP over TCP)" is a category error — SSH `-D` carries the SOCKS5 +SOCKS5 UDP over TCP)" is a category error: SSH `-D` carries the SOCKS5 *TCP* control connection only; SOCKS5 UDP ASSOCIATE datagrams never traverse an SSH channel in any standard SSH implementation. The real UDP-over-stream prior art is tun2proxy's udpgw (already analyzed) and @@ -223,7 +224,7 @@ Reply codes (RFC 1928 §6): | 0x08 | address type not supported | On failure the server MUST close the TCP connection within 10 seconds -of sending the reply (§6 "Reply Processing") — the same +of sending the reply (§6 "Reply Processing"), the same "error-then-close" shape SSH open-failure has, and the same shape OQ-TN-09's accepted direction implies for tunnel establishment (control frame, then channel death). @@ -234,18 +235,18 @@ ReceiveAuthResponse, SendRequest, ReceiveResponse, Established}` (`socks.rs:11-20`), with the reply parsed in `receive_connection_status` (`socks.rs:226-248`) and non-`Succeeded` replies aborting the session. The `ProxyHandler` trait -(`proxy_handler.rs:9-22`) is the handler-inversion point — same shape -as alktty's `TtyBackend`/this crate's OQ-TN-05 question, with +(`proxy_handler.rs:9-22`) is the handler-inversion point, the same +shape as alktty's `TtyBackend`/this crate's OQ-TN-05 question, with `get_udp_associate()` as the UDP-specific handle. ### UDP ASSOCIATE flow (RFC 1928 §7) 1. The client sends UDP ASSOCIATE **over the TCP control connection**, with DST.ADDR/DST.PORT = the client's expected datagram source (all - zeros if unknown — the common case; tun2proxy sends + zeros if unknown, the common case; tun2proxy sends `Address::unspecified()`, `socks.rs:214-215`). 2. The reply's BND.ADDR/BND.PORT is **the relay endpoint the client - must send UDP datagrams to** — this is how the client learns where + must send UDP datagrams to**, which is how the client learns where to send (`socks.rs:241-244` stores it as `udp_associate`). 3. Each datagram to/from that endpoint carries the UDP request header: @@ -262,7 +263,7 @@ as alktty's `TtyBackend`/this crate's OQ-TN-05 question, with MUST drop datagrams with FRAG ≠ 0 (§7). 4. The relay server pins the association to the client's source IP and drops datagrams from any other source (§7). **The association - terminates when the TCP control connection terminates** (§6) — the + terminates when the TCP control connection terminates** (§6); the TCP session is the UDP flow's lifecycle anchor. 5. Replies are relayed silently; there is no per-datagram acknowledgment or error channel. Errors are signaled only by @@ -276,33 +277,33 @@ the relay and strips it on the way back. ### What udpgw dropped vs kept from RFC 1928 udpgw's packet format (`udpgw.rs:55-88`) is `LEN(u16 BE) | FLAGS(u8) | -CONN_ID(u16) | [SOCKS5 address] | DATA` — "referenced from SOCKS5 +CONN_ID(u16) | [SOCKS5 address] | DATA`, "referenced from SOCKS5 packet format, with additional flags and connection ID fields" (`udpgw.rs:57`): - **Kept:** the ATYP/DST.ADDR/DST.PORT address encoding per datagram (§5's scheme-tagged addressing, unchanged). -- **Kept (implicitly):** per-datagram boundary framing — but udpgw +- **Kept (implicitly):** per-datagram boundary framing, but udpgw re-derives it with an explicit LEN prefix, because it rides a TCP stream, not a datagram socket. SOCKS5 UDP never needed a length prefix because UDP itself preserves boundaries; udpgw's LEN is the price of moving the relay onto TCP. -- **Dropped: RSV** — pointless over a stream (it exists only to +- **Dropped: RSV**, pointless over a stream (it exists only to word-align the UDP header). -- **Dropped: FRAG** — fragmentation over a reliable in-order stream is +- **Dropped: FRAG** because fragmentation over a reliable in-order stream is unnecessary; udpgw caps datagram size with an MTU check instead (`parse_udp_response` rejects `data.len() > udp_mtu`, `udpgw.rs:527`). -- **Dropped: the TCP-control-connection lifecycle model** — SOCKS5 +- **Dropped: the TCP-control-connection lifecycle model**: SOCKS5 binds each UDP association to a TCP session and learns the relay endpoint from a reply; udpgw multiplexes all flows over pooled gateway connections with a client-allocated `CONN_ID(u16)` (`udpgw.rs:66`) and replaces "reply carries the relay endpoint" with "the gateway connection is the endpoint." -- **Added: FLAGS** — `KEEPALIVE(0x01)` and `ERR(0x20)` as +- **Added: FLAGS**, namely `KEEPALIVE(0x01)` and `ERR(0x20)` as address-less, data-less control packets (`udpgw.rs:21-26`), giving the stream a minimal frame vocabulary: DATA / KEEPALIVE / ERR. -- **Dropped: reply codes** — the ERR flag carries *no* reason +- **Dropped: reply codes**; the ERR flag carries *no* reason information (`build_error_packet` is just header + conn_id, `udpgw.rs:151-153`). SOCKS5's 9 reply codes and SSH's 4 reason codes + description both collapse to a single opaque bit. This is the @@ -312,11 +313,11 @@ packet format, with additional flags and connection ID fields" | | SSH open-failure (RFC 4254 §5.1) | SOCKS5 reply (RFC 1928 §6) | udpgw flags (udpgw.rs:21-26) | alktty ctrl frames (control.rs:58-93) | |---|---|---|---|---| -| establishment ack | `CHANNEL_OPEN_CONFIRMATION` (91) | REP `0x00` + BND.ADDR/BND.PORT | (none — data just flows) | negotiation frame reply (negotiation.rs) | +| establishment ack | `CHANNEL_OPEN_CONFIRMATION` (91) | REP `0x00` + BND.ADDR/BND.PORT | (none; data just flows) | negotiation frame reply (negotiation.rs) | | failure signal | `CHANNEL_OPEN_FAILURE` (92) | REP `0x01`–`0x08` | `ERR` flag packet | length-prefixed JSON error frame (error_response_bytes, negotiation.rs:263-277) | | failure granularity | 4 IANA codes + private range | 8 codes | 1 opaque bit | free-form JSON `{"error": ...}` | | human-readable detail | `description` string + language tag | none (codes only) | none | JSON fields | -| channel survives failure | no — channel removed client-side (client/encrypted.rs:421-427) | TCP connection closed ≤10s | flow-level (conn_id), stream survives | stream never established | +| channel survives failure | no; channel removed client-side (client/encrypted.rs:421-427) | TCP connection closed ≤10s | flow-level (conn_id), stream survives | stream never established | | in-band vs op-level | in-channel reply message | control-connection reply | in-stream flag packet | first frame on the stream | Observations for the alktunnels control-frame ADR (OQ-TN-09): @@ -324,12 +325,12 @@ Observations for the alktunnels control-frame ADR (OQ-TN-09): - SSH is the only prior art that puts a *reason code + description* in the failure reply; SOCKS5 has codes but no detail; udpgw has neither. The alktty-shaped JSON control frame can carry both (a machine code - field and a detail string) at zero structural cost — it is the + field and a detail string) at zero structural cost; it is the strictly richest of the four, and it is already the accepted direction. - All four agree on the lifecycle consequence: **establishment failure kills the channel/connection before data flows.** None of them - supports "fail the open but keep the channel for a retry" — there is + supports "fail the open but keep the channel for a retry"; there is no prior art for a retry-without-reopen path, and none is needed (the consumer just opens a new channel). - SSH's reason-code taxonomy (policy / dial-failed / unknown-type / @@ -338,7 +339,7 @@ Observations for the alktunnels control-frame ADR (OQ-TN-09): open handler can produce (ACL denied, dial failed, unknown substrate/resource, channels limits). SOCKS5's set adds network-unreachable vs host-unreachable vs connection-refused - granularity inside "dial failed" — worth considering as sub-codes or + granularity inside "dial failed", worth considering as sub-codes or just leaving to the detail string. ## UDP: endpoint-at-open vs per-datagram @@ -347,7 +348,7 @@ What each prior art does: | | endpoint model | where the address lives | flow multiplexing | |---|---|---|---| -| SSH (TCP only) | fixed at open | `direct-tcpip`/`forwarded-tcpip` payload | none needed — one channel = one TCP connection | +| SSH (TCP only) | fixed at open | `direct-tcpip`/`forwarded-tcpip` payload | none needed; one channel = one TCP connection | | SOCKS5 UDP ASSOCIATE | relay endpoint fixed at open (BND.ADDR/BND.PORT); **destination per-datagram** | DST.ADDR/DST.PORT in each UDP header | one association per control connection; source-IP pinning | | udpgw | destination per-datagram | SOCKS5 address in each DATA packet | `CONN_ID(u16)` client-allocated, many flows per gateway stream | | alktunnels base open-op (OQ-TN-01 reframe) | resource fixed at open | `params` (resource id + substrate) | the channel ID itself | @@ -360,30 +361,30 @@ Implications for a channels-based tunnel: alktunnels' base open-op is a *resource access*: the consumer names a produced resource, and the producer owns the backing endpoint. Under the hub model (phase-0 §Prior art), endpoint-at-open is the - natural fit for the base op — the resource *is* the endpoint + natural fit for the base op, since the resource *is* the endpoint binding, and per-datagram addressing would reintroduce exactly the "general addressing in params" that the OQ-TN-01 reframe removed. 2. **Per-datagram addressing belongs to the `-D`/dynamic composition path, not the base op.** If a UDP SOCKS5-style service is produced, the consumer tunnels to it (base op, substrate `udp` or even `tcp` for the control leg) and the SOCKS5 UDP header rides *inside* the - tunnel payload — exactly as tun2proxy's socks path does over its + tunnel payload, exactly as tun2proxy's socks path does over its proxy connection (`lib.rs:672-687`). The base protocol never sees per-datagram addresses. 3. **If a base-op UDP resource ever needs multiple remote endpoints - (a "UDP gateway" resource), the udpgw shape is the template:** - length-prefixed datagrams, SOCKS5-style per-datagram address, a - flow key, and DATA/KEEPALIVE/ERR flags — all *inside* the channel, - self-describing, invisible to the open-op params. That keeps - OQ-TN-07's option A (single `alk/tunnel` ALPN) viable: udpgw proves - one framing can carry the datagram case over a stream substrate - without the ALPN knowing. + (a "UDP gateway" resource), the udpgw shape is the template:** + length-prefixed datagrams, SOCKS5-style per-datagram address, a + flow key, and DATA/KEEPALIVE/ERR flags, all *inside* the channel, + self-describing and invisible to the open-op params. That keeps + OQ-TN-07's option A (single `alk/tunnel` ALPN) viable: udpgw proves + one framing can carry the datagram case over a stream substrate + without the ALPN knowing. 4. **The channel ID replaces CONN_ID.** udpgw needs CONN_ID because it multiplexes over 1–5 pooled TCP streams (a throughput choice, `UDPGW_MAX_CONNECTIONS = 5`, udpgw.rs:15); channels already multiplexes per channel ID with a 256-channel cap. One channel per - UDP flow (or per association) needs no protocol-level flow key — - the OQ-TN-02 residue question "is a u16 conn-id vocabulary right" + UDP flow (or per association) needs no protocol-level flow key; the + OQ-TN-02 residue question "is a u16 conn-id vocabulary right" resolves to *no*: the channel ID does the demux, and a conn-id inside the channel would be a second demux layer (AGENTS.md convention 10: don't build a second demux/mux). @@ -396,7 +397,7 @@ Implications for a channels-based tunnel: ## What NOT to carry over -Explicit anti-prior-art — mechanisms the prior arts have that alkcall +Explicit anti-prior-art: mechanisms the prior arts have that alkcall channels already owns, or that don't survive the substrate change: 1. **SSH window updates (`SSH_MSG_CHANNEL_WINDOW_ADJUST`, RFC 4254 @@ -408,13 +409,13 @@ channels already owns, or that don't survive the substrate change: (`channels/mod.rs:116-139`) is the thing we *don't* build. 2. **SSH's `maximum packet size` negotiation.** Channels owns chunk sizing; the tunnel codec inherits it. (udpgw's MTU cap is the - datagram-flavored version — also producer-side, not wire.) + datagram-flavored version, also producer-side, not wire.) 3. **SSH's sender/recipient dual channel numbering.** Channels allocates monotonic IDs with a single namespace (alkcall ADR-034/040); the open op's channel ID is already agreed before the handler runs. No ID translation in the tunnel protocol. 4. **SOCKS5's FRAG byte.** Fragmentation over a reliable in-order - channel is redundant — udpgw already dropped it for exactly that + channel is redundant; udpgw already dropped it for exactly that reason. If a datagram is too big for the channel's buffer, the producer-side MTU cap rejects it; reassembly is not the tunnel's job. @@ -426,7 +427,7 @@ channels already owns, or that don't survive the substrate change: 6. **SOCKS5's BND.ADDR/BND.PORT reply semantics.** The "reply tells you where to send datagrams" pattern exists because SOCKS5's UDP relay is a *separate socket* from the control connection. On - channels there is no second endpoint — the channel *is* the + channels there is no second endpoint: the channel *is* the endpoint. An establishment-ack control frame needs no address payload. 7. **udpgw's KEEPALIVE flag and connection pooling.** Both are @@ -446,10 +447,10 @@ channels already owns, or that don't survive the substrate change: two-field shape: - SSH's `direct-tcpip` payload is exactly "target + (informational - originator)" — and the informational half is the only part with no + originator)", and the informational half is the only part with no alktunnels analogue, because ACL/identity is carried by the channels open-op machinery, not by params. The routing-relevant payload - reduces to *one address-ish field + one port* — which under the + reduces to *one address-ish field + one port*, which under the resource model collapses further into *one resource identifier*. - OpenSSH's `direct-streamlocal` shows the extensibility pattern: same open-op shape, degenerate address slots, new type string. For @@ -457,13 +458,13 @@ two-field shape: "substrate": "tcp" | "udp" | }` with any substrate-specific detail owned by the producer's registry, not the wire. A new substrate is a new `substrate` value, not a format - change — the same additive property SSH gets from channel-type + change, which is the same additive property SSH gets from channel-type strings and SOCKS5 gets from ATYP values. - SOCKS5's ATYP (v4/domain/v6) is *not* needed in the base params: it is per-datagram/per-request addressing for the dynamic path, which rides inside the tunnel payload (see §UDP). If the resource identifier is ever an address rather than a name, ATYP's - scheme-tagged encoding is the fallback shape — but the resource-name + scheme-tagged encoding is the fallback shape, but the resource-name direction makes that unnecessary. **Open-failure error path.** The prior art converges on: *a structured @@ -476,8 +477,8 @@ ADR): (phase-0 OQ-TN-09): a single JSON frame before any data chunk, with at minimum `{"ok": true}` or `{"error": , "detail": }`. - The reason-code vocabulary should cover SSH's four (policy-denied / - dial-failed / unknown-resource-or-substrate / resource-shortage) — - they map 1:1 onto what an open handler can actually produce: + dial-failed / unknown-resource-or-substrate / resource-shortage), + which map 1:1 onto what an open handler can actually produce: `AccessControl` denial, target dial failure, unregistered resource/substrate, channels limits. SOCKS5's finer network/host/refused granularity can live in `detail` rather than @@ -496,11 +497,11 @@ Resolved as a *split by path*, not a single choice (§UDP above): association). This aligns with resource naming, needs no in-band addressing, and matches how the hub model produces any resource. Boundary preservation inside the channel is still per-datagram - length framing (tun2proxy-proven) — endpoint-at-open is about + length framing (tun2proxy-proven); endpoint-at-open is about *addressing*, not about dropping the LEN prefix. - **Dynamic/`-D` UDP (SOCKS5-style): per-datagram addressing inside the tunnel payload**, using the SOCKS5 UDP header or udpgw's - ADDR-bearing packet format — composed at the assembly layer, never + ADDR-bearing packet format, composed at the assembly layer, never in base params. - The channel ID replaces udpgw's CONN_ID (no second demux); KEEPALIVE drops; the ERR flag's job is done better by the control frame. @@ -519,7 +520,7 @@ strengthens it: datagram framing is confined to UDP-substrate channels and is self-describing from the first chunk (a length prefix is unambiguous against raw pass-through only if TCP channels are also - framed — see the codec decision on the convergence checklist; if + framed; see the codec decision on the convergence checklist; if TCP is raw pass-through, the substrate discriminator in `params` tells the handler which framing to expect, which is exactly option A's "substrate is a params field"). @@ -532,7 +533,7 @@ strengthens it: Inputs from this survey (§Comparison table above): - Shape: alktty-style JSON control frame (already the accepted - direction) — it is the richest vocabulary of the four prior arts + direction), which is the richest vocabulary of the four prior arts (code + detail) and needs no new framing layer. - Content: an establishment ack (SSH's CONFIRMATION / SOCKS5's success reply analogue) and a failure frame with a small reason-code @@ -541,62 +542,62 @@ Inputs from this survey (§Comparison table above): - What the frame does *not* need: window adjustments (channels owns backpressure), keepalives (channels owns liveness), fragmentation (not the tunnel's job), per-datagram error replies (SOCKS5 relays - silently; udpgw's ERR is flow-level at most — dial errors are + silently; udpgw's ERR is flow-level at most, since dial errors are channel-closing per the accepted direction). ## References -- RFC 4254, *The Secure Shell (SSH) Connection Protocol* — §4 (global +- RFC 4254, *The Secure Shell (SSH) Connection Protocol*: §4 (global requests), §5.1 (channel open / open-failure reason codes), §5.2 (window adjust), §7.1 (`tcpip-forward`), §7.2 (`direct-tcpip`, `forwarded-tcpip`). https://www.rfc-editor.org/rfc/rfc4254.txt -- RFC 1928, *SOCKS Protocol Version 5* — §3 (method negotiation), §4 +- RFC 1928, *SOCKS Protocol Version 5*: §3 (method negotiation), §4 (request format), §5 (ATYP addressing), §6 (replies, UDP ASSOCIATE lifecycle), §7 (UDP relay header, FRAG). https://www.rfc-editor.org/rfc/rfc1928.txt -- russh — `/workspace/russh`: - - `russh/src/parsing.rs:8-153` — `OpenChannelMessage` parse, +- russh (`/workspace/russh`): + - `russh/src/parsing.rs:8-153`: `OpenChannelMessage` parse, `TcpChannelInfo` (direct-tcpip/forwarded-tcpip payload), `StreamLocalChannelInfo`, confirm/fail encoders. - - `russh/src/lib_inner.rs:402-423` — `ChannelOpenFailure` reason + - `russh/src/lib_inner.rs:402-423`: `ChannelOpenFailure` reason enum. - - `russh/src/client/mod.rs:675-760` — public `channel_open_*` API; - `client/session.rs:74-100` — wire encoding of direct-tcpip / + - `russh/src/client/mod.rs:675-760`: public `channel_open_*` API; + `client/session.rs:74-100`: wire encoding of direct-tcpip / direct-streamlocal payloads. - - `russh/src/server/encrypted.rs:1150-1278` — server-side open + - `russh/src/server/encrypted.rs:1150-1278`: server-side open dispatch, `finalize_channel_open` failure path; - `server/session.rs:1055-1115, 1186-1233` — server-initiated opens + `server/session.rs:1055-1115, 1186-1233`: server-initiated opens and `tcpip-forward` global request. - - `russh/src/client/encrypted.rs:414-434` — client-side + - `russh/src/client/encrypted.rs:414-434`: client-side open-failure handling. - - `russh/examples/client_open_direct_tcpip.rs:129-184` — the -L + - `russh/examples/client_open_direct_tcpip.rs:129-184`: the -L pump shape (two select arms, EOF handling). - Confirmed: no UDP anywhere in `russh/src/` (grep, 2026-09-06). -- tun2proxy — `/workspace/tun2proxy`: - - `src/udpgw.rs:55-88` — packet format doc comment; `:21-26` flags; +- tun2proxy (`/workspace/tun2proxy`): + - `src/udpgw.rs:55-88`: packet format doc comment; `:21-26` flags; `:147-153` keepalive/error packet builders; `:527` MTU cap. (Analyzed in phase-0-findings.md §Prior art.) - - `src/socks.rs:11-20` — SOCKS5 client state machine; `:213-224` + - `src/socks.rs:11-20`: SOCKS5 client state machine; `:213-224` request encoding (UDP ASSOCIATE sends unspecified address); `:226-248` reply parsing + `udp_associate` capture. - - `src/proxy_handler.rs:9-32` — `ProxyHandler`/`ProxyHandlerManager` + - `src/proxy_handler.rs:9-32`: `ProxyHandler`/`ProxyHandlerManager` inversion point. - - `src/lib.rs:625-724` — `handle_udp_associate_session`: SOCKS5 UDP + - `src/lib.rs:625-724`: `handle_udp_associate_session`: SOCKS5 UDP header added/stripped per datagram over the relay endpoint. -- alktty — `/workspace/@alkdev/alktty`: - - `src/negotiation.rs:64-120` — `NegotiateRequest` (self-contained - JSON open-op precedent); `:127-277` — length-prefixed framing + +- alktty (`/workspace/@alkdev/alktty`): + - `src/negotiation.rs:64-120`: `NegotiateRequest` (self-contained + JSON open-op precedent); `:127-277`: length-prefixed framing + `error_response_bytes` (the error-frame shape). - - `src/wire.rs:1-57` — 5-byte chunk codec, stream types, zero-length + - `src/wire.rs:1-57`: 5-byte chunk codec, stream types, zero-length sentinels. - - `src/control.rs:58-108` — `ControlMessage` JSON control frames. -- alknet-channels POC — `/workspace/alknet-channels-poc`: - - `src/tunnel_handler.rs:24-59` — the two-pump tunnel handler (no - establishment ack — the gap OQ-TN-09 closes). + - `src/control.rs:58-108`: `ControlMessage` JSON control frames. +- alknet-channels POC (`/workspace/alknet-channels-poc`): + - `src/tunnel_handler.rs:24-59`: the two-pump tunnel handler (no + establishment ack, the gap OQ-TN-09 closes). - OpenSSH `-D` protocol-level behavior (no local source; web): - stackoverflow.com/questions/78045232 and - /questions/51347627 — `-D` = local SOCKS server feeding + /questions/51347627: `-D` = local SOCKS server feeding per-connection `direct-tcpip` opens; no distinct channel type. - - SSH3 (draft-michel-remote-terminal-http3) — the only "direct-udp" + - SSH3 (draft-michel-remote-terminal-http3): the only "direct-udp" prior art found; a different protocol (HTTP/3), not RFC SSH. \ No newline at end of file diff --git a/docs/research/tunnels-graduation.md b/docs/research/tunnels-graduation.md index 55e4175..7d610dd 100644 --- a/docs/research/tunnels-graduation.md +++ b/docs/research/tunnels-graduation.md @@ -1,40 +1,42 @@ --- status: decided -last_updated: 2026-09-16 (§2 → ADR-007; §3/§3.1 → ADR-008 — the leans are pinned, see the status note) +last_updated: 2026-09-16 (§2 → ADR-007; §3/§3.1 → ADR-008, the leans are pinned, see the status note) --- -# alksocks research — graduating proxy surface into alktunnels +# alksocks research: graduating proxy surface into alktunnels **Status note (2026-09-16).** Phase 1 decided both asks; this doc is the research record, the ADRs are now normative: - **§2 (the direct op) → - [ADR-007](../architecture/decisions/007-direct-open-op.md)** — + [ADR-007](../architecture/decisions/007-direct-open-op.md)**: option (a) confirmed, with one refinement: the `target` encoding is a self-describing JSON object keyed by the sibling `substrate` - field (`{host, port}` / `{path}`), not the ATYP byte string — the + field (`{host, port}` / `{path}`), not the ATYP byte string, since + the registry's `input_schema` validation (alkcall ADR-047) needs schema-describable params, and the unix path form falls out for free. Op id `channels/tunnel/direct`, scope `tunnel:direct`, dial-only. - **§3/§3.1 (listen metadata) → - [ADR-008](../architecture/decisions/008-listen-metadata-forwarded-op.md)** — + [ADR-008](../architecture/decisions/008-listen-metadata-forwarded-op.md)**: the §3.1 `forwarded-tcpip` alternative ADOPTED over the per-ask - carriers: a bind-first listen establisher (reply carries the real - `bound` address — ask #1; also dissolves the POC's deadlock and + carriers; a bind-first listen establisher (reply carries the real + `bound` address, ask #1; also dissolves the POC's deadlock and 10s-bound findings) + the forwarded op `channels/tunnel/forwarded` with `{listen, substrate, peer}` params (ask #2; ADR-008 Amendment - 1, 2026-09-18 — the correlation key is a producer-minted token, the + 1, 2026-09-18, where the correlation key is a producer-minted token, the original channel-ID key was unimplementable against the landed handler surface). The `TargetHandle.peer` option (§3 ask #2, option i) is superseded. - **A discovery this research missed:** alkcall ADR-047 pins discovery's ALPN derivation to exactly `…/sub`/`…/pub` op-id shapes - (`derive_alpn_from_op_name`) — flavor-form op ids (both new ops) + (`derive_alpn_from_op_name`), so + flavor-form op ids (both new ops) need an upstream alkcall amendment before the first consumer. Filed: `alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md` (both asks landed in alkcall 0.8.0, plus the in-tree - `ChannelRelay`/`HubLegTemplate` — alkcall ADR-051). + `ChannelRelay`/`HubLegTemplate` (alkcall ADR-051)). The upstream ask ledger (§5) is now: items 1–2 pinned by ADR-007/008 (implementation pending the two upstream alkcall asks), item 3 @@ -44,7 +46,7 @@ ledger), which is unchanged by this. The downstream motivation (2026-09-15 discussion): alksocks' three commands (CONNECT / BIND / UDP ASSOCIATE) need only a few alktunnels -additions, and those additions are generally useful — they are the +additions, and those additions are generally useful, being the `ssh -L`/`-R` generalized shape that any dynamic-target consumer (SOCKS5 included) composes on top of. This doc records the proposed graduation: what moves to alktunnels, what stays in alksocks, the design questions @@ -64,15 +66,15 @@ Mapping the SOCKS5 command surface onto alktunnels 0.1.0: | SOCKS5 piece | alktunnels today | Gap | |---|---|---| -| CONNECT (tcp proxy, egress) | dial establisher + `pump_bidi` + `local::dial_tcp` | none for **local** egress; **no dynamic-target open** for **composed** egress — params are `{resource, substrate}` (ADR-001) | -| UDP ASSOCIATE (udp proxy) | connected-udp substrate + per-target tunnel table | none — the alksocks phase-0 note "no upstream ask exists here" holds; the composed form is per-target `udp` opens (POC #2 measured ~0.8 ms/destination) | -| BIND (reverse tcp) | `listen_establisher` + `AcceptQueue` + `bind_tcp` | the two ledger asks: bound-addr discovery (reply#1's `BND.ADDR`) and peer metadata (reply#2's) — alksocks `poc-bind-findings.md` §Upstream-ask ledger | +| CONNECT (tcp proxy, egress) | dial establisher + `pump_bidi` + `local::dial_tcp` | none for **local** egress; **no dynamic-target open** for **composed** egress, since params are `{resource, substrate}` (ADR-001) | +| UDP ASSOCIATE (udp proxy) | connected-udp substrate + per-target tunnel table | none: the alksocks phase-0 note "no upstream ask exists here" holds; the composed form is per-target `udp` opens (POC #2 measured ~0.8 ms/destination) | +| BIND (reverse tcp) | `listen_establisher` + `AcceptQueue` + `bind_tcp` | the two ledger asks: bound-addr discovery (reply#1's `BND.ADDR`) and peer metadata (reply#2's); see alksocks `poc-bind-findings.md` §Upstream-ask ledger | So the graduation reduces to: -1. **One wire addition: a direct/dynamic open form** — "dial an - arbitrary target," not "dial a named resource" (§2). -2. **The two BIND metadata asks** — carriers to be decided (§3). +1. **One wire addition: a direct/dynamic open form** ("dial an + arbitrary target," not "dial a named resource"; §2). +2. **The two BIND metadata asks**, carriers to be decided (§3). The pumping, teardown, codec, and session machinery are already done and POC-validated; the proxy producers on the alksocks side are thin @@ -80,7 +82,7 @@ adapters over the three callback seams (`DialFn`, `EgressFactory`, `BindAcceptFactory`), which the POCs validated in both local and composed variants. -## 2. The direct open form — the real design decision +## 2. The direct open form: the real design decision ### The constraint behind it @@ -91,13 +93,13 @@ That decision was correct for its use case and the no-forced-bind posture (AGENTS.md #9) stands: nothing here forces any side to bind a port. The use case that justifies an *additive* revisit is the one the POC ledger itself names: a consumer asking the producer to dial an -arbitrary `host:port` — ssh's `direct-tcpip` shape, which ssh carries +arbitrary `host:port`, ssh's `direct-tcpip` shape, which ssh carries as its own channel type precisely because it is a different capability from opening a named resource. SOCKS5 CONNECT is the same capability: a dynamic target chosen at session time. The capability is dangerous in exactly the way ADR-001's "resource, -not address" framing protects against — arbitrary egress from the +not address" framing protects against: arbitrary egress from the producer's network. The mitigation is not refusing the capability (a SOCKS5 service IS the capability) but separating it at the ACL: "dial `postgres-primary`" and "dial anything from my network" must not be @@ -105,13 +107,14 @@ the same grant. ### Options -**(a) Second op on the same ALPN — `channels/tunnel/direct`** with +**(a) Second op on the same ALPN, `channels/tunnel/direct`** with params `{substrate, target}` and its own scope (`tunnel:direct`). -Clean in kind: this IS a different operation — dial what I name, vs. +Clean in kind, because this IS a different operation: dial what I +name, vs. dial what the producer registered. The ACL separation is structural (scopes attach to op specs, not params); the POC #5 scope gate (`socks5:bind`) proved the pattern matters. Old producers reject the -unknown op loudly (registry lookup fails — the SSH posture). +unknown op loudly (registry lookup fails, the SSH posture). **(b) Optional `target` field in the existing params** + a `target`-implies-scope establisher check. ADR-001's consequences bless @@ -125,10 +128,10 @@ grants by name; "any name of this shape" is not a scope), registry bookkeeping gets weird, and discovery listings leak addresses. Reject. -**Lean: (a).** One ALPN, two ops, two scopes — the same shape alktty's +**Lean: (a).** One ALPN, two ops, two scopes: the same shape alktty's scope-gating and the POC #5 command gates took. Wire-stable questions (op id string, scope string, params field names, `target` address -encoding) are pinned by an ADR before the first consumer — timing is +encoding) are pinned by an ADR before the first consumer; timing is right because alksocks is the first real consumer and nothing is published yet. @@ -137,23 +140,23 @@ published yet. The direct op's `target` must carry what `DialFn` today gets from the registry's opaque backing string: `{host, port}` for tcp, `{host, port}` (or resolved-destination semantics) for udp, `{path}` for unix. -The RFC-1928 ATYP encoding (IP v4/v6/domain + port — `TargetAddr`'s +The RFC-1928 ATYP encoding (IP v4/v6/domain + port, per `TargetAddr`'s `to_be_bytes`) is the natural cross-substrate carrier: self-contained, no DNS dependency on the consumer side, and it is the encoding SOCKS5 -itself uses — the alksocks adapter maps it verbatim. Domain-form +itself uses, which the alksocks adapter maps verbatim. Domain-form targets stay unresolved on the wire (producer-side resolution, the ssh `-D` semantic; POC #1/#2 both validated egress-side resolution). ### What does NOT move to alktunnels The RFC 1928 conversation, reply codes, the command scope gates, the -front doors, the `{addr, data}` datagram stage — all alksocks. The +front doors, the `{addr, data}` datagram stage; all alksocks. The direct op is substrate-agnostic transport capability; it knows nothing about SOCKS5 (no `fast-socks5` dependency, no reply-code semantics upstream). The layering assertion holds: alktunnels stays SOCKS-ignorant; alksocks composes it. -## 3. The BIND metadata asks — carriers, and one alternative +## 3. The BIND metadata asks (carriers, and one alternative) alksocks' `poc-bind-findings.md` pinned two asks. The POC's stand-ins (factory carries the expected `bind_addr`; sentinel `0.0.0.0:0` as @@ -166,36 +169,37 @@ The open-op reply is the only pre-data-plane wire moment, and it happens *after* the establisher. SOCKS5 BIND is address-first (reply#1 must carry `BND.ADDR` before the app server dials); alktunnels' listen establisher is accept-first (its establishment phase pops an -already-accepted connection — the open resolves *because* the app +already-accepted connection, and the open resolves *because* the app server dialed). The POC resolved the sequencing in shape (reply#1 -first; the tunnel open rides the accept future) — the asks are about +first; the tunnel open rides the accept future), so the asks are about fidelity, not feasibility. ### The two asks, restated as carriers -**Ask #1 — bound-addr discovery without consuming an accept.** +**Ask #1: bound-addr discovery without consuming an accept.** Options: -- **Inspect-only open / `listen/addr` query op** — the plan carries +- **Inspect-only open / `listen/addr` query op**, where the plan + carries `local_addr`, or a small query op on the listen resource. Wire - addition (a query op is a new op, but `Query`-typed — cheap, no data - plane). -- **Out-of-band discovery** — the `OperationSpec.description` + addition (a query op is a new op, but `Query`-typed, cheap with + no data plane). +- **Out-of-band discovery**: the `OperationSpec.description` round-trips through discovery (OQ-TN-08); the producer's registry description carries the advertised address. Zero new wire; the cost is staleness (the description is install-time, the address may be OS-chosen) and per-session dynamic listeners have no description. -- **Same-process assembly wiring** — the POC's stand-in formalized: +- **Same-process assembly wiring**: the POC's stand-in formalized: the alksocks producer's accept factory holds the far side's `local_addr` directly when it constructed the listen resource - itself. Works only when the producer owns both halves in-process — - which is the common hub case (alknodes re-producers), but not the + itself. Works only when the producer owns both halves in-process, + which is the common hub case (alknodes re-producers) but not the general composition case. -**Ask #2 — peer metadata on accepted handles.** Impossible to carry +**Ask #2: peer metadata on accepted handles.** Impossible to carry in the open-op reply (post-reply#2 does not exist as wire); the option space is (i) `TargetHandle` gaining an optional `peer: -Option`-shaped metadata field (additive, no wire change — +Option`-shaped metadata field (additive, no wire change; the metadata rides the establishment phase, which is where the accept happens), or (ii) accept-as-open: the accepted connection opens a reverse channel whose open-op params carry the peer (§3.1). @@ -206,14 +210,14 @@ ssh's `-R` far-side listener opens `forwarded-tcpip` channels back toward the client, with the listen target + peer address riding the open params. alktunnels has the whole reverse path already (`open_reverse_channel`, `TunnelSession::adopt`, producer-allocated -channel IDs — consumer.rs). The shape: +channel IDs in consumer.rs). The shape: 1. The alksocks producer, on BIND, opens the listen-tunnel (or binds locally) and advertises reply#1 from whatever address source the carrier question resolves. 2. When the far-side listener accepts, the *tunnel producer* opens a reverse channel toward the alksocks producer with `{listen_target, - peer}` in params — peer fidelity for free. + peer}` in params, giving peer fidelity for free. 3. The alksocks producer correlates the reverse open to the waiting BIND session (a session-id ↔ quiet-wait-task registry), writes reply#2 with the real peer, and pumps. @@ -221,8 +225,8 @@ channel IDs — consumer.rs). The shape: Costs: a correlation registry on the SOCKS5 side; a direction flip in the composed flow (the accept-initiated open is toward the alksocks producer); the quiet wait must accept opens, not await a popped -handle. Benefits: both asks' fidelity with zero special carriers — -the metadata rides the open op, which is the wire's native +handle. Benefits: both asks' fidelity with zero special carriers, +since the metadata rides the open op, which is the wire's native "this connection is for X" moment. This is the wire-native answer and should be compared against the two-ask ledger rather than assuming the ledger's shapes. @@ -241,8 +245,8 @@ provider next to the `local`-socket one: | alksocks seam | local provider (`local` feature) | composed provider (alktunnels) | |---|---|---| | `DialFn` (CONNECT) | `dial_tcp`/`dial_unix` (`local`) | open `channels/tunnel/direct` (or the direct-op form chosen) toward the target | -| `EgressFactory` (ASSOCIATE) | one `UdpSocket` per association (POC #2's `LocalEgress`) | per-destination udp tunnel table (POC #2's `ComposedEgress` — the direct op dissolves its `resource_of` stand-in) | -| `BindAcceptFactory` (BIND) | real `TcpListener` per session (POC #5's local variant) | listen-tunnel session (POC #5's composed variant — the metadata carrier decides reply#1/#2 fidelity) | +| `EgressFactory` (ASSOCIATE) | one `UdpSocket` per association (POC #2's `LocalEgress`) | per-destination udp tunnel table (POC #2's `ComposedEgress`; the direct op dissolves its `resource_of` stand-in) | +| `BindAcceptFactory` (BIND) | real `TcpListener` per session (POC #5's local variant) | listen-tunnel session (POC #5's composed variant; the metadata carrier decides reply#1/#2 fidelity) | All six paths are individually POC-validated; nothing here is hypothesis-level except the metadata carriers (§3). @@ -251,29 +255,32 @@ hypothesis-level except the metadata carriers (§3). (alkcall ADR-042, alktunnels' refinement) forwards tunnel data byte-for-byte. One structural note for the ADR: a hub that re-produces the direct op applies its OWN target policy at the re-produce hop -(the hub's ACL, not the original opener's, governs the final dial) — -correct-by-default (the hub is the network boundary) but worth pinning +(the hub's ACL, not the original opener's, governs the final dial), +which is correct-by-default (the hub is the network boundary) but +worth pinning as the security model sentence. ## 5. The ask ledger, reconciled The one-version alktunnels bump, post-graduation: -1. **The direct op** (§2) — the wire addition itself: op spec, scope - constant, params schema, establisher shape (dial only — no +1. **The direct op** (§2), i.e. the wire addition itself: op spec, + scope + constant, params schema, establisher shape (dial only, no listen/direct-BIND form), `DialFn` reuse. -2. **Listen metadata** (§3) — bound-addr discovery + peer metadata, +2. **Listen metadata** (§3), meaning bound-addr discovery + peer + metadata, carriers per the ADR; OR the `forwarded-tcpip` shape as the combined alternative (§3.1). The POC ledger's two items map onto this item either way. -3. **(alksocks courtesy, fast-socks5)** — unchanged from POC #5's +3. **(alksocks courtesy, fast-socks5)**, unchanged from POC #5's ledger: publish `new_reply`; the `net` feature gate; `run_udp_proxy_custom`'s unconditional bind. Item 3 is independent; items 1–2 are the single alktunnels version bump alksocks composes against. -## 6. The vpn-like layer — where this ends up +## 6. The vpn-like layer (where this ends up) The graduation is the mechanical half of the vpn-like story; the addressing half rides on top: @@ -283,7 +290,8 @@ addressing half rides on top: translation: intercept `connect(10.x.y.z:p)`, match the subnet against a per-identity address table, open the direct op toward the owning node's resource (or the named resource the address maps - to). No wire change — DST.ADDR/BND.ADDR are arbitrary RFC fields; + to). No wire change, since DST.ADDR/BND.ADDR are arbitrary RFC + fields; the intercept point is the SOCKS5 front door (tun2proxy routes the host's traffic there, `--proxy socks5://...` natively). The POC #2 `resource_of` seam is the same translation at the datagram @@ -295,26 +303,27 @@ addressing half rides on top: prior-art check on tun2proxy's virtual-DNS/virtual-network support (alksocks phase-0 flagged this) when that layer is specced. - **alknodes (the planned assembly layer).** The hub/spoke shape the - user describes — spokes expose resources (postgres, redis, - containers), the hub re-produces them to privileged spokes, spokes - can be hubs themselves (russian-doll: an internal private network - with selective exposure) — is the terminate-and-re-produce story + user describes, where spokes expose resources (postgres, redis, + containers), the hub re-produces them to privileged spokes, and + spokes can be hubs themselves (russian-doll: an internal private + network + with selective exposure), is the terminate-and-re-produce story alktunnels' OQ-TN-12 deferred until a concrete consumer exists. alknodes is that consumer. What it needs from this graduation: the direct op (dynamic egress through the overlay), the listen surface (accept-side resources), and the metadata carriers (BIND fidelity through one or more hops). What it adds on its own layer: - resource directory, identity→subnet mapping, hub ACL policy — - all assembly, no new wire. + resource directory, identity→subnet mapping, hub ACL policy, all + assembly and no new wire. ## 7. Open items for Phase 1 / ADRs -- The direct op's wire shape (op id, scope string, params encoding) — +- The direct op's wire shape (op id, scope string, params encoding): one-way door; ADR before the first consumer. -- The listen-metadata carriers (or the `forwarded-tcpip` alternative) - — same gate. +- The listen-metadata carriers (or the `forwarded-tcpip` alternative): + the same gate. - Whether BIND ships in alksocks' base crate + default accept variant - (OQ-SK-08's remaining decision) — unchanged by this doc, but the + (OQ-SK-08's remaining decision); unchanged by this doc, but the carrier choice feeds it. - The scope-separation posture: `tunnel:direct` ≠ `tunnel:open` ≠ ingress scopes; pin the convention sentence in the ADR. diff --git a/docs/sdd_process.md b/docs/sdd_process.md index 46cd6ac..80a04b8 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, since upstream failures multiply downstream damage regardless of developer type (human or LLM). See the cost-benefit framework in taskgraph's framework docs. @@ -47,11 +47,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 @@ -134,16 +134,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 (~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 --- @@ -179,17 +179,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`, for context management and session history (via @alkdev/open-memory, when available) **Key Behaviors**: @@ -218,10 +218,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", ...})`, for reporting progress/blockers to coordinator +- `worktree({action: "current"})`, to verify worktree assignment - webSearch (documentation lookup) -- `memory` / `memory_compact` — context management (via @alkdev/open-memory, +- `memory` / `memory_compact`, for context management (via @alkdev/open-memory, when available) **Key Behaviors**: @@ -388,7 +388,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`, being 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. @@ -422,15 +422,15 @@ cost-benefit framework in taskgraph's framework docs for the reasoning. ### 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, and 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, as it means 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. | @@ -480,7 +480,7 @@ 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 + `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 @@ -490,7 +490,7 @@ When the unblocking condition arrives (a use case materializes, a crate is specced), the tracker task is marked `completed` and the OQ transitions from `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 @@ -509,7 +509,7 @@ 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 +plugin. It's a single tool with `{action, args}` dispatch, with no separate enable/toggle steps. Role is auto-detected from session state. ``` @@ -563,13 +563,13 @@ a worktree. When the open-memory plugin is available alongside open-coordinator, the coordinator gains: -- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent +- `memory({tool: "children", args: {sessionId: "..."}})`, to view sub-agent sessions spawned from the coordinator -- `memory({tool: "messages", args: {sessionId: "..."}})` — read a spawned +- `memory({tool: "messages", args: {sessionId: "..."}})`, to read a spawned session's conversation for debugging -- `memory({tool: "context"})` — check context window usage before long +- `memory({tool: "context"})`, to check context window usage before long monitoring sessions -- `memory_compact()` — proactively compact at natural breakpoints +- `memory_compact()`, to proactively compact at natural breakpoints Implementation agents can also use `memory({tool: "context"})` and `memory_compact()` to manage their context during long tasks. @@ -593,7 +593,7 @@ Once the hub is operational, coordination uses native operations: ``` State moves from in-process tracking to Postgres `mappings` table. The -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 @@ -660,8 +660,8 @@ Accepted | Proposed | Deprecated | Superseded (Links to related specs and ADRs) ``` -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. +Numbering starts at 001 within each project. ADRs are stable, so once +Accepted they don't revert. If superseded, mark the old one and create a new one. ### Open Questions Format @@ -676,7 +676,7 @@ they don't revert. If superseded, mark the old one and create a new one. - **Status**: open | resolved | deferred(scope) | partially resolved - **Priority**: high | medium | low - **Resolution**: (when resolved) -- **Blocked on**: (when deferred — concrete dependency that would unblock) +- **Blocked on**: (when deferred; concrete dependency that would unblock) - **Cross-references**: OQ-NN, ADR-NNN ``` diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index e11cbc4..0f9dacf 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -27,9 +27,9 @@ dependencies = [ [[package]] name = "alkcall" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b8ef0591beff0e3fe64b7277a67d8e7caeccf71c27538b4b7aced028d22ca44" +checksum = "690d747f42baf385dc22cd120f79d43cf1ea239bc426675e0e1e225b57fc031a" dependencies = [ "async-trait", "bytes", @@ -48,7 +48,7 @@ dependencies = [ [[package]] name = "alktunnels" -version = "0.4.0" +version = "0.4.1" dependencies = [ "alkcall", "bytes", diff --git a/fuzz/README.md b/fuzz/README.md index 5e1d9fe..1239e43 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -7,19 +7,19 @@ from alkcall's `docs/research/fuzzing.md`). ## Layout -- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers. -- `shared/` — the invariant logic, as a plain library so normal +- `fuzz_targets/`: one binary per target; thin `fuzz_target!` wrappers. +- `shared/`, the invariant logic, as a plain library so normal `cargo test` (stable toolchain) can replay the committed corpora through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]` modules; quinn's CI pattern). The fuzz binaries are nightly-only; the shared crate is stable-clean. -- `corpus//` — committed hand-made seeds (regenerate with +- `corpus//`: committed hand-made seeds (regenerate with `python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora and artifacts are gitignored. -- `run-detached.sh` — mandatory runner for agent sessions: wraps +- `run-detached.sh`, the mandatory runner for agent sessions: wraps `cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM in a target can never take down the agent host (fuzzing.md §5). -- `json.dict` — JSON token dictionary for the params target +- `json.dict`: a JSON token dictionary for the params target (transferred from alkcall). ## Targets @@ -28,13 +28,13 @@ from alkcall's `docs/research/fuzzing.md`). |---|---|---| | `datagram_codec` | `DatagramReader::feed` / `frame_datagram` (`src/wire.rs`) | no-panic; infallible decode; yielded datagrams ≤ `MAX_DATAGRAM_LEN`; F-2 sentinel layering (empty datagram ≠ EOF, only from `[00 00]` headers); splitting transparency (3-byte slices == whole-input feed); re-frame round-trip; oversize rejection exactly at the bound; `is_mid_datagram` tracks framing; decoder-state retained-bytes bound | | `params_parse` | `TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` / `SubstrateAddr` serde parses + `parse_listen_reply` / `listen_reply_fields` / `establishment_reason` (`src/params.rs`) | no-panic on raw bytes and structured Values; clean typed failure only; round-trip for valid params; `listen: 0` pin; loud/lenient projection-pair agreement; pinned establishment-reason table | -| `session_pumps` | op sequences (`SessionSequence`) over one live channels stack per exec (MuxRunner → real demux → `ChannelManager` per side; the `TunnelSession` data-plane mirrors — codec plane + `pump_bidi` stream planes) | no-panic / no-wedge (5 s reconcile bound); two-pump shutdown-on-completion; lossless p2c copy count; EOF-sentinel teardown semantics (drain-before-EOF, map empties); FIFO datagram reconciliation (framing-walk model); `pending` backlog ≤ routed-batch remainder (the §1.3 bound); decoder-state retained-bytes bound re-asserted per op; monotonic counters | +| `session_pumps` | op sequences (`SessionSequence`) over one live channels stack per exec (MuxRunner → real demux → `ChannelManager` per side; the `TunnelSession` data-plane mirrors, codec plane + `pump_bidi` stream planes) | no-panic / no-wedge (5 s reconcile bound); two-pump shutdown-on-completion; lossless p2c copy count; EOF-sentinel teardown semantics (drain-before-EOF, map empties); FIFO datagram reconciliation (framing-walk model); `pending` backlog ≤ routed-batch remainder (the §1.3 bound); decoder-state retained-bytes bound re-asserted per op; monotonic counters | ## Commands `cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/` (or deeper) as the working directory so rustup selects the pinned -nightly toolchain — the detached runner handles that itself. +nightly toolchain. The detached runner handles that itself. ```sh # build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/) diff --git a/fuzz/run-detached.sh b/fuzz/run-detached.sh index 043068e..c898976 100755 --- a/fuzz/run-detached.sh +++ b/fuzz/run-detached.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Detached fuzzing runner for agent sessions: the fuzz campaign never # runs as a foreground child of the session (OOM in a target must not -# take down the agent host — the opencode server shares the process +# take down the agent host (the opencode server shares the process # tree and cgroup), and survives the session ending. # # Usage: fuzz/run-detached.sh [extra libfuzzer args...] diff --git a/fuzz/shared/src/arbitrary_value.rs b/fuzz/shared/src/arbitrary_value.rs index 74632a4..846c1f4 100644 --- a/fuzz/shared/src/arbitrary_value.rs +++ b/fuzz/shared/src/arbitrary_value.rs @@ -1,4 +1,4 @@ -//! `Arbitrary` support for `serde_json::Value` — a bounded-depth +//! `Arbitrary` support for `serde_json::Value`: a bounded-depth //! recursive generator shared by the semantic targets. Depth and size //! bounds keep exec/s high (serde_json's own 128-depth recursion limit //! is the decode-side guard; this generator explores structure). diff --git a/fuzz/shared/src/datagram_codec.rs b/fuzz/shared/src/datagram_codec.rs index 4c3e417..59c205a 100644 --- a/fuzz/shared/src/datagram_codec.rs +++ b/fuzz/shared/src/datagram_codec.rs @@ -10,8 +10,8 @@ use alktunnels::wire::{frame_datagram, DatagramCodecError, DatagramReader, MAX_D /// accumulation stay bounded by the fed chunk size plus one frame /// (the header + a full u16 payload). The session-shape /// `DatagramHalves.pending` backlog between chunk reads and -/// `recv_datagram` pulls is a separate question (fuzzing.md §3.1) — -/// this asserts the decoder state itself never grows with input. +/// `recv_datagram` pulls is a separate question (fuzzing.md §3.1). +/// This asserts the decoder state itself never grows with input. fn assert_retained_bound(reader: &DatagramReader, fed: usize) { let retained = reader.retained_bytes(); let max = fed + MAX_DATAGRAM_LEN + 2; @@ -25,7 +25,7 @@ fn assert_retained_bound(reader: &DatagramReader, fed: usize) { pub fn fuzz_datagram_codec(data: &[u8]) { // The infallible decode direction: any chunk sequence feeds // cleanly. Drive the reader with the whole input and with awkward - // 3-byte slices — splitting transparency must hold for both. + // 3-byte slices; splitting transparency must hold for both. let mut whole = DatagramReader::new(); let whole_dgs = whole.feed(data); assert_retained_bound(&whole, data.len()); @@ -57,7 +57,8 @@ pub fn fuzz_datagram_codec(data: &[u8]) { } // F-2 sentinel layering: an empty datagram comes only from a real - // `[00 00]` length header **at a frame boundary** — never invented + // `[00 00]` length header **at a frame boundary**: it is never + // invented // by the codec, and never treated as stream end. The count walks // the framing (a [00 00] pair inside a payload region is payload, // not a header). @@ -139,7 +140,7 @@ fn count_empty_frames(data: &[u8]) -> usize { } /// Split the input into complete `[len: u16 BE][payload]` frames from -/// the start. Returns the frames and the byte length they occupy — +/// the start. Returns the frames and the byte length they occupy; /// the remainder (if any) is a partial/trailing region. fn split_frames(data: &[u8]) -> (Vec<&[u8]>, usize) { let mut frames = Vec::new(); @@ -160,7 +161,7 @@ fn ends_inside_frame(data: &[u8], well_framed_len: usize) -> bool { // Mid-datagram iff the well-framed prefix leaves a *parsed but // incomplete* frame: a remainder ≥ 2 bytes means the length header // was consumed and the payload is in flight (a remainder of 1 byte - // is a buffered header fragment — not yet a datagram in flight). + // is a buffered header fragment, not yet a datagram in flight). let remainder = data.len() - well_framed_len; remainder >= 2 } diff --git a/fuzz/shared/src/params_parse.rs b/fuzz/shared/src/params_parse.rs index ebc44c0..18b0c16 100644 --- a/fuzz/shared/src/params_parse.rs +++ b/fuzz/shared/src/params_parse.rs @@ -30,7 +30,7 @@ pub fn fuzz_params_parse(data: &[u8]) { } /// Run every params/reply parser against one JSON value. Each parser -/// must return cleanly (Result / typed projection) — a panic is the +/// must return cleanly (Result / typed projection); a panic is the /// finding. fn parse_shapes(value: &Value) { // Open-op params: wire-stable {resource, substrate}. @@ -100,8 +100,8 @@ where assert_eq!(&back, params); } -/// The typed establishment-reason surface: a pass-through extractor — -/// `Some(details.reason)` iff the code is `channel:open_failed` and +/// The typed establishment-reason surface: a pass-through extractor, +/// yielding `Some(details.reason)` iff the code is `channel:open_failed` and /// the reason is a string, `None` otherwise (any string is surfaced /// as-is; validation is the consumer's job). The harness must model /// extraction, not a validated table. diff --git a/fuzz/shared/src/session_pumps.rs b/fuzz/shared/src/session_pumps.rs index 228794b..a75e39d 100644 --- a/fuzz/shared/src/session_pumps.rs +++ b/fuzz/shared/src/session_pumps.rs @@ -1,18 +1,18 @@ //! Invariants for the stateful two-pump session shape (fuzzing.md -//! §3.3, wave 2): one real channels stack per sequence — MuxRunner → +//! §3.3, wave 2): one real channels stack per sequence (MuxRunner → //! raw chunk stream → `run_demux_loop_for_client` → ChannelManager -//! per side — under adversarial op sequences, with the two-pump +//! per side), under adversarial op sequences, with the two-pump //! shutdown-on-completion contract (AGENTS.md convention 8), the //! EOF-sentinel teardown semantics, and the `DatagramHalves.pending` //! backlog bound (§1.3) asserted by byte accounting. //! //! Topology (the real protocol shape, mirrored): the PRODUCER is the -//! connect side — `Open` allocates a session (producer +//! connect side: `Open` allocates a session (producer //! `open_channel` + a producer-side drainer, the pump handler's //! stand-in) and the CONSUMER (accept side) `Adopt`s the //! producer-allocated id (the early-arrival drain is the real //! adopt-race path). Sessions are addressed by INDEX into the -//! harness's session table (no id collisions, no masking — the +//! harness's session table (no id collisions, no masking, so the //! generator stays simple); ids are the manager's own allocation. //! //! Data-plane shapes mirror `TunnelSession`: datagram planes drive @@ -26,7 +26,7 @@ //! //! The framing model is STATEFUL per datagram session: a partial //! trailing frame persists in the model's walk offset (the decoder -//! buffers it; the next chunk completes it) — exactly the wave-1 +//! buffers it; the next chunk completes it), exactly the wave-1 //! framing walk, carried across ops. Sentinel layering (F-2) holds //! at the session layer: a routed empty datagram is DATA (the codec //! yields it); the channels EOF sentinel is a separate mechanism @@ -51,13 +51,13 @@ use alktunnels::wire::{DatagramReader, MAX_DATAGRAM_LEN}; const MAX_PAYLOAD: usize = 512; /// The op sequence the fuzzer generates. Channel addressing is by -/// SESSION INDEX (0..8) — the harness maps indices to the manager's +/// SESSION INDEX (0..8); the harness maps indices to the manager's /// allocated ids; the generator never names raw ids. #[derive(Debug, arbitrary::Arbitrary, Clone)] pub enum SessionOp { /// The producer opens a session (allocates the channel id, the /// producer-side drainer runs). `datagram` shapes the consumer - /// data plane (the params' substrate rode the open call — the + /// data plane (the params' substrate rode the open call; the /// harness re-states it here, the adopt's shape input). Open { datagram: bool }, /// Route one framed datagram (the `[len: u16 BE]` form, clamped @@ -68,14 +68,14 @@ pub enum SessionOp { /// against in-process peer halves for stream sessions). Adopt { session: u8 }, /// Push bytes into a stream session's peer half (the pump's p2c - /// source). `len == 0` drops the peer write half — the EOF path. + /// source). `len == 0` drops the peer write half (the EOF path). Pump { session: u8, len: u16, byte: u8 }, /// Pull one datagram off the session's backlog (the - /// `recv_datagram` pull — the consumer-driven drain). `None` + /// `recv_datagram` pull, the consumer-driven drain). `None` /// backlog = a no-op (the pull would block on the stream read; /// the harness models the batched-arrival case only). Pull { session: u8 }, - /// Tear the session's id down (both sides — the out-of-band + /// Tear the session's id down (both sides, the out-of-band /// `channel/close` shape: the serving side reaps; the consumer /// session reaps its own entry). Teardown { session: u8 }, @@ -88,7 +88,7 @@ pub struct SessionSequence { pub ops: Vec, } -/// A datagram session's consumer-side plane — the `DatagramHalves` +/// A datagram session's consumer-side plane: the `DatagramHalves` /// mirror (reader + pending queue), with counters. struct DatagramPlane { recv: MpscRecvStream, @@ -98,8 +98,8 @@ struct DatagramPlane { eof: bool, } -/// The framing model per datagram session — the wave-1 walk, carried -/// across ops (stateful): partial trailing frames persist. +/// The framing model per datagram session is the wave-1 walk, +/// carried across ops (stateful): partial trailing frames persist. #[derive(Default)] struct DgModel { /// Expected datagram payload lengths (FIFO), in rout order. @@ -126,8 +126,8 @@ impl DgModel { } } -/// A stream session's consumer-side plane — the `pump_against` -/// mirror with counters. +/// A stream session's consumer-side plane (the `pump_against` +/// mirror) with counters. struct StreamPlane { /// The pump's p2c source (the harness pushes Pump ops here); /// `None` once dropped (the EOF path). @@ -139,7 +139,7 @@ struct StreamPlane { /// reader task's counter). c2p_counter: Arc, /// The c2p far-end reader task (ends when the pump's write half - /// shuts down — the shutdown-on-completion observable). + /// shuts down, the shutdown-on-completion observable). reader: tokio::task::JoinHandle<()>, } @@ -148,7 +148,7 @@ struct StreamPlane { struct Session { channel_id: u32, /// The producer's channel write half (the mux-registered - /// sender — the pump handler's write side). + /// sender, the pump handler's write side). producer_send: Option, dg_model: Option, dg_plane: Option, @@ -265,10 +265,10 @@ impl SessionHarness { let channel_id = s.channel_id; let is_datagram = s.dg_model.is_some(); // A re-adopt of a LIVE session is the manager's - // documented id-collision outcome (ChannelExists) — + // documented id-collision outcome (ChannelExists): // a legal no-op, not a finding (the alkcall // duplicate-adopt harness lesson; the live entry is - // left intact — alkcall 0.8.1's fix). + // left intact, per alkcall 0.8.1's fix). if s.adopted { return Ok(()); } @@ -363,7 +363,7 @@ impl SessionHarness { SessionOp::ClearAll => { // clear_all empties the ENTIRE channel map; the // returned entries are the opener-ledger records - // (ADR-047 §7) — adopted channels carry no ledger + // (ADR-047 §7); adopted channels carry no ledger // entry (the remote side opened them), so the // return value is NOT the reaped-id set (the // alkcall §7.7 harness lesson, learned again). The @@ -490,7 +490,7 @@ impl SessionHarness { let Some(plane) = s.dg_plane.as_ref() else { unreachable!("drained above"); }; - // The FIFO expectation: the session consumes in order — + // The FIFO expectation: the session consumes in order; // reconcile the pending queue head-by-head against the // model's expected tail. The model's `expected` holds // the routed-but-not-pulled datagrams (Pull pops both @@ -500,7 +500,7 @@ impl SessionHarness { // check; equal lengths are the lossless-delivery check. if plane.pending.len() != model.expected.len() { return Err(format!( - "session {index}: backlog {} != expected {} — routed datagrams \ + "session {index}: backlog {} != expected {}; routed datagrams \ must be either pulled or pending, never lost", plane.pending.len(), model.expected.len() @@ -516,7 +516,7 @@ impl SessionHarness { } // The session-shape bound (the §1.3 assertion): the // backlog holds exactly the routed batch beyond the - // pulled datagrams — with the per-op drain the queue + // pulled datagrams: with the per-op drain the queue // never exceeds one chunk's worth of batched datagrams, // and every routed datagram is eventually pullable. The // amplification question is thus bounded: the backlog @@ -554,7 +554,7 @@ impl SessionHarness { }) .expect("pump task"); // The p2c count equals what the harness pushed - // (lossless two-pump copy — the copy never truncates). + // (lossless two-pump copy; the copy never truncates). assert_eq!( p2c, plane.p2c_written, "session {index}: p2c copy count {p2c} != pushed {}", @@ -603,7 +603,7 @@ pub fn fuzz_session_pumps(seq: &SessionSequence) { let retained = plane.reader.retained_bytes(); assert!( retained <= MAX_DATAGRAM_LEN + 2, - "session {index}: decoder retained {retained} bytes — \ + "session {index}: decoder retained {retained} bytes; \ the state must stay bounded by one frame" ); } @@ -621,7 +621,7 @@ pub fn fuzz_session_pumps_arbitrary(data: &[u8]) { match ::arbitrary_take_rest(u) { Ok(seq) => fuzz_session_pumps(&seq), Err(_) => { - // A sequence that does not decode is not a finding — the + // A sequence that does not decode is not a finding. The // generator's op space is structured; malformed byte // streams are the corpus's job, not the harness's. } @@ -652,7 +652,7 @@ mod corpus_replay { ); } - /// Semantic fixture: the §1.3 amplification probe — open, adopt + /// Semantic fixture: the §1.3 amplification probe: open, adopt /// a datagram session, route batched frames (3-byte, empty, /// 1-byte) through the real demux; the backlog must reconcile /// against the FIFO expectation exactly (F-2: the empty @@ -685,7 +685,7 @@ mod corpus_replay { } /// Semantic fixture: the half-close shape (the W4 probe at the - /// session layer) — open a stream session, adopt, pump bytes, + /// session layer): open a stream session, adopt, pump bytes, /// drop the p2c source (EOF), then teardown: the pump must /// complete and the pushed bytes must reconcile (the two-pump /// contract). diff --git a/tasks/architecture/oq-promotion-sync.md b/tasks/architecture/oq-promotion-sync.md index 85ea090..20a7312 100644 --- a/tasks/architecture/oq-promotion-sync.md +++ b/tasks/architecture/oq-promotion-sync.md @@ -16,26 +16,26 @@ Phase 1 promoted the Phase 0 OQ ledger into `docs/architecture/open-questions.md (OQ-TN-01..14) and split OQ-TN-11/13 into resolved halves (ADR-001/ADR-003). The older ledgers still describe pre-Phase-1 states in two places: -1. `docs/research/phase-0-findings.md` — the OQ sections (OQ-TN-01..10) should +1. `docs/research/phase-0-findings.md`: the OQ sections (OQ-TN-01..10) should carry one-line "promoted to `docs/architecture/open-questions.md` as OQ-TN-NN" pointers so a reader landing in the research doc finds the live tracker (the - convergence checklist's last unchecked item says "promoted ... with statuses" — - the promotion exists; the back-pointer is the missing half). -2. `AGENTS.md` — the "Architecture Context" section says `docs/architecture/` + convergence checklist's last unchecked item says "promoted ... with statuses"; + the promotion exists, and the back-pointer is the missing half). +2. `AGENTS.md`: the "Architecture Context" section says `docs/architecture/` "will hold the authoritative spec (Phase 1 of the SDD process)... It does not exist yet". That is now stale: the spec exists (ADR-001..006 + 4 spec docs + BAST + OQ tracker). Update the paragraph to point at the real directory and adjust the "read it before non-trivial changes once it exists" line. Three adjacent stale references in the same file fold in (same staleness class, - one pass — found during task review 2026-09-08): + one pass; found during task review 2026-09-08): - Convention 16 (module structure): "expected shape (to be finalized in - the architecture docs)" — the docs now exist and pin the module shape; + the architecture docs)"; the docs now exist and pin the module shape; state it as decided (producer half / consumer half / shared wire). - - Convention 11: "alkcall is v0.5.x" — Cargo.toml pins `0.7.0` and the + - Convention 11: "alkcall is v0.5.x". Cargo.toml pins `0.7.0`, and the convention's own later text describes the 0.6.0/0.7.0 surfaces; the version sentence is self-contradicting. - - Architecture Context alkcall bullet: "alkcall (v0.4.x, crates.io)" — - same version drift. + - Architecture Context alkcall bullet: "alkcall (v0.4.x, crates.io)", + the same version drift. ## Work @@ -73,7 +73,7 @@ The older ledgers still describe pre-Phase-1 states in two places: ## Notes - Executed together with `architecture/oq-tn-12-tracker` creation - (process-gap fix found during task review — OQ-TN-12's Blocked-on text + (process-gap fix found during task review; OQ-TN-12's Blocked-on text now references its tracker task id per `sdd_process.md` §Deferred OQs and the blocker-task half). - While rewriting the Architecture Context bullet, a fourth same-class diff --git a/tasks/architecture/oq-tn-12-tracker.md b/tasks/architecture/oq-tn-12-tracker.md index 700a5fc..d456013 100644 --- a/tasks/architecture/oq-tn-12-tracker.md +++ b/tasks/architecture/oq-tn-12-tracker.md @@ -13,7 +13,7 @@ tags: [architecture, external-trigger, deferred-oq, hub, producer, consumer] ## Description OQ-TN-12 is `deferred(scope)` (open-questions.md): the hub re-produce -composition helper — whether a crate that re-produces a tunnel resource it +composition helper: whether a crate that re-produces a tunnel resource it consumes (a hub proxy: consumer + `register_openable` on the same connection) needs a composition helper from this crate, or assembles from the public producer/consumer surface as-is. ADR-004 resolved the substrate @@ -25,7 +25,8 @@ This task is the machine-readable half of the deferral (per whether the unblocking condition has arrived. It is not implementation work and blocks nothing. -The unblocking condition: a concrete hub-proxy consumer — a crate that +The unblocking condition: a concrete hub-proxy consumer, i.e. a +crate that re-produces a consumed tunnel resource (the reverse POC's hub is the seed shape minus re-export: consumer + `register_openable` on the same connection, without exercising re-export). @@ -39,7 +40,7 @@ connection, without exercising re-export). ## Verification - [ ] Tracked (this file existing and being reviewed at triage time IS - the tracking mechanism — no code or doc change is expected unless + the tracking mechanism; no code or doc change is expected unless the unblocking condition above arrives) ## Out of scope diff --git a/tasks/architecture/oq-tn-14-tracker.md b/tasks/architecture/oq-tn-14-tracker.md index 8fee95e..053f6bc 100644 --- a/tasks/architecture/oq-tn-14-tracker.md +++ b/tasks/architecture/oq-tn-14-tracker.md @@ -14,21 +14,21 @@ tags: [architecture, external-trigger, unix, stdio, alktty] OQ-TN-14 is resolved (open-questions.md, 2026-09-07): unix ships with the `local` feature v1 (`tunnels/local-socket-halves`); stdio is OUT of -scope — a spawned process's stdio is alktty's pipe mode (exit codes + +scope. A spawned process's stdio is alktty's pipe mode (exit codes + signals are tty semantics, not tunnel semantics); remote command execution composes via alktty. This task stays open as the boundary-maintenance tracker (research -level — not implementation work). It re-opens the question only if a +level, not implementation work). It re-opens the question only if a concrete consumer demonstrates the resolved posture is wrong: 1. A consumer needs stdio tunneling with NO process semantics (no exit - code, no signals) — e.g. piping an already-running long-lived + code, no signals), e.g. piping an already-running long-lived process's existing stdio. That is a different resource shape (the pipe IS the resource, no dial); it would need its own ADR, not a silent addition to `local`. 2. A consumer reports confusion about when to use alktunnels vs alktty - for a stdio-like flow — the composition boundary in OQ-TN-14 needs + for a stdio-like flow; the composition boundary in OQ-TN-14 needs sharpening in the docs, not code. If neither arrives, this task closes with the implementation phase. @@ -42,7 +42,7 @@ If neither arrives, this task closes with the implementation phase. ## Verification - [ ] Tracked (this file existing and being reviewed at triage time IS - the tracking mechanism — no code or doc change is expected unless + the tracking mechanism; no code or doc change is expected unless a boundary condition above arrives) ## Out of scope diff --git a/tasks/tunnels/bindfirst-params.md b/tasks/tunnels/bindfirst-params.md index cb4f03f..dfd6e48 100644 --- a/tasks/tunnels/bindfirst-params.md +++ b/tasks/tunnels/bindfirst-params.md @@ -1,6 +1,6 @@ --- id: tunnels/bindfirst-params -name: Bind-first params — ListenParams, the forwarded-op spec, the bind-first plan type (ADR-008 as amended) +name: Bind-first params; ListenParams, the forwarded-op spec, the bind-first plan type (ADR-008 as amended) status: completed depends_on: [] scope: narrow @@ -13,14 +13,14 @@ tags: [wire, params, bind-first, forwarded-op, correlation-token] ## Description Implement the bind-first wire surface's params half (ADR-008, as -amended 2026-09-18 — Amendment 1): the listen-open reply fields +amended 2026-09-18, Amendment 1): the listen-open reply fields (`bound` + `listen`), the forwarded-op params + spec, and the bind-first plan payload type. Split from the establisher/pump task (`tunnels/bindfirst-producer`) so the wire-stable types land and get -pinned first — the same ordering `tunnels/params` used for the base +pinned first; the same ordering `tunnels/params` used for the base op. -### params.rs — constants + types +### params.rs: constants + types ```rust pub const OP_TUNNEL_FORWARDED: &str = "channels/tunnel/forwarded"; @@ -50,28 +50,28 @@ pub struct TunnelForwardedParams { pub fn tunnel_forwarded_spec() -> OperationSpec ``` -- **The correlation token (Amendment 1):** `listen` (u32) — minted by - the bind-first establisher, returned in the reply as the `listen` - field, stamped into each forwarded open's `listen` param. NOT the - listen channel's ID (the pre-amendment key is superseded — the +- **The correlation token (Amendment 1):** `listen` (u32), minted by + the bind-first establisher and returned in the reply as the + `listen` field, stamped into each forwarded open's `listen` param. + NOT the listen channel's ID (the pre-amendment key is superseded: the serving-side handler never sees its own channel ID, and generic relays don't rewrite params; ADR-008 Amendment 1 has the full - rationale). `SubstrateAddr` is defined by `tunnels/direct-op` — + rationale). `SubstrateAddr` is defined by `tunnels/direct-op`, the same value shape (`{host, port}` / `{path}`); if that task has not landed, define it here and let `tunnels/direct-op` consume it (either order works; the type lands once). - `tunnel_forwarded_spec()` per wire.md §The Forwarded Op: `Sub`-typed, `Visibility::External`, `ChannelOpenSpec::new(TUNNEL_ALPN)`, - scope `["tunnel:forwarded"]` (ADR-006 Amendment 1 — ingress, a + scope `["tunnel:forwarded"]` (ADR-006 Amendment 1: ingress, a separate grant), input schema pinning all three fields required (`listen` integer > 0; `peer` an object with the per-substrate shape), output schema `{channel_id: integer > 0}`, description - carried. **No establisher** on the forwarded op — the data plane - starts immediately (ADR-008 §3). -- `TunnelListenReply` is the consumer-side reader (the wire itself is - plain JSON reply fields — additive, unknown fields ignored by old - consumers; the struct is the typed view, not a deny-unknown wire - gate on the reply — NOTE: the reply arrives as + carried. **No establisher** on the forwarded op, because the data + plane starts immediately (ADR-008 §3). +- `TunnelListenReply` is the consumer-side reader (the wire itself + is plain JSON reply fields: additive, with unknown fields ignored + by old consumers; the struct is the typed view, not a deny-unknown + wire gate on the reply). NOTE: the reply arrives as `{channel_id, bound?, listen?}`; parse it with a dedicated reply-viewing function that extracts the optional fields from the full output Value, not with `deny_unknown_fields` deserialization @@ -84,29 +84,30 @@ pub fn tunnel_forwarded_spec() -> OperationSpec /// The bind-first establisher's plan payload: the listener handle /// (typed-opaque to the pump via downcast) + the correlation token /// the accept dispatcher stamps into forwarded opens (ADR-008 -/// Amendment 1 — the handler cannot learn its own channel ID, so the +/// Amendment 1: the handler cannot learn its own channel ID, so the /// establisher-minted token is the correlation key). pub struct BindFirstPlan { - pub listener: TargetHandle, // the listener's halves? — NO: see the shape note + pub listener: TargetHandle, // the listener's halves? NO; see the shape note pub token: u32, } ``` - **Shape note (decide during implementation, document the choice):** - the listener is NOT `TargetHandle` halves — a listener is accepted - FROM, not pumped. The plan carries whatever the assembly's `BindFn` - produced (opaque to the protocol), plus the token. Honest shape: - `pub struct BindFirstPlan { pub listener: Arc, pub token: u32 }` - — the accept dispatcher downcasts the `Any` to the assembly's + the listener is NOT `TargetHandle` halves, since a listener is + accepted FROM, not pumped. The plan carries whatever the assembly's + `BindFn` produced (opaque to the protocol), plus the token. Honest + shape: + `pub struct BindFirstPlan { pub listener: Arc, pub token: u32 }`. + The accept dispatcher downcasts the `Any` to the assembly's listener type via a type-erased accept closure (the same injection pattern as `DialFn`/`AcceptFn`). The ADR's "listener handle" in the plan is assembly-typed; the protocol sees `BindFirstPlan { inner, token }`. - The token mint: `std::sync::atomic::AtomicU32` counter seeded non-zero (e.g. 1) inside the establisher closure state; wraps are - acceptable (u32, per-producer uniqueness is all that's required — - ADR-008 Amendment 1's token-space note; a wrap collision requires - 2^32 concurrent opens on one producer). + acceptable (u32; per-producer uniqueness is all that's required, + per ADR-008 Amendment 1's token-space note; a wrap collision + requires 2^32 concurrent opens on one producer). - Wire-stability note for the implementer: `listen`'s u32 type and field name, the forwarded params field names, and the op id are one-way doors (ADR-008 Consequences). `deny_unknown_fields` on @@ -120,7 +121,7 @@ pub struct BindFirstPlan { - `TunnelListenReply` projected parse: full reply `{channel_id: 7, bound: {...}, listen: 918}` → both fields; bare `{channel_id: 7}` → both `None` (accept-first/dial shape); unknown - extra reply fields tolerated (the additive posture — the reply + extra reply fields tolerated (the additive posture; the reply viewer must not deny unknown fields). - Spec-shape conformance: op id, Sub type, scope, schema structure (all three required; peer object), ALPN marker, description. @@ -141,45 +142,46 @@ pub struct BindFirstPlan { ## References - docs/architecture/decisions/008-listen-metadata-forwarded-op.md - (§2 as amended — the `listen` reply field; §3 as amended — the - forwarded params; Amendment 1 — the token rationale) + (§2 as amended, the `listen` reply field; §3 as amended, the + forwarded params; Amendment 1, the token rationale) - docs/architecture/wire.md §The Listen Open Op's Reply + §The Forwarded Op - tasks/tunnels/params.md (the ordering precedent: wire types first) -- tasks/tunnels/direct-op.md (`SubstrateAddr` — shared type) +- tasks/tunnels/direct-op.md (`SubstrateAddr`, the shared type) ## Notes -- `SubstrateAddr` had already landed (via `tunnels/direct-op`) — - consumed, not redefined (the "either order works" branch resolved - to the consuming side). +- `SubstrateAddr` had already landed (via `tunnels/direct-op`), so + it is consumed, not redefined (the "either order works" branch + resolved to the consuming side). - The reply viewer is `parse_listen_reply(&Value) -> Result`: parses the FULL success output (the `open_channel_with_reply` shape), tolerates unknown - extra reply fields (the additive posture — no deny-unknown gate on - the reply), requires a positive `channel_id` (its absence/zero = - not an open reply — loud), and fails loud on malformed `bound` - (not substrate-shaped) or `listen` (not a positive u32) via the - new `TunnelError::MalformedReply` variant (error.rs). `null` - fields parse as `None` (defensive; a well-formed reply omits them). + extra reply fields (the additive posture: no deny-unknown gate on + the reply), requires a positive `channel_id` (its absence/zero is + not an open reply and fails loud), and fails loud on malformed + `bound` (not substrate-shaped) or `listen` (not a positive u32) + via the new `TunnelError::MalformedReply` variant (error.rs). + `null` fields parse as `None` (defensive; a well-formed reply + omits them). - `TunnelForwardedParams.listen` carries a serde-level `positive_u32` pin behind the schema's `minimum: 1` (the task's - "listen: 0 rejected (schema minimum 1 + serde-level pin)" — both + "listen: 0 rejected (schema minimum 1 + serde-level pin)": both gates, test-pinned). - The `peer` sub-schema follows the direct op's if/then precedent (tcp/udp → `{host, port}` with `additionalProperties: false` + `maximum: 65535`; unix → `{path}`), structural assertions per the no-jsonschema-dep precedent. The untagged-peer lenient-extra-fields asymmetry carries over from the direct op (schema = the shape gate; - serde = the backstop) — documented in the test. + serde = the backstop), documented in the test. - `BindFirstPlan` lives in `producer.rs` (the plan-payload family, next to `TargetHandle`): `{ listener: Arc, - token: u32 }` per the honest shape — the listener is NOT + token: u32 }` per the honest shape, with the listener NOT `TargetHandle` halves (a listener is accepted FROM, not pumped); the accept dispatcher downcasts via a type-erased accept closure. The token-mint discipline (AtomicU32 seeded non-zero, per-open fresh, per-producer unique, wraps acceptable) is pinned in the - type's doc — the establisher that implements it lands with + type's doc; the establisher that implements it lands with `tunnels/bindfirst-producer`. - Custom `Debug` impls (matching `TargetHandle`'s precedent): the listener renders ``, the token renders plainly. @@ -194,17 +196,18 @@ pub struct BindFirstPlan { ## Summary Implemented the bind-first wire surface's params half per ADR-008 as -amended: `params.rs` — `OP_TUNNEL_FORWARDED` / `TUNNEL_FORWARDED_ -SCOPE` constants; `TunnelForwardedParams {listen, substrate, peer}` +amended: `params.rs` gained the `OP_TUNNEL_FORWARDED` / +`TUNNEL_FORWARDED_SCOPE` constants; `TunnelForwardedParams {listen, +substrate, peer}` (deny-unknown, `listen` positive via schema `minimum: 1` + the serde pin); `tunnel_forwarded_spec()` (`Sub`-typed, External, `ChannelOpenSpec::new(TUNNEL_ALPN)`, scope `["tunnel:forwarded"]`, per-substrate if/then `peer` sub-schema, output `{channel_id: integer > 0}`, description, no establisher); `TunnelListenReply` + `parse_listen_reply` (the projected reply -viewer — unknown extra fields tolerated, `channel_id` presence +viewer: unknown extra fields tolerated, `channel_id` presence required, malformed fields fail loud via `TunnelError:: -MalformedReply`). `producer.rs` — `BindFirstPlan {listener: +MalformedReply`). `producer.rs` defines `BindFirstPlan {listener: Arc, token: u32}` with the mint discipline pinned in docs (non-zero-seeded AtomicU32, per-open fresh, per-producer unique, wraps acceptable). Re-exported through @@ -214,5 +217,5 @@ types land with `tunnels/bindfirst-producer`. Verified: `cargo test` green (105 default; 125 with `--all- features`), clippy `--all-targets -D warnings` clean (native + wasm32), fmt clean, `wasm32-unknown-unknown` check passes (pure -protocol types — the default crate stays wasm-clean), `cargo doc +protocol types, so the default crate stays wasm-clean), `cargo doc --no-deps` clean. CHANGELOG updated (Unreleased → Added). \ No newline at end of file diff --git a/tasks/tunnels/bindfirst-producer.md b/tasks/tunnels/bindfirst-producer.md index 15d5619..38930fe 100644 --- a/tasks/tunnels/bindfirst-producer.md +++ b/tasks/tunnels/bindfirst-producer.md @@ -13,16 +13,16 @@ tags: [producer, establisher, bind-first, accept-dispatcher, forwarded-op] ## Description Implement the bind-first establisher (producer.md §The Establisher, -shape 3 — ADR-008 §1): the establisher binds (via the injected +shape 3; ADR-008 §1): the establisher binds (via the injected `BindFn`), mints the correlation token, contributes `bound` + `listen` to the reply, and returns the plan; the pump handler is the **accept -dispatcher** — it accepts the channel's `BiStream`, runs the +dispatcher**: it accepts the channel's `BiStream`, runs the assembly-provided accept loop, and per accept opens `channels/tunnel/forwarded` back toward the listen opener and pumps the accepted halves against the adopted channel. Plus `register_tunnel_bindfirst_openable` wiring it. -### The establisher (shape 3 — bind-first) +### The establisher (shape 3, bind-first) ```rust pub type BindFn = Arc< @@ -45,7 +45,7 @@ pub fn bindfirst_establisher( ``` - Flow: parse `TunnelParams` → registry namespace gate - (`unknown_resource` on miss — same posture as + (`unknown_resource` on miss, the same posture as `listen_establisher`) → mint the correlation token (per-open-fresh, per-producer-unique; `tunnels/bindfirst-params`) → `bind(substrate, backing)` → contribute reply fields @@ -68,51 +68,51 @@ pub fn make_bindfirst_accept_handler( ) -> OpenHandler ``` -- **Injection seams (the design pin — decide the exact closure +- **Injection seams (the design pin: decide the exact closure signatures during implementation, document in Notes):** the dispatcher needs two assembly-provided capabilities the protocol cannot own: - 1. **The accept loop** — pops accepted connections from the + 1. **The accept loop**, which pops accepted connections from the `BindFirstPlan`'s opaque listener and yields `(SubstrateAddr /* peer */, TargetHandle /* accepted halves */)`. Substrate code lives behind the closure (ADR-004). - 2. **The forwarded-open caller** — opens `OP_TUNNEL_FORWARDED` + 2. **The forwarded-open caller**: opens `OP_TUNNEL_FORWARDED` with `TunnelForwardedParams { listen: token, substrate, peer }` on the channel-0 call plane toward the listen opener, adopts the opener-allocated ID (`adopt_channel` on the producer-side manager), and returns the streams. **This needs the call plane**: the per-channel `Connection` the handler receives is - stream-less (`open_bi` is `StreamClosed` — ADR-035) and carries + stream-less (`open_bi` is `StreamClosed`; ADR-035) and carries no call surface, so the registration must close over the serving session's `Arc` (the reverse POC's `open_reverse_channel` shape: `call_with_payload` on channel 0 → extract `channel_id` from the reply → `manager.adopt_channel`). The registration function takes the call-plane handle (the - assembly has it — it constructed the serving session); document + assembly has it: it constructed the serving session); document the wiring. - Handler contract (producer.md §The Pump Handler item 5 + R-02): - `conn.accept_bi()` FIRST (the yield-once contract — the wrapper's + `conn.accept_bi()` FIRST (the yield-once contract; the wrapper's accepted-flag telemetry requires it), then run the accept loop concurrently with holding the channel's stream. The listen channel's own `BiStream` stays idle (no pumps on it); **its EOF - cancels the loop and tears the listener down** — `select!` on the + cancels the loop and tears the listener down** via `select!` on the idle stream's EOF vs the accept loop (read from the stream and observe EOF; the zero-byte sentinel is the opener dropping the listener session). Per-accept data planes are `pump_bidi` - (convention 8 — never a hand-rolled loop over one stream): each + (convention 8, never a hand-rolled loop over one stream): each accepted handle pumps against its adopted forwarded channel inside a spawned task tracked by the handler's JoinHandle set. - **Lifetime (R-02):** the handler's returned JoinHandle tracks the whole accept loop + every per-accept pump it spawned (collect the - pump handles; the handler task awaits them on loop exit — a + pump handles; the handler task awaits them on loop exit, since a forwarded pump outliving its listener is a leak). Handler exit → wrapper teardown (the listen channel reaps) → the far listener tears down (the `BindFn`'s listener drops with the plan/loop). - Error posture per accept: a forwarded-open failure (opener without the scope → FORBIDDEN; old opener → NOT_FOUND; correlation miss → - handler_error-class) logs and drops that accepted connection — the - loop continues; the listener's overall health is not hostage to one - failed accept. (Decide: retry/backoff — v1 says no; document.) + handler_error-class) logs and drops that accepted connection; the + loop continues, and the listener's overall health is not hostage to + one failed accept. (Decide: retry/backoff. v1 says no; document.) ### Registration @@ -131,16 +131,16 @@ pub fn register_tunnel_bindfirst_openable( ``` - Same spec (`tunnel_open_spec()`) + scope gate (`tunnel:open`) as the - dial/listen variants — the bind-first shape is a per-registration + dial/listen variants; the bind-first shape is a per-registration establisher choice over the same op (ADR-008 §5's per-registration - posture). One establisher per op id per registry — same note as - `register_tunnel_listen_openable`. + posture). One establisher per op id per registry (same note as + `register_tunnel_listen_openable`). - The forwarded-op SERVING side (opener side) is a separate - registration shape (`register_openable` — no establisher) and lands + registration shape (`register_openable`, with no establisher) and lands with `tunnels/forwarded-receipt`; this task's tests still need an opener-side stub (see Tests). -### Tests (integration — the duplex harness, listen-topology extension) +### Tests (integration: the duplex harness, listen-topology extension) - **The full bind-first flow e2e:** opener opens `channels/tunnel/sub` with the bind-first producer registered → @@ -177,7 +177,7 @@ pub fn register_tunnel_bindfirst_openable( idle-stream EOF cancels the loop + tears the listener down, per-accept `pump_bidi` tasks tracked by the handler JoinHandle - [x] The forwarded-open caller rides the call plane - (`call_with_payload` + adopt — the reverse POC's shape); the + (`call_with_payload` + adopt, the reverse POC's shape); the registration wiring documents where the call plane comes from - [x] `register_tunnel_bindfirst_openable` wires spec + establisher + dispatcher + injections @@ -192,7 +192,7 @@ pub fn register_tunnel_bindfirst_openable( - docs/architecture/decisions/008-listen-metadata-forwarded-op.md (§1 shape 3, §2 the reply fields, §3 the forwarded op, Amendment 1 - — the token; §6 the hub policy sentence) + on the token; §6 the hub policy sentence) - docs/architecture/producer.md §The Establisher (shape 3) + §The Pump Handler (item 5) + §The Forwarded Op - tasks/tunnels/bindfirst-params.md (the types this consumes) @@ -200,8 +200,8 @@ pub fn register_tunnel_bindfirst_openable( harness + registration shapes this extends) - alkcall surface: `Establishment::with_reply_field` (ADR-049 amendment 3, landed 0.8.0), the `OpenHandler` signature (no channel - ID — Amendment 1's premise), `ChannelRelay` reply-field pass-through - (ADR-051 — the relay-transparency property the tests should pin) + ID; Amendment 1's premise), `ChannelRelay` reply-field pass-through + (ADR-051, the relay-transparency property the tests should pin) ## Notes @@ -209,27 +209,27 @@ pub fn register_tunnel_bindfirst_openable( `BindFirstAcceptLoop` / `ForwardedOpener` follow the type-erased closure precedent: - `BindFn = Arc BoxFuture<'static, - Result> + Send + Sync>` — + Result> + Send + Sync>`, same `(substrate, backing)` form as `DialFn` (the registry backing string rides the same signature; the bind closure stays substrate-agnostic from the protocol's view). - `BindFirstAcceptLoop = Arc) -> BoxFuture<'static, Result<(SubstrateAddr, TargetHandle), - TunnelEstablishError>> + Send + Sync>` — the closure receives the - plan's OPAQUE listener (`Arc`, no + TunnelEstablishError>> + Send + Sync>`, and the closure receives + the plan's OPAQUE listener (`Arc`, no downcast in the protocol: the assembly downcasts to ITS concrete type inside the closure; the same injection pattern as `DialFn`/`AcceptFn`). A returned `Err` is LOOP-CRITICAL (the - substrate accept source broke) — the dispatcher ends the loop; + substrate accept source broke), so the dispatcher ends the loop; per-accept forwarded-open failures never come from this closure. - `ForwardedOpener = Arc BoxFuture<'static, Result<(MpscSendStream, MpscRecvStream), - TunnelEstablishError>> + Send + Sync>` — closes over the serving - session's `Arc` + the producer-side + TunnelEstablishError>> + Send + Sync>`, which closes over the + serving session's `Arc` + the producer-side `ChannelManager` (the reverse POC's `open_reverse_channel` shape: `call_with_payload` on channel 0 → extract `channel_id` → `manager.adopt_channel`). **Where the call plane comes from:** the - assembly layer holds it — it constructed the serving session + assembly layer holds it, since it constructed the serving session (the harness's `wire_bindfirst` shows the wiring: the `ForwardedOpener` closure closes over the `Arc` and calls `call_open_op` + `adopt_channel`; in production the @@ -237,42 +237,42 @@ pub fn register_tunnel_bindfirst_openable( - **Registration signature deviation (documented):** the task sketch had 8 parameters (clippy's `too_many_arguments`); the injected seams bundle into `BindFirstInjections {bind, accept_loop, - open_forwarded}` — one coherent injection point passed beside the + open_forwarded}`, one coherent injection point passed beside the core pair + the optional witness. Same posture as `DialFn` riding the dial registration. The `identity_witness` seam is wired via a small `witness_establisher` wrapper (records the per-call opener - identity, then delegates — the same CF-006 seam the dial/direct + identity, then delegates; the same CF-006 seam the dial/direct variants inline). - **The token mint** (`mint_token`, private): `AtomicU32` counter seeded at 1 inside the establisher's closure state; `fetch_add` - returns the PREVIOUS value, so the minted stream is 1, 2, 3, … — + returns the PREVIOUS value, so the minted stream is 1, 2, 3, …; token 0 (an illegal wire value) is never minted, and a u32 wrap that lands on 0 re-increments (the loop skips it). Per-open fresh, per-producer unique; unit-pinned including the wrap shape. - **The idle-stream EOF watch:** a 1-byte `read` on the split idle - half inside a `select!` against the accept loop — `Ok(0)` (the + half inside a `select!` against the accept loop: `Ok(0)` (the zero-byte EOF sentinel), a read error, or (defensively) any non-empty read on an idle stream all cancel the loop. The listen channel's stream is never pumped (no `pump_bidi` on it). - **Per-accept failure posture (v1, documented):** a forwarded-open failure (scope-less opener → FORBIDDEN; old opener → NOT_FOUND; correlation miss → handler_error-class) logs a warning and drops - that accepted connection — the loop continues; NO retry/backoff + that accepted connection; the loop continues, with NO retry/backoff (v1 posture per the task). - **Lifetime (R-02):** the handler collects every per-accept `pump_bidi` `JoinHandle` in a `Vec` and awaits them all on loop - exit (before the handler task returns) — a forwarded pump never + exit (before the handler task returns); a forwarded pump never outlives its listener. Handler exit → wrapper teardown → the listen channel reaps → the plan's listener drops with the loop. - **Harness changes (test infrastructure):** `wire_bindfirst` (RegistrationMode::BindFirst); the consumer's install hook gained a shared serving registry + the accept-side identity propagation - (the transport identity attaches to channel 0 — the same posture + (the transport identity attaches to channel 0, the same posture `wire_forward` models; needed because the forwarded opens arrive ON the consumer's serving path); `Topology.consumer_registry` exposed; the forwarded-op serving stub (`register_forwarded_stub_on_consumer` / - `make_forwarded_stub_handler` — correlate by `listen` token → + `make_forwarded_stub_handler`: correlate by `listen` token → accept the `BiStream` → `pump_bidi` against the correlated halves; the opener-side registration shape lands for real with `tunnels/forwarded-receipt`). @@ -281,9 +281,9 @@ pub fn register_tunnel_bindfirst_openable( of a raw duplex; the stub's pump bridges forwarded channel ↔ those halves; the accepted side's echo task closes the loop). The forwarded channel on the opener side is wrapper-managed (the - serving wrapper allocated it — `adopt_channel` on it a second time - fails `ChannelExists`), so the test drives the client end, not a - re-adopt. + serving wrapper allocated it, so `adopt_channel` on it a second + time fails `ChannelExists`), so the test drives the client end, not + a re-adopt. - **EOF-teardown observability:** the test pins the listen channel reaping on the producer side after the opener drops its session (`has_channel` false) and the accept loop's death via the queue @@ -292,28 +292,28 @@ pub fn register_tunnel_bindfirst_openable( ## Summary Implemented the bind-first producer (shape 3, ADR-008 as amended) per -the task: `src/producer.rs` — `BindFn` + `BoundListener` (the +the task. `src/producer.rs`: `BindFn` + `BoundListener` (the platform-boundary bind seam); the token mint (non-zero-seeded `AtomicU32`, per-open fresh, per-producer unique, token 0 never -minted — the wrap shape unit-pinned); `bindfirst_establisher` +minted, the wrap shape unit-pinned); `bindfirst_establisher` (registry namespace gate → mint → bind → `bound` + `listen` reply -fields → the `BindFirstPlan` plan; the typed errors pass through — +fields → the `BindFirstPlan` plan; the typed errors pass through: bind failure `dial_failed`, exhaustion `resource_shortage`, registry miss `unknown_resource`); `BindFirstAcceptLoop` (the type-erased accept closure receiving the opaque listener); `ForwardedOpener` (the -call-plane forwarded-open caller: call + adopt); +call-plane forwarded-open caller: call + adopt); `make_bindfirst_accept_handler` (the accept dispatcher: `accept_bi` first, the idle-stream EOF sentinel cancels the loop via `select!`, per-accept `pump_bidi` in spawned tasks tracked by the handler's `JoinHandle`, per-accept failure isolation, loop-critical accept errors end the loop); `register_tunnel_bindfirst_openable` (same -spec + scope gate, `BindFirstInjections` bundle + witness seam) — -re-exported through `lib.rs`. `tests/harness.rs` — +spec + scope gate, `BindFirstInjections` bundle + witness seam), +re-exported through `lib.rs`. `tests/harness.rs`: `RegistrationMode::BindFirst` + `wire_bindfirst` (the consumer hook gained the accept-side identity propagation + a shared serving registry, `Topology.consumer_registry` exposed) + the forwarded-op serving stub (`make_forwarded_stub_handler`). `tests/ -producer_bindfirst.rs` — 6 integration tests: the full flow e2e (the +producer_bindfirst.rs` has 6 integration tests: the full flow e2e (the acceptance gate: reply projection → token correlation → forwarded open → byte round-trip through the whole path), the dial-open neither-field projection, EOF teardown (listen reaped, loop gone), @@ -321,8 +321,8 @@ per-accept isolation (scope-less opener; the loop serves the next accept), typed establishment errors, and the token discipline (two opens distinct tokens; each dispatcher stamps its own plan's token). Plus 6 unit tests in producer.rs (the mint discipline incl. the wrap -shape, the establisher flow at the `Establishment` surface — plan -token == reply token — distinct tokens per open, registry miss, +shape, the establisher flow at the `Establishment` surface (plan +token == reply token), distinct tokens per open, registry miss, bind-error passthrough, the witness wrapper). Verified: `cargo test` green (118 default; 138 `--all-features`), diff --git a/tasks/tunnels/consumer-session.md b/tasks/tunnels/consumer-session.md index d355128..e6cf122 100644 --- a/tasks/tunnels/consumer-session.md +++ b/tasks/tunnels/consumer-session.md @@ -1,6 +1,6 @@ --- id: tunnels/consumer-session -name: Consumer half — TunnelSession (open/adopt, data planes, teardown) +name: Consumer half, TunnelSession (open/adopt, data planes, teardown) status: completed depends_on: [tunnels/params, tunnels/wire-codec, tunnels/producer-open-op] scope: broad @@ -12,26 +12,26 @@ tags: [consumer, session, teardown] ## Description -Implement `src/consumer.rs` per consumer.md + ADR-005: `TunnelSession` — +Implement `src/consumer.rs` per consumer.md + ADR-005: `TunnelSession`, the typed client, both construction paths, the substrate-shaped data plane, and teardown ownership. The reverse POC's consumer -(`/workspace/alktunnels-reverse-poc/src/consumer.rs` — `ReverseTunnel`) +(`/workspace/alktunnels-reverse-poc/src/consumer.rs`, `ReverseTunnel`) is the seed; this task generalizes it to the spec's session type. ### Construction - **Forward path:** `TunnelSession::open(client: &ChannelClient, params: TunnelParams) -> Result` — `client.open_channel(OP_TUNNEL_OPEN, params, + TunnelOpenError>` via `client.open_channel(OP_TUNNEL_OPEN, params, TUNNEL_ALPN)` → adopt → split the channel `BiStream` → data plane by substrate. Typed errors (ADR-049 §4; never a phantom session). - **Reverse path:** `TunnelSession::adopt(manager: &ChannelManager, channel_id: u32, alpn: impl Into) -> Result` — adopt a worker-allocated ID (early arrivals + TunnelOpenError>`: adopt a worker-allocated ID (early arrivals parked), install the data plane from the adopted halves. `open_reverse_channel(hub_call, params, auth_token) -> Result` as the free function the assembly layer calls - before `adopt` (the POC's shape — the hub's call surface is a + before `adopt` (the POC's shape: the hub's call surface is a `CallConnection`, not a `ChannelClient`, so the two-step is the honest API). @@ -40,30 +40,30 @@ is the seed; this task generalizes it to the spec's session type. - **Stream variant:** `stream_halves()` → `(&mut dyn AsyncRead, &mut dyn AsyncWrite)` (borrowed access); `take_halves(self)` → owned boxed halves (the session's halves ARE - the tunnel — raw pass-through). + the tunnel, i.e. raw pass-through). - **Datagram variant:** `send_datagram(&[u8]) -> Result<(), TunnelIoError>` (frame → write → flush; `Oversize` >65535); - `recv_datagram() -> Result, TunnelIoError>` — + `recv_datagram() -> Result, TunnelIoError>`: `Some(bytes)` per datagram (possibly empty, `len=0` legal), `None` only on stream EOF (the F-2 layering; the POC's `read_one_datagram` incremental loop). - Wrong-substrate operations are `WrongSubstrate` errors (the POC's shape). -### Pump ownership + teardown (ADR-005 — the point) +### Pump ownership + teardown (ADR-005, the point) -- `pump_against(self, accepted: impl AsyncRead + AsyncWrite + ...)` — +- `pump_against(self, accepted: impl AsyncRead + AsyncWrite + ...)`, for the reverse path: spawn `pump_bidi(channel_bistream, accepted_read, accepted_write)`, hold the returned handle. Returns - the session (builder-style) or takes self and returns the handle — - pick the shape that makes holding easy; document it. -- `close(self) -> bool` — abort the pump (if session-owned) + + the session (builder-style) or takes self and returns the handle. + Pick the shape that makes holding easy; document it. +- `close(self) -> bool`: abort the pump (if session-owned) + `teardown_channel` (ungraceful path). -- `join(self) -> (u64, u64, bool)` — await pump completion, then reap; +- `join(self) -> (u64, u64, bool)`: await pump completion, then reap; copy counts for observability. **Pump-less sessions** (after `take_halves`): completes immediately, reaps only, `(0, 0, reaped)` (consumer.md's pinned semantics). -- `Drop` — abort + sync `teardown_channel` (best-effort; never leak +- `Drop`: abort + sync `teardown_channel` (best-effort; never leak the entry). No `Clone`. ### Tests @@ -73,8 +73,8 @@ halves drive a duplex), reverse open+adopt+pump_against (the POC's `ReverseTunnel` tests: round-trip, half-close W4, join_and_reap copy counts, out-of-band close + self-reaping, pump-less join), datagram variant (round-trip incl. empty datagram via the codec), teardown -matrix (close/join/Drop paths — no leaked channel entries asserted via -`channel_ids()`). +matrix (close/join/Drop paths, with no leaked channel entries asserted +via `channel_ids()`). ## Acceptance Criteria @@ -102,11 +102,13 @@ matrix (close/join/Drop paths — no leaked channel entries asserted via - **`adopt` takes `substrate: Substrate`** (a deviation from the task sketch's `adopt(manager, channel_id, alpn)`): the substrate shapes the data plane (raw halves vs the UDP codec) and cannot be inferred - from the channel ID. The ALPN is observability-only (alkcall + from the channel ID, so passing it to `adopt` keeps the two calls + consistent. + The ALPN is observability-only (alkcall manager semantics); the substrate rode the open call that produced - the ID — passing it to `adopt` keeps the two calls consistent. + the ID. - **`pump_against` takes the whole accepted stream** (`S: AsyncRead + - AsyncWrite`) and splits internally — `tokio::io::split` at the + AsyncWrite`) and splits internally with `tokio::io::split` at the pump boundary, matching the POC's `pump_against(accepted)`. Returns the session (builder-style); the pump handle is session-owned and private (close/join/Drop are the only handles needed). @@ -114,24 +116,24 @@ matrix (close/join/Drop paths — no leaked channel entries asserted via }`** (not bare halves): the session must stay alive as the channel-entry owner (reap via `close`/`join`/`Drop`); returning bare halves would strand the entry. `join` on the held session - completes immediately (`(0, 0, reaped)` — pump-less). -- **`open_reverse_channel`'s hub_call is `&CallConnection`** (not - `&Arc`) — borrowing is the honest shape; callers - hold the Arc. + completes immediately (`(0, 0, reaped)`, because pump-less). +- **`open_reverse_channel`'s hub_call is `&CallConnection`** + (not `&Arc`); borrowing is the honest shape; + callers hold the Arc. - **Fields are `Option` + idempotent `Drop`:** a `Drop`-impl type cannot be destructured, so `take_halves`/`pump_against`/`close`/ `join` take fields out via `.take()`; an emptied session drops as a no-op. Teardown is idempotent (`teardown_channel` on a reaped entry is `Err(UnknownChannel)`, ignored). - **Bogus-ID adopt succeeds by design** (the manager parks early - arrivals for any not-yet-seen ID — the adoption-race cover, + arrivals for any not-yet-seen ID, the adoption-race cover, alkcall ADR-047 §5); the honest `AdoptFailed` probe is the ID-collision path (same ID adopted twice), pinned in the tests. - **`recv_datagram` keeps a pending queue**: one chunk may batch several frames; `DatagramReader::feed` decodes all completions per feed, so frames beyond the first queue for subsequent `recv_datagram` calls (dropped frames were a bug the tests caught). -- **Harness gained `wire_forward`** (`ForwardTopology`): the forward +- **Harness gained `wire_forward`, a `ForwardTopology`:** the forward path's consumer is the connect side (`from_connection`, pure consumer) and the producer accepts via the adapter; the accept-side transport-identity posture attaches the dialer's identity in the @@ -149,11 +151,11 @@ adopt_channel` + `open_reverse_channel` on the `CallConnection`), the substrate-shaped data planes (raw halves for stream; the ADR-003 codec with a pending frame queue for UDP), pump ownership (`pump_against` spawns `pump_bidi` and holds the handle), and the -full teardown matrix — `close` (abort + ungraceful reap), `join` -(await + reap + copy counts; pump-less `(0, 0, reaped)`), `Drop` +full teardown matrix: `close` (abort + ungraceful reap), `join` +(await + reap + copy counts; pump-less `(0, 0, reaped)`), and `Drop` (abort + sync reap, never leaks). No `Clone` (compile_fail doc-test). -Tests: `tests/consumer_session.rs` — 12 integration tests over the +Tests: `tests/consumer_session.rs` has 12 integration tests over the two harness topologies: forward round-trip (session halves), take- halves + pump-less join, datagram round-trip incl. the F-2 empty datagram, wrong-substrate typed errors, oversize-at-frame-time, diff --git a/tasks/tunnels/corrmiss-adr.md b/tasks/tunnels/corrmiss-adr.md index e38afee..155e14a 100644 --- a/tasks/tunnels/corrmiss-adr.md +++ b/tasks/tunnels/corrmiss-adr.md @@ -1,6 +1,6 @@ --- id: tunnels/corrmiss-adr -name: "ADR: the forwarded op's establishment phase + the correlation-miss vocabulary (resolves review 003 — decisions)" +name: "ADR: the forwarded op's establishment phase + the correlation-miss vocabulary (resolves review 003's decisions)" status: completed depends_on: [] scope: narrow @@ -16,7 +16,7 @@ Land the ADR ink for the settled decisions in `docs/plans/003-correlation-miss-resolve.md` §Decisions (1-4): the forwarded op's serving side gains an establishment phase; a stale/ missing `listen` token fails the open as a typed `channel:open_failed` -(rewriting plan decision 2 — the wire vocabulary already pins +(rewriting plan decision 2: the wire vocabulary already pins `unknown_resource`, so no wire-vocabulary extension and no reason-class ADR); the residual post-open decline (the narrow establisher-pass/handler-miss race) keeps teardown-at-birth and adopts @@ -28,7 +28,7 @@ Also record the tri-vocabulary history the decision retires: alkssh `correlation-miss` (debug, `decline_arrival`), alksocks prose ("forwarded open with no waiting session", warn, `src/composed/mod.rs:497`), alktunnels `handler_error`-class generic -arm (warn, the bind-first accept loop's failed-open branch) — review +arm (warn, the bind-first accept loop's failed-open branch); review 003's situation section plus the plan's §Verified findings are the primary sources for it. @@ -37,9 +37,9 @@ Doc-only change; no code in this task. ### Shape to decide while writing (the plan's "ADR-008 amendment ### (or a small successor ADR)" choice) -Recommendation: an **Amendment 2 to ADR-008** — this amends the +Recommendation: an **Amendment 2 to ADR-008**. This amends the forwarded op's serving shape exactly as §3 pins it (that section's -sentence "no establisher — the forwarded channel needs no +sentence "no establisher: the forwarded channel needs no establishment; its data plane starts immediately" is the text being amended), the wire vocabulary is unextended, and the amendment keeps the whole forwarded-op decision record in one ADR. Take a small @@ -51,23 +51,25 @@ format precedent). ### The invariants the ADR must pin -- The gate is injected — the protocol crate cannot hold the assembly's +- The gate is injected: the protocol crate cannot hold the assembly's correlation map; the establisher's check is an assembly-provided closure the crate wraps into the alkcall `OpenEstablisher` dance (the plan's W1 decision; assemblies must not hand-roll the alkcall establisher plumbing). - The gate rides `register_openable_with_establisher` (alkcall 0.10.0, verified): establisher failure → typed `channel:open_failed` with - `details.reason`, "never a phantom channel" is ADR-049's posture — - the forwarded op currently rides the phantom-shaped serving side + `details.reason`; "never a phantom channel" is ADR-049's posture. The + forwarded op currently rides the phantom-shaped serving side (establisher-less everywhere), and this closes it for the miss class. - `unknown_resource` is reused as-is (the waiting session IS a resource - that is gone) — `details.reason`'s wire vocabulary is NOT extended. -- The scope gate (`tunnel:forwarded`) precedes the establisher — pin + that is gone), so `details.reason`'s wire vocabulary is NOT + extended. +- The scope gate (`tunnel:forwarded`) precedes the establisher; pin the order (the registry runs the ACL before the open op's establishment phase; alkcall's open-op wrapper order). - The residual post-open race keeps teardown-at-birth (the fallback - shape — ADR-008 §3's opener-half posture, unchanged) and its event + shape, which is ADR-008 §3's opener-half posture, unchanged), and + its event name is `correlation-miss` at `debug` (assembly-side; the protocol crate's built-in handler holds no map, so the name is the assemblies' convention this ADR records, not code this crate runs). @@ -83,13 +85,13 @@ format precedent). - [x] The amended/superseded §3 sentence is marked, not silently replaced - [x] Wire-vocabulary non-extension is explicit (`unknown_resource` - reused; `wire.md` §The Open Ops untouched — state that) + reused; `wire.md` §The Open Ops untouched; state that) - [x] Status block of `docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md` - is NOT updated here (the doc-sync task owns it — this task is + is NOT updated here (the doc-sync task owns it; this task is the ADR itself) - [x] No code changes; doc build clean (`cargo doc --no-deps` clean is - NOT required for pure docs/ edits — verify md renders coherently + NOT required for pure docs/ edits; verify md renders coherently instead) ## References @@ -101,32 +103,32 @@ format precedent). (the serving shape being amended; Amendment 1 is the format precedent) - docs/architecture/wire.md §The Open Ops (the pinned - `details.reason` vocabulary — unextended) + `details.reason` vocabulary, unextended) - alkcall ADR-049 (the establisher posture being extended to the forwarded op's serving side); alkcall 0.10.0 `register_openable_with_establisher` (verified present) ## Notes -- Shape chosen: **Amendment 2 to ADR-008** (the recommendation held — - it did not balloon; §4/§5 needed no restating). +- Shape chosen: **Amendment 2 to ADR-008** (the recommendation held: + it did not balloon, and §4/§5 needed no restating). - Before writing, verified the ground against the shipped sources: alkcall 0.10.1 `EstablishmentError` (variants `DialFailed`/`UnknownResource`/`ResourceShortage`/`HandlerError`; `.reason()` maps `UnknownResource → "unknown_resource"`, operations.rs:592-596) and `register_openable_with_establisher` (operations.rs:761); the open-op wrapper order - (`policy.check_open` — the ACL — then allocate → establisher → + (`policy.check_open`, the ACL, then allocate → establisher → handler, operations.rs:1085-1103) pins the "scope gate precedes the establisher" invariant. Cargo.toml pins `alkcall = "0.10.1"`, so the ADR says "0.10.x, verified present". - The amended §3 sentence (lines 187-196, the serving-shape bullet) - is marked inline with "**amended by Amendment 2**" — the + is marked inline with "**amended by Amendment 2**"; the pre-amendment posture is recorded, not replaced (the Amendment 1 style). - Deliberately NOT touched: `wire.md` (the amendment states its `details.reason` vocabulary is unextended; the one serving-shape - prose line — "no establisher" under §The Forwarded Op — syncs with + prose line ("no establisher" under §The Forwarded Op) syncs with the API task per the amendment's note) and review 003's status block (doc-sync task owns it). @@ -135,7 +137,7 @@ format precedent). Amendment 2 landed at `docs/architecture/decisions/008-listen-metadata-forwarded-op.md` (2026-11-03): the forwarded op's serving side gains an establishment -phase — an assembly-provided gate closure (over +phase: an assembly-provided gate closure (over `TunnelForwardedParams`) the crate wraps into the alkcall `OpenEstablisher` dance via `register_openable_with_establisher`, with failures mapped to `EstablishmentError::UnknownResource` so a diff --git a/tasks/tunnels/corrmiss-alksocks-adopt.md b/tasks/tunnels/corrmiss-alksocks-adopt.md index 5665eab..1ebb33b 100644 --- a/tasks/tunnels/corrmiss-alksocks-adopt.md +++ b/tasks/tunnels/corrmiss-alksocks-adopt.md @@ -1,6 +1,7 @@ --- id: tunnels/corrmiss-alksocks-adopt -name: "W3 (pointer, alksocks repo): adopt the establisher — CorrelationRegistry lookup becomes the gate; the prose warn retires" +name: "W3 (pointer, alksocks repo): adopt the establisher (CorrelationRegistry lookup becomes the +gate); the prose warn retires" status: pending depends_on: [tunnels/corrmiss-review] scope: moderate @@ -12,20 +13,20 @@ tags: [pointer, cross-repo, alksocks, correlation-miss, review-003, wave-2] ## Description -**CROSS-REPO POINTER — executed in the alksocks repo, not here.** +**Cross-repo pointer: executed in the alksocks repo, not here.** Dependency-graph carrier for the plan's third wave; the work session is booked in alksocks. Runs in parallel with W2 (alkssh) once W1's reviewed surface is landed. -Per plan 003 §W3 (read that section — it is the spec for this session; +Per plan 003 §W3 (read that section, since it is the spec for this session; this task only carries the dependency + the acceptance summary): - Move the `CorrelationRegistry` lookup (alksocks - `src/composed/mod.rs` — `make_forwarded_handler`) into the new + `src/composed/mod.rs`, `make_forwarded_handler`) into the new `register_tunnel_forwarded_openable_with_establisher` gate (consumed from alktunnels at the version W1 landed). - Retire the prose warn ("forwarded open with no waiting session", - `src/composed/mod.rs:497`) — the miss becomes a typed failure at + `src/composed/mod.rs:497`), so the miss becomes a typed failure at the open. - Residual logging aligns to the canonical `correlation-miss` name at `debug` (the family one-way vocabulary; alkssh's shipped form). @@ -44,7 +45,7 @@ this task only carries the dependency + the acceptance summary): - docs/plans/003-correlation-miss-resolve.md §W3 (THE spec) - docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md §Verified - findings (the third variant's provenance — + findings (the third variant's provenance, `src/composed/mod.rs:497`) - The alksocks repo: `src/composed/mod.rs` (`make_forwarded_handler`, `CorrelationRegistry`) diff --git a/tasks/tunnels/corrmiss-alkssh-adopt.md b/tasks/tunnels/corrmiss-alkssh-adopt.md index 221a274..f7a1494 100644 --- a/tasks/tunnels/corrmiss-alkssh-adopt.md +++ b/tasks/tunnels/corrmiss-alkssh-adopt.md @@ -1,6 +1,6 @@ --- id: tunnels/corrmiss-alkssh-adopt -name: "W2 (pointer, alkssh repo): adopt the establisher — pump_forwarded_arrival's lookup becomes the gate" +name: "W2 (pointer, alkssh repo): adopt the establisher (pump_forwarded_arrival's lookup becomes the gate)" status: pending depends_on: [tunnels/corrmiss-review] scope: moderate @@ -12,26 +12,26 @@ tags: [pointer, cross-repo, alkssh, correlation-miss, review-003, wave-2] ## Description -**CROSS-REPO POINTER — executed in the alkssh repo, not here.** This +**Cross-repo pointer: executed in the alkssh repo, not here.** This task exists in this repo's decomposition so the dependency graph and the plan's ordering (`W1 → {W2, W3}`) stay visible; the work session -is booked in alkssh (its wave-3 review gate item 5 lineage — +is booked in alkssh (its wave-3 review gate item 5 lineage from `tasks/implementation/review-wave-3.md` item 5 and `docs/plans/implementation.md` "Still booked with their owners"). -Per plan 003 §W2 (read that section — it is the spec for this session; +Per plan 003 §W2 (read that section, since it is the spec for this session; this task only carries the dependency + the acceptance summary): - Move `pump_forwarded_arrival`'s route lookup (alkssh - `src/tunnel_map.rs` — the `ListenRouter` lookup) into the new + `src/tunnel_map.rs`, the `ListenRouter` lookup) into the new `register_tunnel_forwarded_openable_with_establisher` gate (consumed from alktunnels at the version W1 landed). - The residual handler keeps `decline_arrival("correlation-miss", …)` at its shipped `debug` level for the narrow post-open race only. - Verify the fabric-side silence fix: a stale-token arrival now fails the open (alkssh-side typed error; the producer-side generic arm - fires with a parseable reason) — the race window no longer produces - a zero-signal pump. + fires with a parseable reason, so the race window no longer + produces a zero-signal pump. - Fold alkssh's em-dash doc cleanup into its release for this change (user-owned, out of plan scope). diff --git a/tasks/tunnels/corrmiss-doc-sync.md b/tasks/tunnels/corrmiss-doc-sync.md index aec0d1b..81d5678 100644 --- a/tasks/tunnels/corrmiss-doc-sync.md +++ b/tasks/tunnels/corrmiss-doc-sync.md @@ -17,7 +17,7 @@ Close out W1's remaining doc bookkeeping (the plan-level AC's "review resolve the review's doc trail once the ADR + API + tests + review checkpoint are landed. -- `docs/reviews/003-…alkssh-input.md`: update the Status block — +- `docs/reviews/003-…alkssh-input.md`: update the Status block, resolved for W1 (this repo absorbed the ask via the ADR + the establisher variant); W2/W3 pointed at the sibling repos (`docs/plans/003-correlation-miss-resolve.md` is the pointer). Match @@ -25,13 +25,13 @@ checkpoint are landed. - `docs/plans/003-correlation-miss-resolve.md`: tick the W1 share of §Acceptance criteria (the first checkbox's clauses that are now true). Leave the W2/W3 checkboxes and the plan-level "W2: …" / - "W3: …" boxes UNTICKED — they are sibling-repo sessions, unticked - until those repos land. + "W3: …" boxes UNTICKED, since they are sibling-repo sessions, + unticked until those repos land. - `docs/plans/003-…` status header: planned → W1 landed (keep the file; it records the tri-vocabulary decision's ground). - If any doc comment referencing the miss is stale after the API task (a `grep` for `correlation miss` / `handler_error`-class across - `src/`, `docs/architecture/`) — align it. Convention: stale + `src/`, `docs/architecture/`), align it. Convention: stale TODOs/comments pointing at superseded designs get removed, not annotated. @@ -52,7 +52,7 @@ checkpoint are landed. (the status block) - docs/plans/003-correlation-miss-resolve.md §Acceptance criteria (the plan-level boxes) -- tasks/tunnels/review-002-doc-sync.md (the doc-sync precedent — +- tasks/tunnels/review-002-doc-sync.md (the doc-sync precedent, the same job shape for review 002) ## Notes @@ -66,25 +66,25 @@ the ADR amendment itself). Three STALE spots found and aligned: - `src/producer.rs:838-854` (the bind-first accept loop's failed-open arm): the in-comment class-distinction still read - "correlation miss → handler_error-class" — the exact prose review + "correlation miss → handler_error-class", the exact prose review 003 flagged. Reworded: a stale/missing token is now the typed `unknown_resource` open_failed under the serving side's establisher variant (ADR-008 Amendment 2); other establisher failures ride - handler_error-class. (Deliberately a comment-update, not removal — - the class distinctions are the arm's working notes; convention 1's + handler_error-class. (Deliberately a comment-update, not removal, + since the class distinctions are the arm's working notes; convention 1's removal rule is for pointers at superseded designs, and this one now points at the current one.) - `wire.md` §The Forwarded Op "Reply / errors": "a producer-side accept correlation miss is the typed - `channel:open_failed`/`handler_error`-class error" — pre-amendment - attribution. Now records the amendment posture: the miss is the + `channel:open_failed`/`handler_error`-class error", which was + pre-amendment attribution. Now records the amendment posture: the miss is the typed `unknown_resource` open_failed (establisher variant, recommended); under the base shape an in-session miss produces no - call error — the assembly-side post-open decline, + call error: the assembly-side post-open decline, `correlation-miss` at `debug`. - `producer.md` §The Forwarded Op: "(ADR-008 §3) … no establisher, - the data plane starts immediately" — now the two-shape posture with - the Amendment 2 pointer; the correlate step notes the gate rides + the data plane starts immediately" now carries the two-shape + posture with the Amendment 2 pointer; the correlate step notes the gate rides the establisher with the handler-side correlation as the residual fallback. `last_updated` header bumped. @@ -97,7 +97,7 @@ via plan 003 §Decisions 1–4 + ADR-008 Amendment 2 (typed vocabulary unextended), with W2 (alkssh) / W3 (alksocks) pointed at the plan's §W2/§W3 and recorded as open. Plan 003's status header is "W1 landed; W2/W3 open (sibling repos)" and only the W1 plan-level AC -box is ticked — W2/W3 and the family-coherence box stay untouched +box is ticked, and W2/W3 plus the family-coherence box stay untouched until those repos land. The stale-comment sweep aligned three stragglers (the accept-loop arm's comment, wire.md's failure line, producer.md's forwarded-op section); everything else already carried diff --git a/tasks/tunnels/corrmiss-forwarded-gate.md b/tasks/tunnels/corrmiss-forwarded-gate.md index 966556d..2198d07 100644 --- a/tasks/tunnels/corrmiss-forwarded-gate.md +++ b/tasks/tunnels/corrmiss-forwarded-gate.md @@ -1,6 +1,6 @@ --- id: tunnels/corrmiss-forwarded-gate -name: "API: the forwarded-op establisher variant — ForwardedGate + register_tunnel_forwarded_openable_with_establisher" +name: "API: the forwarded-op establisher variant (ForwardedGate + register_tunnel_forwarded_openable_with_establisher)" status: completed depends_on: [tunnels/corrmiss-adr] scope: moderate @@ -16,13 +16,13 @@ Implement the additive establisher variant for the forwarded op's serving half (plan 003 W1's API item): an assembly-injected correlation gate that the crate wraps into the alkcall `OpenEstablisher` dance, registered via -`register_openable_with_establisher` — a stale `listen` token now +`register_openable_with_establisher`: a stale `listen` token now returns a typed `channel:open_failed` before the channel's data plane is served, instead of succeeding at the call plane and declining post-open (the phantom-channel shape the review records as fully silent on the fabric side). -Candidate shape (the plan's W1 design decision to finalize — this task +Candidate shape (the plan's W1 design decision to finalize, which this task finalizes it): ```rust @@ -44,36 +44,36 @@ pub fn register_tunnel_forwarded_openable_with_establisher( ### The design decisions to finalize (and pin in doc comments) 1. **Gate input vs raw input.** The gate receives the PARSED - `TunnelForwardedParams` — the establisher parses (the params' - serde pins — `deny_unknown_fields`, positive `listen` — are the + `TunnelForwardedParams`; the establisher parses (the params' + serde pins, `deny_unknown_fields`, positive `listen`, are the loud gates; `parse` failure at the establisher maps through `EstablishmentError::HandlerError` → a typed `channel:open_failed`, which is an improvement over the base shape's silent birth-teardown for a malformed ingress; pin this deliberately, do not drift it). The handler still receives the raw input and - re-parses as it does today — keep `make_forwarded_op_handler` + re-parses as it does today, so keep `make_forwarded_op_handler` unchanged (the double parse is cheap and preserves deny-unknown at both layers; the handler must stay usable by the base shape). 2. **The establishment answer.** A gate pass produces no resource handle (the correlation map is assembly state; the handler needs - nothing it does not re-derive) — return `Establishment::new(plan)` + nothing it does not re-derive). It returns `Establishment::new(plan)` with an empty/marker plan payload (same typed-opaque `ChannelPlan` posture as ADR-049 amendment 2; `()` or an empty marker struct is the payload choice to pin). No reply-field contribution (the forwarded op's reply stays the wrapper's `channel_id`). 3. **Error mapping.** A gate `Err(TunnelEstablishError)` maps through the EXISTING `From for EstablishmentError` - table (`src/producer.rs:142-154`) — a stale token is + table (`src/producer.rs:142-154`); a stale token is `UnknownResource` → `details.reason == "unknown_resource"` on the wire. Do not mint new variants. The gate SHOULD document that a miss is `TunnelEstablishError::UnknownResource` (that is the canonical reason the ADR pins), but the crate passes whatever the gate returns through. -4. **No timeout change** — `timeout: None` (the alkcall 10s default) +4. **No timeout change**: `timeout: None` (the alkcall 10s default) as the other registrations do; the gate is an in-memory map lookup. -5. **No scope-table change** — the same `tunnel_forwarded_spec()` / +5. **No scope-table change**: the same `tunnel_forwarded_spec()` / `tunnel:forwarded` gate runs (the registry runs the ACL before the - establishment phase — alkcall's open-op wrapper order; pin the + establishment phase, alkcall's open-op wrapper order; pin the order in a doc comment + a test, see the tests task). ### Also in this task (the doc half of W1's API item) @@ -81,7 +81,7 @@ pub fn register_tunnel_forwarded_openable_with_establisher( - Update the in-code docs that describe the miss as assembly-side-only: `make_forwarded_op_handler`'s correlation-miss-semantics block (`src/producer.rs:1014-1020`) and - `register_tunnel_forwarded_openable`'s doc (1047-1056) — both gain + `register_tunnel_forwarded_openable`'s doc (1047-1056); both gain the two-shape posture: the establisher variant is the recommended shape (the miss is a typed failure at the open); the base shape stays for compatibility and keeps the post-open teardown-at-birth @@ -89,11 +89,12 @@ pub fn register_tunnel_forwarded_openable_with_establisher( variant). - Re-export the new surface from `lib.rs` (convention 16). - The base `register_tunnel_forwarded_openable` stays - byte-for-byte compatible (additive-only — plan decision 4). + byte-for-byte compatible (additive-only, per plan decision 4). -NOT in this task: the sibling-repo adoptions (alkssh W2 / alksocks W3 -— pointer tasks exist), the gate-miss tests (the dedicated tests task -— but do not land the API without landing its tests task after). +NOT in this task: the sibling-repo adoptions (alkssh W2 / alksocks +W3, pointer tasks exist) and the gate-miss tests (the dedicated +tests task; but do not land the API without landing its tests task +after). ## Acceptance Criteria @@ -119,15 +120,15 @@ NOT in this task: the sibling-repo adoptions (alkssh W2 / alksocks W3 - docs/plans/003-correlation-miss-resolve.md §W1 (the API bullet; the decisions this finalizes) - docs/architecture/decisions/008-listen-metadata-forwarded-op.md §3 - + the ADR this task's predecessor lands (Amendment 2 or successor — + + the ADR this task's predecessor lands (Amendment 2 or successor; cite whichever the ADR task landed) -- src/producer.rs:982-1082 (the base serving shape — `ForwardedHandler`, +- src/producer.rs:982-1082 (the base serving shape: `ForwardedHandler`, `make_forwarded_op_handler`, `register_tunnel_forwarded_openable`); 142-154 (the `From` table); 641-673 - (the `bindfirst_establisher` — the wrap-into-`OpenEstablisher` + (the `bindfirst_establisher`, the wrap-into-`OpenEstablisher` closure pattern to mirror, including the `Box::pin` shape) - alkcall 0.10.0 `ChannelCore::register_openable_with_establisher` - (src/channels/operations.rs:761 — the establisher runs AFTER + (src/channels/operations.rs:761; the establisher runs AFTER allocation, BEFORE the reply; failure → typed `channel:open_failed`, no phantom channel; `timeout: None` = the 10s default) - AGENTS.md conventions 11 (the ADR-049 establishment surface is @@ -138,24 +139,24 @@ NOT in this task: the sibling-repo adoptions (alkssh W2 / alksocks W3 - `ForwardedGate` (sync closure over the parsed `TunnelForwardedParams`) wraps into a private `make_forwarded_gate_establisher` → - `register_openable_with_establisher(spec, Some(...), make_forwarded_op_handler(handler), registry, auth, None)` - — the base handler is reused byte-for-byte, so the base registration - stays untouched (additive-only). + `register_openable_with_establisher(spec, Some(...), make_forwarded_op_handler(handler), registry, auth, None)`, + so the base handler is reused byte-for-byte and the base + registration stays untouched (additive-only). - The design decisions were finalized exactly as the task sketched: (1) gate receives the PARSED params; establisher parse failure maps `TunnelEstablishError::HandlerError` → typed `channel:open_failed` - (pinned in the establisher doc — an improvement over the base + (pinned in the establisher doc, an improvement over the base shape's silent birth-teardown for malformed ingress); the handler re-parses the raw input (deny-unknown at both layers, handler unchanged); (2) gate pass → `Establishment::new(plan)` with an - EMPTY MARKER plan payload — pinned as a new pub `ForwardedPlan` + EMPTY MARKER plan payload, pinned as a new pub `ForwardedPlan` unit struct (chosen over `()`: self-documenting, downcast-able by - assemblies that want to assert the gate ran — same typed-opaque + assemblies that want to assert the gate ran; the same typed-opaque posture as ADR-049 amendment 2); no reply-field contribution; (3) gate errors pass through the existing - `From` table, no new variants — the gate doc + `From` table, no new variants; the gate doc pins `UnknownResource` as the canonical stale-token reason; - (4) `timeout: None` (10s default); (5) no scope-table change — the + (4) `timeout: None` (10s default); (5) no scope-table change; the wrapper order (schema → ACL → allocate → establisher → handler) is pinned in the establisher doc; the order-pinning test lands with the tests task. diff --git a/tasks/tunnels/corrmiss-gate-tests.md b/tasks/tunnels/corrmiss-gate-tests.md index c9fb5ba..8740515 100644 --- a/tasks/tunnels/corrmiss-gate-tests.md +++ b/tasks/tunnels/corrmiss-gate-tests.md @@ -1,6 +1,6 @@ --- id: tunnels/corrmiss-gate-tests -name: "Tests: the forwarded gate — typed miss at the open, gate-pass flow, residual race, scope-gate ordering" +name: "Tests: the forwarded gate: typed miss at the open, gate-pass flow, residual race, scope-gate ordering" status: completed depends_on: [tunnels/corrmiss-forwarded-gate] scope: narrow @@ -14,8 +14,8 @@ tags: [tests, establisher, forwarded-op, correlation-miss, review-003] Land W1's test item (plan 003 §W1 "Tests:"): pin the four behaviors the establisher variant introduces, mirroring the shapes the existing -suite uses (`tests/producer_forwarded.rs` — the serving-half e2e + -scope-gate + malformed-params + correlation-miss tests are the +suite uses (`tests/producer_forwarded.rs`, whose serving-half e2e, +scope-gate, malformed-params, and correlation-miss tests are the precedents; the harness's `wire_with` + `Topology.consumer_registry` shape needs no harness changes per the `forwarded-receipt` task's notes). @@ -26,7 +26,7 @@ The four pins: with no waiting session → the open fails as a `channel:open_failed` reply with `details.reason == "unknown_resource"`; the transiently allocated channel is torn - down by the wrapper (ADR-049 §5 — the reply is the typed error, + down by the wrapper (ADR-049 §5: the reply is the typed error, not a phantom channel); the pump handler NEVER runs. Probe by opening the forwarded op with a token the gate rejects. 2. **Gate pass → the existing serving flow is unchanged.** A token the @@ -38,13 +38,13 @@ The four pins: 3. **The residual race shape still reaps cleanly.** Gate passes, route gone at handler time → the injected handler's error path drops the stream and returns → teardown-at-birth reaps the forwarded channel - (the base shape's pinned semantics — re-pin them under the + (the base shape's pinned semantics; re-pin them under the establisher variant; the narrow establisher-pass→handler-run window is the residual `correlation-miss` class). 4. **Scope gate precedes the establisher.** An identity WITHOUT `tunnel:forwarded` → FORBIDDEN and the gate closure is never invoked (a witness-style `Arc` or captured flag the - gate sets on entry; assert unmoved) — the registry runs the ACL + gate sets on entry; assert unmoved); the registry runs the ACL before the open op's establishment phase; pin the order. If the establisher variant's parse-failure behavior (malformed params @@ -81,65 +81,65 @@ explicitly (do not silently assume). the FORBIDDEN gate, the malformed-params gates, the correlation-miss teardown) - tasks/tunnels/corrmiss-forwarded-gate.md (the surface being tested; - design decisions 1 + 3 — the parse-failure and error-mapping pins) + design decisions 1 + 3 (the parse-failure and error-mapping pins)) - docs/architecture/wire.md §The Open Ops (the - `details.reason == "unknown_resource"` vocabulary — the assertion + `details.reason == "unknown_resource"` vocabulary; the assertion target) - alkcall 0.10.0 `src/channels/operations.rs:761` (the wrapper's establisher failure → typed open_failed semantics; ADR-049 §5) ## Notes -- New test file `tests/producer_forwarded_gate.rs` — the +- New test file `tests/producer_forwarded_gate.rs` with the establisher-variant probes live in their own file (the base shape's `tests/producer_forwarded.rs` stays untouched), reusing the harness's `wire_with` + `Topology.consumer_registry` shape with no harness changes, exactly as the `forwarded-receipt` task's notes predicted. - The gate under test closes over a `std::sync::Mutex>` - of waiting tokens (the gate is a sync closure — no awaits) and sets + of waiting tokens (the gate is a sync closure, with no awaits) and sets an `Arc` witness on entry; the witness is what the miss/ordering pins assert against. Poisoned-lock handling follows convention 2 (`unwrap_or_else(|e| e.into_inner())`). - The five tests: - 1. `gate_miss_fails_typed_no_phantom_no_handler` — AC 1: open with + 1. `gate_miss_fails_typed_no_phantom_no_handler` (AC 1: open with an absent token → `Err(CallError)` with `code == "channel:open_failed"` and `details.reason == "unknown_resource"` (the existing `From` table, 1:1); after a settle sleep the opener-side manager has no channels (the wrapper's transient - allocation was torn down — ADR-049 §5, no phantom); the gate's + allocation was torn down; ADR-049 §5, no phantom); the gate's witness IS set (the miss is at the gate, not before it) and the pump handler's witness is unmoved (the handler never ran). - 2. `gate_pass_serves_the_unchanged_serving_flow` — AC 2: token in + 2. `gate_pass_serves_the_unchanged_serving_flow` (AC 2: token in the map + correlated halves → the open resolves with the wrapper's `channel_id`; the injected handler is witnessed with the parsed params verbatim; the full dispatcher-pump dance - (adopt + driving echo) round-trips bytes end to end — the + (adopt + driving echo) round-trips bytes end to end, i.e. the `producer_forwarded.rs` e2e shape driven through the establisher-registered variant. - 3. `residual_race_still_reaps_cleanly` — AC 3: the token is in the + 3. `residual_race_still_reaps_cleanly` (AC 3: the token is in the map (the gate passes, the open RESOLVES at the call plane) but the halves vec is empty at handler time → the handler's error path drops the stream and returns → teardown-at-birth reaps. Re-pins the base shape's `forwarded_correlation_miss_ - tears_channel_down` semantics under the establisher variant — + tears_channel_down` semantics under the establisher variant, where the establisher-pass→handler-run window is the residual `correlation-miss` class. - 4. `scope_gate_precedes_the_establisher` — AC 4: an identity + 4. `scope_gate_precedes_the_establisher` (AC 4: an identity holding only `tunnel:open` → `FORBIDDEN` naming the missing scope, and the gate's witness stays unmoved even though the - token WOULD have passed the gate (the tie-break is the order — - schema → ACL → allocate → establisher → handler — pinning that - the registry runs the ACL before the establishment phase). - 5. `malformed_params_fail_typed_at_the_establisher` — AC 5: the + token WOULD have passed the gate. The tie-break is the order + (schema → ACL → allocate → establisher → handler), which pins + that the registry runs the ACL before the establishment phase). + 5. `malformed_params_fail_typed_at_the_establisher` (AC 5: the establisher-shape malformed-params delta IS separately testable through the public surface, so it is pinned: the schema gates (`listen: 0`, missing `peer`) fire first exactly as in the base shape (`INVALID_INPUT`, the test explicitly - re-checks both), but an unknown top-level field — passes the - schema's additive posture, fails the establisher's serde parse - — becomes a TYPED `channel:open_failed` with + re-checks both), but an unknown top-level field passes the + schema's additive posture while failing the establisher's serde + parse, so it becomes a TYPED `channel:open_failed` with `details.reason == "handler_error"` (vs the base shape's success-with-channel_id + silent birth-teardown: the improvement design decision 1 pins). The establisher's parse precedes the @@ -149,7 +149,7 @@ explicitly (do not silently assume). `cargo clippy --target wasm32-unknown-unknown --all-targets` cannot compile on this repo's baseline (the wasm clippy's `--all-targets` pulls the test/dev targets whose `mio`/`socket2` deps do not - compile for wasm) — verified by removing the new test file and + compile for wasm); verified by removing the new test file and re-running: identical failure without the change. The repo's AGENTS.md verification list specifies wasm clippy WITHOUT `--all-targets` @@ -163,14 +163,14 @@ explicitly (do not silently assume). Landed W1's test item: five tests in the new `tests/producer_forwarded_gate.rs` pinning the establisher variant's -behaviors — (1) gate miss → typed `channel:open_failed` / +behaviors: (1) gate miss → typed `channel:open_failed` / `details.reason == "unknown_resource"` / no phantom channel / handler never runs; (2) gate pass → the base serving flow unchanged (params witnessed verbatim + full byte round-trip through the establisher-registered variant); (3) the residual race (gate pass, route gone at handler time) reaps cleanly via teardown-at-birth, re-pinning the base shape's correlation-miss semantics under the -establisher variant; (4) scope-gate ordering — a scope-less identity +establisher variant; (4) scope-gate ordering: a scope-less identity is FORBIDDEN and the gate closure is never invoked (witness unmoved); (5) the establisher-variant malformed-params delta pinned explicitly: schema gates unchanged (`INVALID_INPUT`) but an unknown top-level @@ -179,11 +179,11 @@ field becomes a typed `channel:open_failed` with Verification green: `cargo test` (14 suites, 0 failures), `cargo clippy --all-targets -- -D warnings` (native) clean, wasm clippy `cargo clippy --target wasm32-unknown-unknown -- -D -warnings` clean (see Notes for the `--all-targets`-wasm deviation — -pre-existing, dev-deps do not target wasm), +warnings` clean (see Notes for the pre-existing + `--all-targets`-wasm deviation: dev-deps do not target wasm), `cargo fmt --check` clean, `cargo check --target wasm32-unknown-unknown` clean (default crate stays wasm-clean), `cargo test --all-features` green, `cargo doc --no-deps` clean. Harness unchanged. Follow-ups: the sibling-repo -adoptions (`corrmiss-alkssh-adopt`, `corrmiss-alksocks-adopt`) — the +adoptions (`corrmiss-alkssh-adopt`, `corrmiss-alksocks-adopt`). The W1 wave's tests are green. \ No newline at end of file diff --git a/tasks/tunnels/corrmiss-review.md b/tasks/tunnels/corrmiss-review.md index a270dfa..a3a9f3c 100644 --- a/tasks/tunnels/corrmiss-review.md +++ b/tasks/tunnels/corrmiss-review.md @@ -12,12 +12,12 @@ tags: [review, checkpoint, establisher, review-003] ## Description -Review checkpoint before the parallel consumer waves (W2 — alkssh, -W3 — alksocks) adopt the landed establisher variant: the surface these +Review checkpoint before the parallel consumer waves (W2, alkssh, +W3, alksocks) adopt the landed establisher variant: the surface these sessions build on must be right before two sibling repos burn sessions on it. Wire-stability context: the REPLY-side semantics (a typed `channel:open_failed` with the pre-pinned `unknown_resource` reason) -are what the consumers ride — once alkssh ships the gate shape, the +are what the consumers ride: once alkssh ships the gate shape, the API surface is de-facto wire-stable for them. Check: @@ -31,17 +31,17 @@ Check: registrations, and lib.rs's re-export surface - [x] The tests actually pin the wire-observable contract (`channel:open_failed` / `unknown_resource`, gate ordering, - residual-race reaping) — not just internal shapes + residual-race reaping), not just internal shapes - [x] Patterns are consistent with the existing establisher shapes (the `bindfirst_establisher` mirror; the `From` table reuse; no new error axes) -- [x] `cargo test`, clippy native + wasm32 `-D warnings`, fmt — green +- [x] `cargo test`, clippy native + wasm32 `-D warnings`, and fmt are green (re-confirm, they gate the review) - [x] CHANGELOG updated (Added: the establisher variant) if the implementation tasks did not already Filing guidance: if the surface needs a shape change (naming, gate -arity, plan payload), fix it in this checkpoint — it is the last cheap +arity, plan payload), fix it in this checkpoint; it is the last cheap moment before the consumers fork. ## Acceptance Criteria @@ -56,7 +56,7 @@ moment before the consumers fork. - docs/plans/003-correlation-miss-resolve.md §Ordering (the W1 → {W2, W3} fork this review gates) - docs/architecture/decisions/008-listen-metadata-forwarded-op.md - (the amended ADR — the shape source) + (the amended ADR, the shape source) - tasks/tunnels/corrmiss-adr.md, corrmiss-forwarded-gate.md, corrmiss-gate-tests.md (the W1 trio being reviewed) - src/producer.rs (the landed surface) @@ -64,7 +64,7 @@ moment before the consumers fork. ## Notes Review performed 2026-11-03 (review checkpoint gating the W1 → {W2, -W3} fork). Outcome: **surface approved — no shape change needed; two +W3} fork). Outcome: **surface approved with no shape change needed; two doc-level fixes applied and re-verified.** - **API vs the ADR ink (check 1):** all pins hold. @@ -75,10 +75,10 @@ doc-level fixes applied and re-verified.** matches. - Error-mapping pin: gate `Err` passes through the existing `From for EstablishmentError` table - (`src/producer.rs:142`) — no new variants minted; the gate doc + (`src/producer.rs:142`); no new variants are minted, and the gate doc pins `UnknownResource` as the canonical stale-token reason. - Establisher parse failure → `HandlerError` → typed - `channel:open_failed` — deliberately pinned in the establisher + `channel:open_failed`, deliberately pinned in the establisher doc (an improvement over the base shape's silent birth-teardown), exactly as corrmiss-forwarded-gate's decision 1 recorded. - Timeout: `timeout: None` (the 10s default) in the registration. @@ -86,7 +86,7 @@ doc-level fixes applied and re-verified.** `tunnel:forwarded` gate runs; the wrapper order pin (schema → ACL → allocate → establisher → handler) is in the establisher doc. - The plan payload is the pinned `ForwardedPlan` marker struct (the - `()`-alternative was rejected in the API task's Notes — + `()`-alternative was rejected in the API task's Notes, as self-documenting, downcast-able; consistent with the typed-opaque posture of `TargetHandle`/`BindFirstPlan`). - **Two-shape posture consistency (check 2):** present and consistent @@ -97,7 +97,8 @@ doc-level fixes applied and re-verified.** Forwarded Op's two-shape prose, `tunnel_forwarded_spec()`'s serving registration block). ONE drift found and fixed: the handler doc's HEADER still read "the no-establisher serving shape" while it now - serves both registrations — retitled "the serving-shape wrapper + serves both registrations, so it was retitled "the serving-shape + wrapper both registrations share" (the two-shape block below it already carried the posture; this aligns the header). - **Tests vs the wire contract (check 3):** the five tests in @@ -109,7 +110,7 @@ doc-level fixes applied and re-verified.** the residual race asserts the call-plane RESOLVES then the channel reaps; the malformed-params delta asserts the typed `handler_error` reason and parse-before-gate. Each contract assertion is at the - `CallError`/reply surface — a wire change would break them. + `CallError`/reply surface, and a wire change would break them. - **Pattern consistency (check 4):** `make_forwarded_gate_establisher` mirrors the `bindfirst_establisher` closure shape (the `Arc::clone`-into-`Box::pin` pattern, the same @@ -120,10 +121,10 @@ doc-level fixes applied and re-verified.** crate-owned-surface rule (assemblies cannot hand-roll the alkcall plumbing). - **CHANGELOG (check 6):** the implementation tasks did NOT update it - (Unreleased was empty) — added the "[Unreleased] / Added" entry for + (Unreleased was empty); added the "[Unreleased] / Added" entry for the establisher variant per this task's checklist. - **Verification (check 5), run at this review:** `cargo test` green - (14 suites, 0 failures — includes the five gate tests), clippy + (14 suites, 0 failures, which includes the five gate tests), clippy `--all-targets -- -D warnings` clean (native), wasm clippy `cargo clippy --target wasm32-unknown-unknown -- -D warnings` clean, `cargo check --target wasm32-unknown-unknown` clean (default crate @@ -147,6 +148,6 @@ the `CallError` surface; patterns mirror `bindfirst_establisher` with no new error axes. Found and fixed: the stale "no-establisher serving shape" handler-doc header, and the missing CHANGELOG entry (Added under [Unreleased]). Re-verification all green (test, clippy native + -wasm, fmt, wasm check, --all-features, doc). No shape change needed — -the surface is ready for the consumer waves (W2 alkssh, W3 alksocks) +wasm, fmt, wasm check, --all-features, doc). No shape change is +needed, and the surface is ready for the consumer waves (W2 alkssh, W3 alksocks) to adopt. \ No newline at end of file diff --git a/tasks/tunnels/coverage-weak-spots.md b/tasks/tunnels/coverage-weak-spots.md index 609f199..589ec60 100644 --- a/tasks/tunnels/coverage-weak-spots.md +++ b/tasks/tunnels/coverage-weak-spots.md @@ -1,6 +1,6 @@ --- id: tunnels/coverage-weak-spots -name: "Coverage weak spots — CLOSED (2026-09-09 second pass): the UdpHalf sender-block recipe found, feed infallible, wrappers covered" +name: "Coverage weak spots, CLOSED (2026-09-09 second pass): the UdpHalf sender-block recipe found, feed infallible, wrappers covered" status: completed depends_on: [tunnels/review-impl] scope: single @@ -18,16 +18,16 @@ The second pass found the sender-block recipe, took **option 1**, and coverage moved **92.86% → 93.91% lines** (50 → 41 uncovered). Three commits: -1. **`DatagramReader::feed` is infallible** — the same zombie category +1. **`DatagramReader::feed` is infallible**, the same zombie category as `4014ba6`'s `InvalidLength`: with the oversize bound enforced at frame time, the incremental decode cannot fail, so the `Vec` shape and `local/mod.rs`'s dead `InvalidData` mapping were removed (pre-consumer window open per ADR-005's N-3). 2. **The sender-block machinery test** (`udp_half_write_side_blocks_ - stashes_and_drains_on_resolve`) — the recipe that reached the + stashes_and_drains_on_resolve`): the recipe that reached the WouldBlock stash, the readiness-Pending arms, AND the high-water backpressure in one coherent behavior (option 1). -3. **The thin wrappers** — `connect_udp`'s resolve-failure arm +3. **The thin wrappers**, `connect_udp`'s resolve-failure arm (`.invalid` NXDOMAIN → `dial_failed`) and the no-witness `tunnel_establisher` (a harness `DialNoWitness` mode + a full open round-trip through the wrapper). @@ -44,16 +44,16 @@ persistently reports `WouldBlock`/`ENOBUFS`. The recipe (validated unresolved neighbor while charged to `sk_wmem_alloc`; with a shrunken `SO_SNDBUF` the charge exceeds the buffer → persistent `EAGAIN`/`ENOBUFS` (the drain reports 105=ENOBUFS alongside - EAGAIN — both mean "the socket cannot take this datagram now", + EAGAIN; both mean "the socket cannot take this datagram now", which the `pump_queue_to_socket` stash arm now matches too). - **Jumbo MTU (65535)** on the fixture: a 60 KB datagram crosses as - ONE frame — no IP fragmentation, so no fragment-reassembly races + ONE frame, with no IP fragmentation, so no fragment-reassembly races at the peer (fragment-ID collisions under the drain burst merged datagrams on an MTU-1500 veth; the peer reassembled 5-6 datagrams into single ~43947-byte payloads). - **The block-state probe runs on a THROWAWAY socket**: an oversized probe datagram's partially-built skb chain stays corked in - `sk_write_queue` on EAGAIN and flushes on the next successful send — + `sk_write_queue` on EAGAIN and flushes on the next successful send, poisoning the socket with a stale partial datagram. The test's real socket is created fresh after the probe. - **Assigning the peer address + binding the peer** resolves ARP; the @@ -63,74 +63,75 @@ persistently reports `WouldBlock`/`ENOBUFS`. The recipe (validated The first pass's kernel findings still hold and are why the loopback recipes failed there: a non-reading peer's full receive queue DROPS -datagrams silently (loopback/veth/netem — the sender never blocks); +datagrams silently (loopback/veth/netem: the sender never blocks); netem with `limit` still did not block (2M sends Ok in a probe). ## Negative result: the ICMP ECONNREFUSED arm is unreachable through the poll drive A connected UDP socket + a closed peer port elicits an ICMP port unreachable that `ECONNREFUSED`s the next `recvfrom` (a plain std -nonblocking recv surfaces it deterministically — probed rootless). +nonblocking recv surfaces it deterministically; probed rootless). But **tokio 1.53 structurally masks it out of the poll drive** (the adapter's shape): the `EPOLLOUT|EPOLLERR` event maps to `WRITABLE|ERROR|WRITE_CLOSED`; `poll_recv_ready`'s Direction::Read mask is `READABLE|READ_CLOSED` (ERROR excluded) → Pending forever; `try_io(READABLE)` intersects empty → WouldBlock. Only the async -`recv()` (`async_io` with `READABLE|ERROR`) surfaces `ECONNREFUSED` -— and `UdpHalf::poll_read` is a poll impl. Verified by probes +`recv()` (`async_io` with `READABLE|ERROR`) surfaces `ECONNREFUSED`, +but `UdpHalf::poll_read` is a poll impl. Verified by probes (`poll_recv`, `poll_recv_ready`, `try_recv` all Elapsed; the async -`recv` surfaced it exactly once, racing the waiter registration — -nondeterministic). `local/mod.rs:356` therefore joins the defensive +`recv` surfaced it exactly once, racing the waiter registration, +hence nondeterministic). `local/mod.rs:356` therefore joins the defensive list: no deterministic test can reach it through the pump's drive on tokio 1.53. ## The final uncovered-line inventory (41 lines, all deliberate) -- `src/local/mod.rs:136-137` — the no-addresses resolve arm: a name +- `src/local/mod.rs:136-137`, the no-addresses resolve arm: a name that RESOLVES to zero addresses is unreachable via getaddrinfo - (NXDOMAIN errors first — the `.invalid` test hits the error arm). -- `src/local/mod.rs:321-333` — the `Oversize` mapping at the recv - scratch (a >65535-byte datagram cannot exist on a real wire — the + (NXDOMAIN errors first; the `.invalid` test hits the error arm). +- `src/local/mod.rs:321-333`: the `Oversize` mapping at the recv + scratch (a >65535-byte datagram cannot exist on a real wire; the IP theoretical max is 65507). -- `src/local/mod.rs:351-352,356` — the recv-side readiness-Pending - arms (genuine WouldBlock) + the ICMP error mapping (the negative +- `src/local/mod.rs:351-352,356`, the recv-side readiness-Pending + arms (genuine WouldBlock) plus the ICMP error mapping (the negative result above). -- `src/local/mod.rs:378-379,383` — the high-water drain's +- `src/local/mod.rs:378-379,383`: the high-water drain's Ready(Ok)/Ready(Err) sub-arms (the observed over-line poll rides the Pending sub-arm; the Ok sub-arm needs the drain to succeed - within that single poll — the FAILED-cycle release timing). -- `src/local/mod.rs:476-477` — `pump_queue_to_socket`'s + within that single poll, the FAILED-cycle release timing). +- `src/local/mod.rs:476-477`, `pump_queue_to_socket`'s `poll_send_ready` Ready(Ok)/Err sub-arms (the Pend sub-arm is the covered one). -- `src/consumer.rs:91-94,405-406` — `serde_json::to_value` failures +- `src/consumer.rs:91-94,405-406`: `serde_json::to_value` failures on `TunnelParams` (impossible by construction). -- `src/consumer.rs:205,290` — `unreachable!` guards on spent sessions. -- `src/consumer.rs:285` — the stale-pump abort (defensive only). -- `src/consumer.rs:325-330` — the pump `JoinError` telemetry arm - (needs the spawned pump to panic/abort — not producible through - the session API). -- `src/producer.rs:85-86` — the `HandlerError` mapping (the schema +- `src/consumer.rs:205,290`, `unreachable!` guards on spent sessions. +- `src/consumer.rs:285`, the stale-pump abort (defensive only). +- `src/consumer.rs:325-330`, the pump `JoinError` telemetry arm + (needs the spawned pump to panic/abort, which is not producible + through the session API). +- `src/producer.rs:85-86`: the `HandlerError` mapping (the schema gate preempts it; pinned by `schema_gate_rejects_missing_substrate_…`). -- `src/producer.rs:338-347` — the pump-handler telemetry early-returns +- `src/producer.rs:338-347`, the pump-handler telemetry early-returns (`accept_bi` failure needs a dead channel; the plan is always `TargetHandle`; `Arc::try_unwrap` needs a second clone). -- `src/wire.rs:77-79` — the `Default` impl shim (clippy's +- `src/wire.rs:77-79`: the `Default` impl shim (clippy's `new_without_default`). -- `src/wire.rs:106` — the `unreachable!` invariant in `feed`'s loop. -- `src/wire.rs:177` — a unit test's own panic line (the negative +- `src/wire.rs:106`, the `unreachable!` invariant in `feed`'s loop. +- `src/wire.rs:177`, a unit test's own panic line (the negative branch of `datagram_split_across_awkward_chunks_reassembles`). ## Verification (2026-09-09, second pass) -- `cargo llvm-cov --all-features --workspace --summary-only` - — **93.91% lines** (from 92.86%; 50 → 41 uncovered, all deliberate). -- `cargo test --all-features` — **91 tests green** (15 lib + 16 +- `cargo llvm-cov --all-features --workspace --summary-only` reports + **93.91% lines** (from 92.86%; 50 → 41 uncovered, all deliberate). +- `cargo test --all-features` passes with **91 tests green** (15 lib + + 16 consumer_session + 18 end_to_end + 13 doc/lib + 19 local_halves + 9 producer_listen + 13 producer_open_op + 1 doc-test compile_fail). -- `cargo test` (default) — green; the root-gated veth test skips +- `cargo test` (default) is green; the root-gated veth test skips loud-free without root. - `cargo clippy --all-targets --all-features -- -D warnings` and - `cargo fmt --check` — clean. + `cargo fmt --check` are clean. - `cargo check --target wasm32-unknown-unknown` + - `cargo clippy --target wasm32-unknown-unknown -- -D warnings` — + `cargo clippy --target wasm32-unknown-unknown -- -D warnings` are clean (the default crate stays wasm-clean). \ No newline at end of file diff --git a/tasks/tunnels/crate-init.md b/tasks/tunnels/crate-init.md index 320171e..b216883 100644 --- a/tasks/tunnels/crate-init.md +++ b/tasks/tunnels/crate-init.md @@ -22,8 +22,8 @@ fill in. ```rust // src/lib.rs -//! alktunnels — arbitrary bidirectional tunnels over alkcall channels. -//! (crate docs per overview.md §What — the two-paragraph shape) +//! alktunnels: arbitrary bidirectional tunnels over alkcall channels. +//! (crate docs per overview.md §What, the two-paragraph shape) pub mod error; pub mod params; @@ -35,32 +35,32 @@ pub mod consumer; // Public API surface: re-exports filled in by subsequent tasks (convention 16). ``` -- `src/error.rs` — `TunnelError` (thiserror; convention 2) + the typed +- `src/error.rs`: `TunnelError` (thiserror; convention 2) + the typed open-error surface re-exported from alkcall (`ChannelOpenError`, `establishment_reason`-shaped helper). Skeleton only; filled by `tunnels/params` and `tunnels/consumer-session`. -- `src/params.rs` — `TunnelParams {resource, substrate}` + +- `src/params.rs`: `TunnelParams {resource, substrate}` + `Substrate` enum (`tcp | udp | unix`) + `tunnel_open_spec()` builder + `TUNNEL_OPEN_SCOPE`/`OP_TUNNEL_OPEN`/`TUNNEL_ALPN` constants. Skeleton with type definitions; the schema builder + tests land in `tunnels/params`. -- `src/wire.rs` — the codec module doc (ADR-003 summary + bast.md +- `src/wire.rs`: the codec module doc (ADR-003 summary + bast.md pointer); skeleton for `frame_datagram`/`DatagramReader`/ `DatagramCodecError`, filled by `tunnels/wire-codec`. -- `src/producer.rs` — module doc (producer.md summary); skeleton for +- `src/producer.rs`: module doc (producer.md summary); skeleton for `tunnel_open_spec` wiring, `register_tunnel_openable`, the establisher - shape, the pump handler — filled by `tunnels/producer-open-op` + + shape, and the pump handler, filled by `tunnels/producer-open-op` + `tunnels/producer-listen`. -- `src/consumer.rs` — module doc (consumer.md summary); skeleton for - `TunnelSession` — filled by `tunnels/consumer-session`. +- `src/consumer.rs`: module doc (consumer.md summary); skeleton for + `TunnelSession`, filled by `tunnels/consumer-session`. ### Keep in place / verify - Cargo.toml: alkcall 0.7.0 pin, the wasm-clean tokio subset, the empty `[features]` table (the `local` feature is added by `tunnels/local-socket-halves`). - `bytes`, `serde`, `serde_json`, `thiserror`, `tracing`, `futures` stay; - drop `async-trait` IF the skeleton confirms no trait is needed (ADR-004 — - it likely is not; note the decision in Summary if dropped). + drop `async-trait` IF the skeleton confirms no trait is needed + (per ADR-004, it likely is not; note the decision in Summary if dropped). - The default crate stays wasm-clean: `cargo check --target wasm32-unknown-unknown` must pass at every task in this graph (convention 4; the structural guard). @@ -88,17 +88,17 @@ pub mod consumer; docs pointing at the spec docs + ADRs and `tunnels/*` tasks that fill them in. - Constants placed early (`OP_TUNNEL_OPEN`, `TUNNEL_ALPN`, - `TUNNEL_OPEN_SCOPE` in params.rs — they are part of the wire surface, + `TUNNEL_OPEN_SCOPE` in params.rs, which are part of the wire surface, not producer-only, so they live in the shared module per the module map's "params.rs … + open-op input schema"). - `establishment_reason` is a thin delegation to `ChannelOpenError::establishment_reason` (alkcall 0.7.0 has the method upstream); the crate-level helper keeps the POC's free-fn shape for consumers and re-exports the typed error. -- `MAX_DATAGRAM_LEN` (65535) placed in wire.rs — the one codec constant +- `MAX_DATAGRAM_LEN` (65535) placed in wire.rs, the one codec constant ADR-003 pins regardless of the codec functions landing later. - **`async-trait` dropped** per the task's IF: the skeleton confirms no - trait is needed (ADR-004 — halves functions, not traits); + trait is needed (ADR-004 calls for halves functions, not traits); `cargo check` passes without it. ## Summary @@ -108,5 +108,5 @@ docs + re-exports); Cargo.toml unchanged except `async-trait` dropped (ADR-004 confirmation). Verified: `cargo check`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo test`, `cargo check --target wasm32-unknown-unknown`, -`cargo clippy --target wasm32-unknown-unknown -- -D warnings` — all -clean. \ No newline at end of file +`cargo clippy --target wasm32-unknown-unknown -- -D warnings`: +all clean. \ No newline at end of file diff --git a/tasks/tunnels/decide-take-framed-halves.md b/tasks/tunnels/decide-take-framed-halves.md index 9266172..d1ebe95 100644 --- a/tasks/tunnels/decide-take-framed-halves.md +++ b/tasks/tunnels/decide-take-framed-halves.md @@ -1,6 +1,6 @@ --- id: tunnels/decide-take-framed-halves -name: "N-3 decision — take_halves on datagram sessions hands RAW FRAMED bytes; decide the API posture before the first external consumer" +name: "N-3 decision: take_halves on datagram sessions hands RAW FRAMED bytes; decide the API posture before the first external consumer" status: completed depends_on: [tunnels/review-impl] scope: single @@ -16,7 +16,7 @@ completed: 2026-09-08 Review 001 finding N-3 (`docs/reviews/001-implementation-review.md`): a datagram session exposes `send_datagram`/`recv_datagram` (codec applied) until -`take_halves` — then the caller owns RAW halves whose wire bytes carry +`take_halves`; then the caller owns RAW halves whose wire bytes carry the codec framing (`[len: u16 BE][payload]`, documented on `take_halves`). The doc covers it, but it is a footgun asymmetry: the same session flips from "codec applied" to "codec is yours" depending @@ -36,23 +36,23 @@ before then. The options on the table: 3. **Re-shape `take_halves` for datagram sessions** (breaking, before the window closes): e.g. a datagram session's `take_halves` returns framed-preserving halves by default, raw access via a distinct - explicit method — removes the footgun entirely, at the cost of + explicit method, which removes the footgun entirely at the cost of diverging `take_halves` semantics per substrate. The decision should weigh: the pump_against shape (a UDP session pumped against an accepted local half needs the FRAMED adapter on the -accepted side — the same framing-responsibility seam N-11's doc note +accepted side (the same framing-responsibility seam N-11's doc note covers), what an actual consumer (alkcall's assembly layer, a DNS-proxy shape) reaches for first, and whether a `DatagramReader` + write-frame wrapper pair is small enough to just ship (option 2 is a -thin compose of existing public pieces — `src/wire.rs` already +thin compose of existing public pieces (`src/wire.rs` already exposes both codec halves of the job). ## Work - Re-read review 001 §N-3 + ADR-005 (the API-stability window); confirm no external consumer has pinned the `take_halves` import - style yet (if one has, the window is closed — record that and + style yet. If one has, the window is closed; record that and choose the additive path (2) or docs-only (1)). - Write the decision as an ADR amendment (ADR-005) or a new ADR if the posture adds a public method; file the OQ only if the decision @@ -75,20 +75,20 @@ exposes both codec halves of the job). ## Resolution (2026-09-08) -**Option 1 — keep as-is, docs carry it.** Recorded as ADR-005's +**Option 1: keep as-is, docs carry it.** Recorded as ADR-005's §Amendment (2026-09-08, N-3). Decision inputs: - **No external consumer exists** (workspace grep verified 2026-09-08) - — the window is open, so the breaking reshape (option 3) was + so the window is open and the breaking reshape (option 3) was available but nothing demonstrates the need. - One `take_halves` semantic across substrates (the tunnel's wire bytes, uninterpreted) beats per-substrate divergence; the framing asymmetry is ADR-003's spec, not drift. - The `pump_against` seam composes the framed ADAPTER (`UdpHalf`, - ADR-003's boundary codec — landed with the U-1 remediation the same + ADR-003's boundary codec, which landed with the U-1 remediation the same day), not framed halves; option 2 would not feed that use. - Option 2 (`take_framed_halves`) is additive and can land after the - first consumer defines its shape without breaking anything — + first consumer defines its shape without breaking anything, so deferring costs nothing. Design note recorded in the ADR: an `AsyncRead`-faced wrapper re-collides with F-2's empty-datagram/EOF ambiguity; the codec-side framing wants diff --git a/tasks/tunnels/direct-op.md b/tasks/tunnels/direct-op.md index b07ff96..25af890 100644 --- a/tasks/tunnels/direct-op.md +++ b/tasks/tunnels/direct-op.md @@ -1,6 +1,6 @@ --- id: tunnels/direct-op -name: Direct op — params, spec, establisher, consumer open_direct (ADR-007) +name: Direct op, params, spec, establisher, consumer open_direct (ADR-007) status: completed depends_on: [] scope: broad @@ -16,12 +16,12 @@ Implement the direct op (`channels/tunnel/direct`, ADR-007) end to end: the params type + spec builder (`src/params.rs`), the establisher + registration (`src/producer.rs`), and the consumer constructor (`src/consumer.rs`). The op rides the same `alk/tunnel` ALPN (ADR-002 -Amendment 1's flavor-form op ids — the upstream discovery derivation +Amendment 1's flavor-form op ids; the upstream discovery derivation landed in alkcall 0.8.0, ADR-047 amendment 3). This is the ssh `direct-tcpip` / SOCKS5 CONNECT egress capability with structural ACL separation from `tunnel:open`. -### params.rs — types + spec +### params.rs: types + spec ```rust pub const OP_TUNNEL_DIRECT: &str = "channels/tunnel/direct"; @@ -29,7 +29,7 @@ pub const TUNNEL_DIRECT_SCOPE: &str = "tunnel:direct"; /// The substrate-shaped address object (ADR-007 §2): keyed by the /// sibling `substrate` field of the params. Also the `peer` value -/// shape of the forwarded op (ADR-008 §3) — define once here. +/// shape of the forwarded op (ADR-008 §3); define once here. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum SubstrateAddr { @@ -50,10 +50,10 @@ pub fn tunnel_direct_spec() -> OperationSpec - Spec per wire.md §The Direct Op: `Sub`-typed, `Visibility::External`, `ChannelOpenSpec::new(TUNNEL_ALPN)`, scope `["tunnel:direct"]` - (**never implied by `tunnel:open`** — ADR-006 Amendment 1), output + (**never implied by `tunnel:open`**, ADR-006 Amendment 1), output schema `{channel_id: integer > 0}`. - `input_schema` pins both fields required and the target sub-schema - per-substrate — a `oneOf` on the sibling `substrate` value + per-substrate, as a `oneOf` on the sibling `substrate` value (`tcp`/`udp` → `{host, port}`, `unix` → `{path}`), so malformed targets fail the registry's schema validation (the typed `INVALID_INPUT` class) **before** the establisher runs. The @@ -62,9 +62,9 @@ pub fn tunnel_direct_spec() -> OperationSpec - `deny_unknown_fields` on both params types (the loud-rejection posture, ADR-001's precedent). - The `Substrate` enum is reused as the discriminator (no new - substrate values — ADR-001's additive rule). + substrate values (ADR-001's additive rule). -### producer.rs — the establisher + registration +### producer.rs: the establisher + registration ```rust pub fn direct_establisher(dial: DialFn) -> OpenEstablisher @@ -82,28 +82,28 @@ pub fn register_tunnel_direct_openable( `TunnelDirectParams`, renders the target to the same backing-string form the registry backings use (`"host:port"` for tcp/udp, the path for unix), and calls the injected `DialFn(substrate, rendered)`. - No second dial type — a producer that registered `dial_tcp` for the + No second dial type: a producer that registered `dial_tcp` for the base op registers the direct op with zero new substrate code (ADR-007's `local` interaction note). The UDP framed-adapter wrap (ADR-003) happens inside the dial closure (the `local` feature's - `connect_udp` already does this) — the establisher stays + `connect_udp` already does this), so the establisher stays substrate-agnostic. -- **No registry lookup** — `unknown_resource` can never fire +- **No registry lookup**: `unknown_resource` can never fire (ADR-007 §2). Error mapping (ADR-007 §4's table minus the registry row): the serde-parse failure of a schema-valid-shaped target maps - `HandlerError` (the schema gate should have caught it — the parse + `HandlerError` (the schema gate should have caught it; the parse is the backstop); dial failures pass through the `DialFn`'s own typed errors unchanged (`dial_failed` / `resource_shortage` / `handler_error`). -- The per-call `auth` carries the opener identity (CF-006) — the +- The per-call `auth` carries the opener identity (CF-006); the witness seam records it, same as the base op (the direct op is the arbitrary-egress capability; identity visibility here matters more, not less). - Registration uses the SAME pump handler (`make_tunnel_pump_handler` - — the direct op's plan payload is a `TargetHandle` exactly like the - base op's; plan-flow is unchanged). + (the direct op's plan payload is a `TargetHandle` exactly like the + base op's, and plan-flow is unchanged). -### consumer.rs — `open_direct` +### consumer.rs: `open_direct` ```rust pub async fn open_direct( @@ -116,27 +116,27 @@ pub async fn open_direct( - A separate constructor, not a params enum (ADR-007 §5: two scopes are two capabilities; the caller states which one it holds). Serializes `{substrate, target}`, `open_channel(OP_TUNNEL_DIRECT, …, - TUNNEL_ALPN)`, `from_halves` — identical session type, data planes, - and teardown API as `open`. + TUNNEL_ALPN)`, `from_halves`, with an identical session type, data + planes, and teardown API as `open`. - Uses plain `open_channel` (no reply fields expected on the direct - op — dial establishers never bind; wire.md §The Direct Op). + op, because dial establishers never bind; wire.md §The Direct Op). - Documented error surface: the base open errors minus `unknown_resource`; malformed targets are the schema/ `invalid_input`-class failure (registry-side); an old producer - rejects the op loudly (`NOT_FOUND` — the SSH posture). + rejects the op loudly (`NOT_FOUND`, the SSH posture). ### Tests Unit (params.rs): `TunnelDirectParams` round-trip per substrate; untagged `SubstrateAddr` disambiguation (`{host, port}` vs `{path}`); `deny_unknown_fields` rejection; spec-shape conformance to wire.md §The -Direct Op (op id, scope, oneOf schema structure — structural +Direct Op (op id, scope, oneOf schema structure; structural assertions, no jsonschema dep here, per `tunnels/params`'s precedent). Integration (extend the duplex harness from `tunnels/producer-open-op`): - `open_direct` e2e round-trip through `pump_bidi` (tcp duplex target). -- `open_direct` udp e2e (the framed adapter wraps at the dial closure — - datagram boundary preserved). +- `open_direct` udp e2e (the framed adapter wraps at the dial closure, so the datagram + boundary is preserved). - Malformed target (unix without `path`) → schema-class failure, no phantom session. - Producer without the direct op registered → `NOT_FOUND`-class. @@ -175,7 +175,7 @@ Integration (extend the duplex harness from `tunnels/producer-open-op`): - The `input_schema` pins the per-substrate target sub-schema as two `if`/`then` branches under `allOf`, keyed on the sibling `substrate` value (tcp/udp → `{host, port}` with - `additionalProperties: false`; unix → `{path}`, same posture) — + `additionalProperties: false`; unix → `{path}`, same posture); JSON Schema `if/then` composes through alkcall's jsonschema 0.46 compile, and the integration tests prove the gate fires (`INVALID_INPUT` before the establisher). Structural assertions @@ -189,11 +189,11 @@ Integration (extend the duplex harness from `tunnels/producer-open-op`): `direct_establisher` maps `TunnelEstablishError::HandlerError`). This asymmetry is documented in both the type docs and the test. - `TunnelDirectParams::render_target()` centralizes the backing-string - form (`"host:port"` / path) — the establisher injects the SAME + form (`"host:port"` / path); the establisher injects the SAME `DialFn` with the rendered string; the render is pinned by test to the registry-backing form. - `TunnelSession::open_direct` uses plain `open_channel` (no reply - fields — dial establishers never bind); `SubstrateAddr` re-exports + fields, since dial establishers never bind); `SubstrateAddr` re-exports as the future forwarded-op `peer` shape (ADR-008 §3, defined once). - Harness: `RegistrationMode::Direct`, `wire_direct` / `wire_direct_with_identity` (the raw-call topology), @@ -206,7 +206,7 @@ Integration (extend the duplex harness from `tunnels/producer-open-op`): Implemented the direct op end to end per ADR-007: -- **params.rs** — `OP_TUNNEL_DIRECT` / `TUNNEL_DIRECT_SCOPE` +- **params.rs**: `OP_TUNNEL_DIRECT` / `TUNNEL_DIRECT_SCOPE` constants; `SubstrateAddr` (untagged `{host, port}` | `{path}`, the ADR-008 §3 `peer` shape too); `TunnelDirectParams` (`deny_unknown_fields`, `render_target()`); `tunnel_direct_spec()` @@ -215,27 +215,27 @@ Implemented the direct op end to end per ADR-007: per-substrate `if/then` target sub-schema). 7 unit tests: round- trips per substrate, untagged disambiguation, loud unknown-field rejection, spec-shape conformance, schema structure. -- **producer.rs** — `direct_establisher` / +- **producer.rs**: `direct_establisher` / `direct_establisher_with_witness` (parse → render → injected `DialFn`; no registry lookup; typed-error mapping per ADR-007 §4 minus the registry row; CF-006 witness seam) + `register_tunnel_direct_openable` (direct spec + establisher + the SAME `make_tunnel_pump_handler`, plan-flow unchanged). -- **consumer.rs** — `TunnelSession::open_direct(client, target, +- **consumer.rs**: `TunnelSession::open_direct(client, target, substrate)`: separate constructor, serializes `{substrate, target}`, plain `open_channel(OP_TUNNEL_DIRECT, …, TUNNEL_ALPN)`, - `from_halves` — identical session/data-planes/teardown as `open`. -- **lib.rs** — the new surface re-exported. + `from_halves`, with identical session/data-planes/teardown as `open`. +- **lib.rs**: the new surface re-exported. -Tests: `tests/producer_direct_op.rs` — 15 integration tests over the +Tests: `tests/producer_direct_op.rs` has 15 integration tests over the duplex harness: tcp + udp e2e round-trips through `pump_bidi` (datagram boundary preserved via the framed dial closure), malformed targets (`unix` without `path`, `tcp` without `port`, missing target) → `INVALID_INPUT` with no phantom channel/session, dial-failure pass-through (`dial_failed`), NOT_FOUND on a producer without the op, scope separation both ways (`tunnel:open` ↛ `tunnel:direct` and -`tunnel:direct` ↛ `tunnel:open` — FORBIDDEN, raw-call AND session- -level), CF-006 witness on the direct establisher (raw + session +`tunnel:direct` ↛ `tunnel:open`, all FORBIDDEN (raw-call AND +session-level), CF-006 witness on the direct establisher (raw + session paths), the establisher parse backstop (`handler_error`), and the target-rendering pin (the dial closure observes the exact backing-string form). Harness: `Direct` registration mode + @@ -243,6 +243,6 @@ backing-string form). Harness: `Direct` registration mode + Verified: `cargo test` green (93 native; 94 with `--all-features`), clippy `-D warnings` clean (native + wasm32), fmt clean, wasm32 -check passes (default crate stays wasm-clean — no substrate code +check passes (default crate stays wasm-clean; no substrate code was added), `cargo doc --no-deps` clean. CHANGELOG updated (Unreleased → Added). \ No newline at end of file diff --git a/tasks/tunnels/end-to-end-suite.md b/tasks/tunnels/end-to-end-suite.md index 84cb90a..ac43903 100644 --- a/tasks/tunnels/end-to-end-suite.md +++ b/tasks/tunnels/end-to-end-suite.md @@ -1,6 +1,6 @@ --- id: tunnels/end-to-end-suite -name: End-to-end suite — forward, reverse, listen, datagram, teardown matrix +name: End-to-end suite: forward, reverse, listen, datagram, teardown matrix status: completed depends_on: [tunnels/consumer-session, tunnels/producer-listen, tunnels/local-socket-halves] scope: broad @@ -15,7 +15,7 @@ tags: [tests, integration, phase-gate] Consolidate the integration coverage into `tests/` as the crate's end-to-end suite: every validated POC behavior, re-expressed against the crate's public API (not the POCs' internals). This is the quality gate -before the implementation review — the suite is the spec's executable +before the implementation review; the suite is the spec's executable form. ### Suites @@ -24,8 +24,8 @@ form. `pump_bidi` → round-trip; 1 MiB backpressure; typed errors (unknown_resource / dial_failed / FORBIDDEN / timeout-adjacent); per-call opener identity witness (CF-006). -2. **Reverse (`-R`)**: hub initiator — `open_reverse_channel` + - `adopt` + `pump_against`; odd-ID allocation asserted (ADR-047 §5); +2. **Reverse (`-R`)**: hub initiator (`open_reverse_channel` + + `adopt` + `pump_against`); odd-ID allocation asserted (ADR-047 §5); identity precedence chain (transport alone / `ServingConfig. identity` override / token override / identity-less fail-closed); half-close (W4); out-of-band `channel/close` + self-reaping (W3); @@ -33,16 +33,17 @@ form. 3. **Listen producer**: the accept-queue flow end-to-end + its typed errors (from `tunnels/producer-listen`). 4. **Datagram (`udp`)**: forward + reverse datagram sessions via the - codec — round-trip, empty datagram (the F-2 layering), chunk-split + codec: round-trip, empty datagram (the F-2 layering), chunk-split survival, TCP+UDP concurrent channels on one connection. 5. **Teardown matrix** (ADR-005): close / join (with + without pump) / - Drop — `channel_ids()` asserts no leaks on either side after each. + Drop, with `channel_ids()` asserting no leaks on either side after + each. 6. **`local` feature suite**: real sockets end-to-end (from - `tunnels/local-socket-halves` — runs after that task lands; the + `tunnels/local-socket-halves`; runs after that task lands, though the duplex-transport suites above do not need it). Transport stand-in: `tokio::io::duplex` (both POCs' harness; the -alkcall layer owns real transports — unchanged scope). The suite may +alkcall layer owns real transports; unchanged scope). The suite may share one harness module (`tests/common/`); the POC harnesses (`/workspace/alktunnels-udp-poc/src/harness.rs`, `/workspace/alktunnels-reverse-poc/src/harness.rs`) are the two @@ -50,18 +51,18 @@ topologies to unify. ### The spec-conformance assertions to keep visible -- A failed open never returns a `channel_id` (no phantom channel — +- A failed open never returns a `channel_id` (no phantom channel; both sides' `channel_ids()` hold only 0 afterward). - A failed adopt never leaks the session (Drop reaps). - The pump handler's `JoinHandle` tracks the data plane (a test that - simulates early-return would hang/EOF-instantly — the R-02 shape; - assert the correct shape survives). + simulates early-return would hang/EOF-instantly, which is the + R-02 shape; assert the correct shape survives). ## Acceptance Criteria - [ ] All 6 suites present; ≥20 integration tests total - [ ] `cargo test` green; `cargo test --features local` green -- [ ] Repeat-run stable (3× clean — the POCs' flakiness bar) +- [ ] Repeat-run stable (3× clean, the POCs' flakiness bar) - [ ] Clippy/fmt clean; wasm32 check passes ## References @@ -84,21 +85,21 @@ task consolidated the remaining POC-validated behaviors they didn't cover into `tests/end_to_end.rs` (18 tests, the suites as enumerated) and extended `tests/harness.rs`: -- `RegistrationMode::Timeout(Duration)` — a hanging establisher with +- `RegistrationMode::Timeout(Duration)`: a hanging establisher with an explicit per-registration establishment timeout (the ADR-049 §2 deadline-expiry probe; the wrapper maps expiry to reason `timeout` with no channel surviving). -- `wire_serving_identity` — the `ServingConfig.identity` override +- `wire_serving_identity`, the `ServingConfig.identity` override probe shape (CF-005 (a)). Gaps the suite filled (behaviors the per-task suites did not cover): 1 MiB backpressure round-trip (both POCs' sizing probe); the deadline→`timeout` wire mapping; odd-ID allocation asserts (ADR-047 §5) -plus distinctness; the FULL identity precedence chain — the +plus distinctness; the FULL identity precedence chain, including the `ServingConfig.identity` override and the token-over-transport precedence with a DISTINCT resolved id (the existing token test resolved to the same id as the transport identity, so it proved -authorization but not precedence — the new `DistinctTokenProvider` +authorization but not precedence. The new `DistinctTokenProvider` probe proves precedence); reverse datagram sessions via the codec (pump-less `(0, 0, reaped)` join shape); 40-datagram bursts with batching (the pending-queue cover, both directions); the 7-byte @@ -109,15 +110,15 @@ failed-adopt no-leak arm; R-02's JoinHandle-tracks-the-data-plane assertion (alive-and-pumpable long after the open, completion only at both-direction EOF). -### Test-side findings (not product bugs — recorded for the record) +### Test-side findings (not product bugs; recorded for the record) - **Parked ≠ adopted.** `teardown_channel` on a reverse-open'd but - never-adopted channel id is `UnknownChannel` — the manager parks + never-adopted channel id is `UnknownChannel`; the manager parks early arrivals for unseen ids (the adoption-race cover, ADR-047 §5) but does not install a channel entry until adopt. Cleanup for such ids is adopt-then-close (what the tests do). Worth knowing when an assembly layer errors between `open_reverse_channel` and `adopt`. -- **`read(&mut Vec)` vs `read(buf)`** — an unpopulated `Vec` passed to +- **`read(&mut Vec)` vs `read(buf)`**: an unpopulated `Vec` passed to `AsyncReadExt::read` has len 0 and reads nothing (instant EOF); the 1 MiB loop needed a size-`expected` buffer with `&mut received[got..]`. - The empty-queue-vs-error posture from `tunnels/producer-listen` @@ -149,6 +150,6 @@ Existing suites untouched. Verification: `cargo test` green (68 tests incl. unit + doc), `cargo test --features local` green (75), 3× repeat-run clean (both feature configs), clippy `-D warnings` clean (all-targets, `--features local`, wasm32), `cargo fmt --check` clean, -wasm32 check passes. No product-code changes were needed — the +wasm32 check passes. No product-code changes were needed; the failures during development were all test-harness bugs (zero-len read buffer, teardown-on-parked-id), recorded above. \ No newline at end of file diff --git a/tasks/tunnels/fix-udp-framed-adapter.md b/tasks/tunnels/fix-udp-framed-adapter.md index c562a9e..dc56383 100644 --- a/tasks/tunnels/fix-udp-framed-adapter.md +++ b/tasks/tunnels/fix-udp-framed-adapter.md @@ -1,6 +1,6 @@ --- id: tunnels/fix-udp-framed-adapter -name: "U-1 remediation — make UdpHalf the FRAMED adapter (ADR-003 at the local boundary)" +name: "U-1 remediation: make UdpHalf the FRAMED adapter (ADR-003 at the local boundary)" status: completed depends_on: [tunnels/review-impl] scope: moderate @@ -15,17 +15,17 @@ completed: 2026-09-08 Review 001 finding U-1 (`docs/reviews/001-implementation-review.md`): the `local` feature's `UdpHalf` (`src/local/mod.rs`) is NOT the framed -adapter ADR-003 mandates at the substrate boundary — it is +adapter ADR-003 mandates at the substrate boundary: it is boundary-preserving (one datagram per poll_read/poll_write) but raw. The `[len: u16 BE]` codec exists only on the session side, so UDP-over-`local` tunnels: 1. deliver codec-framed bytes to real targets (the target sees - `[len]payload`, not payload — executable-pinned in the review + `[len]payload`, not payload; executable-pinned in the review probes); 2. tear the tunnel down on an empty datagram (a raw zero-length datagram is `Ok(())` with 0 bytes filled = EOF-shaped to - `tokio::io::copy` — the F-2 violation end-to-end; the session-side + `tokio::io::copy`, the F-2 violation end-to-end; the session-side codec exists precisely to prevent this); 3. split datagrams > ~8 KiB at the target (`tokio::io::copy`'s `DEFAULT_BUF_SIZE` bounds each read; the raw adapter sends each @@ -34,38 +34,38 @@ UDP-over-`local` tunnels: codec parses a raw datagram's first two bytes as a length header). The session-side tests pass because the harness's -`framed_udp_echo_dial` frames symmetrically at the test boundary — a +`framed_udp_echo_dial` frames symmetrically at the test boundary, a correct-shape stand-in for what `connect_udp` itself must do. ### The fix Apply ADR-003's placement: `UdpHalf` becomes the framed adapter at the -substrate boundary — **as seen from the pump (the channel side)**: +substrate boundary, **as seen from the pump (the channel side)**: - `poll_write` is fed CHANNEL bytes (already `[len: u16 BE][payload]` frames from the session's codec) → **de-frame** (an incremental `DatagramReader` held on the write half) and send each decoded payload to the target as one raw datagram. `len=0` decodes to a - zero-payload datagram — a real empty datagram, NOT EOF. + zero-payload datagram, i.e. a real empty datagram, NOT EOF. - `poll_read` yields bytes destined for the CHANNEL (the session's codec decodes there) → **frame** (`frame_datagram`) each raw target datagram and hand the frame bytes to the pump. Both halves of the association then speak the same framing on both faces; the target sees payload-only bytes, and an empty datagram -crosses the pump as its 2-byte frame (unambiguous with EOF — the F-2 +crosses the pump as its 2-byte frame (unambiguous with EOF, the F-2 fix end-to-end). **Orientation caution** (the review's §U-1 sketch had this inverted -and was corrected in re-validation — read the review's §Re-validation +and was corrected in re-validation; read the review's §Re-validation before implementing): "frame on write, de-frame on read" describes the TARGET's perspective. `UdpHalf`'s caller is the pump; the channel side is the framed side. Getting the orientation wrong double-frames the channel→target direction and leaves unframed target→channel -traffic undecodable — the U-1 defects survive the wrong fix. +traffic undecodable; the U-1 defects survive the wrong fix. The recv scratch (U-3 interplay): the de-frame path needs a FULL -65535-byte recv (tokio's `try_recv` truncates silently — a smaller +65535-byte recv (tokio's `try_recv` truncates silently, so a smaller scratch corrupts the framing before any size check runs). Any memory mitigation must keep the full-size read underneath; see U-3 below. @@ -76,7 +76,7 @@ mitigation must keep the full-size read underneath; see U-3 below. (session `recv_datagram` yields `Some(b"")`, channel alive). 2. A >8 KiB datagram round-trips as ONE datagram at the target. 3. An unframed target-initiated datagram resolves at the session. -4. The existing `local_halves` suite stays green — EXCEPT +4. The existing `local_halves` suite stays green, EXCEPT `truncation_fails_loud_oq_tn_13`, whose assertion surface may legitimately change: once framing composes, a >caller-buffer frame is a partial-drain read (or an equivalent typed failure) rather @@ -89,31 +89,31 @@ mitigation must keep the full-size read underneath; see U-3 below. ### Riders (from the same review, same file) -- U-2 (bind posture, sharpened during review follow-up — read the +- U-2 (bind posture, sharpened during review follow-up; read the whole bullet before implementing): the crate must NOT wildcard-bind implicitly. Two distinct cases: - - `connect_udp` binds `("0.0.0.0", 0)` — this is a CLIENT socket + - `connect_udp` binds `("0.0.0.0", 0)`: this is a CLIENT socket (bind-ephemeral → `connect(target)`; the kernel delivers only datagrams from the connected peer), so its internet-exposure - profile is small — but the v4 hardcoded bind breaks v6-only + profile is small, but the v4 hardcoded bind breaks v6-only targets at connect, and wildcard binds are a bad default regardless. Fix: resolve the target first, bind the UNSPECIFIED address of the MATCHING family (`0.0.0.0:0` for v4 targets, - `[::]:0` for v6). Do NOT bind loopback (`127.0.0.1:0`) — that + `[::]:0` for v6). Do NOT bind loopback (`127.0.0.1:0`); that would break every non-loopback target (a query to `8.8.8.8` can't route from a loopback-bound socket). No user-supplied bind address - here unless a caller actually needs source-address selection — + here unless a caller actually needs source-address selection; keep the signature unless that need exists. - `bind_tcp` (the listener half) already REQUIRES the caller to - supply the address — that is the correct posture (the assembly + supply the address; that is the correct posture (the assembly layer decides loopback vs a public interface). Keep it; add a doc warning that a wildcard bind (`0.0.0.0` / `::`) is the caller's explicit choice and exposes the listener per the host's firewall. - - Note: `::1` is loopback (the v6 `127.0.0.1`), NOT a wildcard — + - Note: `::1` is loopback (the v6 `127.0.0.1`), NOT a wildcard, as the risky v6 address is `::`. The rule is "no implicit wildcard binds", not "no v6 loopback". - U-3: the per-tunnel 65535-byte recv scratch is correctness- - load-bearing ON THE DE-FRAME PATH — tokio's `try_recv` truncates + load-bearing ON THE DE-FRAME PATH, because tokio's `try_recv` truncates SILENTLY, so a scratch smaller than the largest in-flight datagram would corrupt the framing before any size check could reject it (a truncated frame decodes as a shorter declared length, @@ -139,19 +139,19 @@ All acceptance criteria met. Key implementation notes beyond the sketch (pinned in ADR-003's §Amendment): - **Orientation** (the caution held): de-frame on write, frame on - read — the channel side is the framed side. + read; the channel side is the framed side. - **The write half accepts ALL offered bytes** and queues completed datagrams for the socket: tokio's `copy` re-offers a FULL window unsatisfied until drained (its `read_done` path never tops up), so the "hold the partial tail, re-feed" shape deadlocked gate 2 (the 9000-byte test) and `Ready(Ok(0))` on a partial-only feed tripped the copy's `WriteZero` abrupt-close. The `DatagramReader`'s - partial-frame state is bounded by construction (u16 cap — review + partial-frame state is bounded by construction (u16 cap, per review 001 N-2); backpressure is an accepted-byte high-water mark (128 KiB), and the socket drain pumps on write/flush/shutdown (copy polls flush after every window drain). - **A >caller-buffer frame stages across reads** (length prefix - first, remainder on the next read) — gate 4's predicted + first, remainder on the next read), which is gate 4's predicted assertion-surface change. The fail-loud probe is now the 65507-byte max datagram reassembling through 16 KiB reads (`truncation_fails_loud_oq_tn_13`, rewritten) plus the direct @@ -161,7 +161,7 @@ sketch (pinned in ADR-003's §Amendment): wildcard-exposure doc warning. Pinned by a real `::1` round-trip (`connect_udp_binds_the_matching_family_v6_targets_dial`). - **U-3**: the 65535 scratch allocates lazily on first recv (the - full-size read kept — the MTU-shrink idea confirmed unsafe). + full-size read kept; the MTU-shrink idea confirmed unsafe). Verification: 63 default / 76 `local` tests green ×3 repeat runs; clippy `-D warnings` (all-targets + wasm32), fmt, doc, publish @@ -170,7 +170,7 @@ dry-run, wasm check all clean. ## References - `docs/reviews/001-implementation-review.md` §U-1 (executable-pinned - probe evidence), §U-2, §U-3, §N-1 — and §Re-validation (2026-09-08: + probe evidence), §U-2, §U-3, §N-1, and §Re-validation (2026-09-08: findings re-confirmed by fresh probes; the fix orientation corrected; gates 4 and U-3 adjusted as above) - `docs/architecture/decisions/003-codec-and-udp-framing.md` (the diff --git a/tasks/tunnels/forwarded-receipt.md b/tasks/tunnels/forwarded-receipt.md index 66b978f..d3e6b17 100644 --- a/tasks/tunnels/forwarded-receipt.md +++ b/tasks/tunnels/forwarded-receipt.md @@ -13,7 +13,7 @@ tags: [opener, forwarded-op, serving, adopt, correlation] ## Description Implement the forwarded op's SERVING side (the listen-opener half, -ADR-008 §3): `register_tunnel_forwarded_openable` — the +ADR-008 §3): `register_tunnel_forwarded_openable`, the `register_openable` shape (no establisher; the data plane starts immediately), scope-gated `tunnel:forwarded`, whose handler parses `TunnelForwardedParams`, hands the correlation material to the @@ -46,38 +46,40 @@ pub fn register_tunnel_forwarded_openable( ) -> Result<(), String> ``` -- **No establisher** (`register_openable` — the forwarded channel - needs no establishment; its data plane starts immediately — ADR-008 +- **No establisher** (`register_openable`; the forwarded channel + needs no establishment, since its data plane starts immediately, + ADR-008 §3). The wrapper's `channel_id` allocation IS the reply (the producer adopts it). - The built-in handler shape: parse `TunnelForwardedParams` (`deny_unknown_fields`; a malformed open → - the handler logs + returns early — teardown-at-birth is correct for + the handler logs + returns early, because teardown-at-birth is + correct for a malformed ingress), accept the `BiStream`, invoke the injected assembly handler with the parsed params + the stream. The assembly correlates (via `listen`), adopts nothing (the WRAPPER owns this - channel — the serving side allocated it; the assembly pumps the + channel: the serving side allocated it; the assembly pumps the BiStream against its local halves via `pump_bidi`, and the wrapper's R-02 tracking reaps on handler exit). - **Pump responsibility split (the honest-shape decision to make and document):** the injected `ForwardedHandler` receives the channel - `BiStream` and the params — the ASSEMBLY owns the pump + `BiStream` and the params; the ASSEMBLY owns the pump (`pump_bidi(forwarded_bidi, local_read, local_write)`), because the local halves are assembly state (the SOCKS5 BIND client's socket). The protocol-side built-in handler only bridges wrapper→assembly. - Await the injected handler's JoinHandle inline (R-02 — the + Await the injected handler's JoinHandle inline (R-02: the forwarded channel's lifetime tracks it). - Identity: the opener-side gate sees the PRODUCER's per-call - identity (CF-005 precedence — the producer may carry `auth_token` + identity (CF-005 precedence: the producer may carry `auth_token` when it is itself forwarding); the `peer` param is producer-asserted metadata, not transport truth (producer.md's - posture — ACL trusts the grant, app treats `peer` as metadata). + posture: ACL trusts the grant, app treats `peer` as metadata). The registration's `auth` + the per-call overlay handle this automatically (upstream machinery); nothing to implement, but the test suite pins it. - The `listen`-token correlation failure (no matching session) is the ASSEMBLY's rejection (it holds the map): the injected handler's - error path — drop the stream, return (teardown-at-birth reaps the + error path drops the stream and returns (teardown-at-birth reaps the channel). Document that a correlation miss tears the forwarded channel down; the producer's accept loop drops the accepted connection on the open's failure (the shapes mirror). @@ -86,7 +88,7 @@ pub fn register_tunnel_forwarded_openable( ```rust /// Extract the bind-first reply fields from an open-op success reply -/// (the full output Value — `channel_id` + optional `bound`/`listen`). +/// (the full output Value: `channel_id` + optional `bound`/`listen`). pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply ``` @@ -109,7 +111,7 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply handler logs + early return → the channel tears down (no phantom). - The reply reader: full/empty/extra-field replies parse per the additive posture (unit). -- Old-opener posture is upstream's registry lookup (`NOT_FOUND`) — +- Old-opener posture is upstream's registry lookup (`NOT_FOUND`); pin it in the producer-side test in `tunnels/bindfirst-producer` (cross-referenced, not duplicated here). @@ -129,21 +131,21 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply ## References - docs/architecture/decisions/008-listen-metadata-forwarded-op.md - (§3 the forwarded op — the opener-side serving shape; Amendment 1) + (§3 the forwarded op, the opener-side serving shape; Amendment 1) - docs/architecture/producer.md §The Forwarded Op; consumer.md §The Listen Flow; wire.md §The Forwarded Op - docs/architecture/decisions/006-access-control-posture.md Amendment 1 - tasks/tunnels/bindfirst-params.md (the types); - tasks/tunnels/bindfirst-producer.md (the caller half — the tests + tasks/tunnels/bindfirst-producer.md (the caller half; the tests compose) ## Notes - **`ForwardedHandler` (the injected assembly handler):** `Arc tokio::task::JoinHandle<()> + - Send + Sync>` — the correlation material the opener-side handler + Send + Sync>`, the correlation material the opener-side handler yields per forwarded open. The assembly owns the pump (the honest - split: the local halves — the SOCKS5 BIND client's socket — are + split: the local halves, the SOCKS5 BIND client's socket, are assembly state), correlating via the `listen` token against its own waiting-listener map; the protocol provides the op + params, not the wait state machine (ADR-008 §3's posture, realized). @@ -151,26 +153,27 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply `TunnelForwardedParams` (`deny_unknown_fields` + the landed positive-`listen` serde pin), accepts the channel `BiStream` (yield-once; accepting BEFORE the assembly handler runs keeps the - R-02 accepted-flag telemetry truthful — a parse-gated early return + R-02 accepted-flag telemetry truthful: a parse-gated early return never accepted, which the wrapper's birth-teardown hint reports), - then awaits the injected handler's `JoinHandle` inline (R-02 — the - forwarded channel's lifetime tracks it; the wrapper's tracking reaps + then awaits the injected handler's `JoinHandle` inline (R-02, since + the forwarded channel's lifetime tracks it; the wrapper's tracking + reaps on handler exit). -- **No establisher** — `register_openable` exactly (the forwarded - channel needs no establishment; the data plane starts immediately — +- **No establisher**, `register_openable` exactly (the forwarded + channel needs no establishment; the data plane starts immediately, ADR-008 §3). The wrapper's `channel_id` allocation IS the reply (the - producer adopts it) — nothing to implement, upstream machinery. + producer adopts it): nothing to implement, upstream machinery. - **Identity posture (upstream machinery, test-pinned):** the opener-side gate sees the PRODUCER's per-call identity (CF-005 - precedence — payload token > `ServingConfig.identity` > + precedence: payload token > `ServingConfig.identity` > transport); the `peer` param is producer-asserted metadata, not transport truth. Nothing implemented here; the FORBIDDEN probe pins the gate runs (identity without the grant → FORBIDDEN with the - missing scope named, never implied by `tunnel:open` — probed with a + missing scope named, never implied by `tunnel:open`; probed with a `tunnel:open`-only identity). - **Correlation-miss semantics (documented + test-pinned):** the `listen`-token miss is the ASSEMBLY's rejection (it holds the map): - the injected handler's error path drops the stream and returns — + the injected handler's error path drops the stream and returns; teardown-at-birth reaps the forwarded channel. The producer's accept loop drops the accepted connection on the open's failure (the shapes mirror; pinned producer-side in `tunnels/bindfirst-producer`'s @@ -178,31 +181,32 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply - **Reply reader lands in `src/params.rs`** as `listen_reply_fields` (the lenient, infallible session-surface projection) DELEGATING to the landed `parse_listen_reply` (the loud typed viewer): unknown - extra reply fields tolerated (the additive posture — no deny-unknown + extra reply fields tolerated (the additive posture; no deny-unknown gate on the reply), a malformed shape logs `tracing::error!` loud and surfaces both-`None` (the session presents `Option` fields and never refuses construction over an informational field; use `parse_listen_reply` directly when the loud typed distinction - matters). Both `Option` — absent for dial/accept-first producers; + matters). Both `Option`, absent for dial/accept-first producers; the projection is unit-testable independent of the session type (this task's placement rationale), consumed by `TunnelListener` (`tunnels/tunnel-listener`, pending). - **Test-data-plane shape (the harness):** the e2e drives the - forwarded channel through BOTH pumps — the opener-side assembly + forwarded channel through BOTH pumps: the opener-side assembly pump pops correlated duplex halves for the token (pushed BEFORE the - forwarded open — the waiting-session posture; a late push is the - miss shape, probed separately) and the producer-side test adopts the - opener-allocated ID and pumps it against a driving echo duplex — - then round-trips bytes from the client end through the whole path + forwarded open, the waiting-session posture; a late push is the + miss shape, probed separately), and the producer-side test adopts + the opener-allocated ID and pumps it against a driving echo + duplex, then round-trips bytes from the client end through the + whole path (assembly pump → forwarded channel → adopted pump → driving echo → back). The malformed-params probes pin the no-phantom property at both gates: `listen: 0` / missing `peer` fail the registry's schema (`INVALID_INPUT`, no channel ever); an unknown top-level field - passes the schema (additive posture) but fails the serde parse → + passes the schema (additive posture) but fails the serde parse, so the channel (whose ID the reply already carried) reaps promptly. - **Harness note:** the suite reuses the existing harness's `wire_with` + `Topology.consumer_registry` (the - `bindfirst-producer` shape) — no harness changes needed; the + `bindfirst-producer` shape), so no harness changes are needed; the forwarded-op serving stub helpers (`make_forwarded_stub_handler` etc.) stay as-is (they remain the producer-leg stub stand-in; the real opener-side registration is this task's, exercised directly). @@ -210,30 +214,30 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply ## Summary Implemented the forwarded op's SERVING side (the listen-opener half, -ADR-008 §3 as amended) per the task: `src/producer.rs` — +ADR-008 §3 as amended) per the task: `src/producer.rs` gained `ForwardedHandler` (the injected assembly handler: parsed params + channel `BiStream`; the assembly owns the pump, correlating by the `listen` token against its waiting-listener bookkeeping); `make_forwarded_op_handler` (the built-in no-establisher wrapper: -deny-unknown params parse — a malformed open fails loud → early +deny-unknown params parse, so a malformed open fails loud → early return → teardown-at-birth reaps the channel, no phantom; `accept_bi` FIRST per R-02 telemetry; the injected handler's `JoinHandle` awaited -inline); `register_tunnel_forwarded_openable` (`register_openable` — +inline); `register_tunnel_forwarded_openable` (`register_openable`, no establisher, the data plane starts immediately; scope gate -`tunnel:forwarded` via `tunnel_forwarded_spec`). `src/params.rs` — -`listen_reply_fields` (the lenient, infallible reply projection: +`tunnel:forwarded` via `tunnel_forwarded_spec`). `src/params.rs` +gained `listen_reply_fields` (the lenient, infallible reply projection: unknown extra fields tolerated, a malformed shape logs loud and surfaces both-`None`; shared with `TunnelListener`), delegating to the landed `parse_listen_reply`. Re-exported through `lib.rs`. Tests: 4 new unit tests in `params.rs` (full/bare/extra-field/malformed reply projection) + 4 integration tests in `tests/producer_forwarded.rs` -(the serving-half e2e round-trip gate — reply projection → token +(the serving-half e2e round-trip gate: reply projection → token correlation → forwarded open → byte round-trip through the assembly -pump + the producer-side adopted pump; the FORBIDDEN scope gate — +pump + the producer-side adopted pump; the FORBIDDEN scope gate: `tunnel:open`-only identity, no channel allocated, handler never ran; -malformed params — both gates pinned loud, the serde-gated shape -tears down at birth; the correlation-miss teardown — the assembly's +malformed params: both gates pinned loud, the serde-gated shape +tears down at birth; the correlation-miss teardown: the assembly's rejection, drop the stream, the channel reaps, the invocation witnessed). Verified: `cargo test` green (126 default; 146 `--all-features`), clippy `--all-targets -D warnings` clean (native + diff --git a/tasks/tunnels/local-bind-halves.md b/tasks/tunnels/local-bind-halves.md index 3744ba9..bb14038 100644 --- a/tasks/tunnels/local-bind-halves.md +++ b/tasks/tunnels/local-bind-halves.md @@ -1,6 +1,6 @@ --- id: tunnels/local-bind-halves -name: local feature — TCP bind halves + the bind-first assembly pieces +name: local feature, TCP bind halves + the bind-first assembly pieces status: completed depends_on: [tunnels/bindfirst-producer] scope: narrow @@ -12,13 +12,13 @@ tags: [local, tcp, bind, listener, feature-gated] ## Description -Extend the `local` feature (non-wasm, `tokio/net`-gated — +Extend the `local` feature (non-wasm, `tokio/net`-gated; `tunnels/local-socket-halves` established the pattern) with the bind-first shape's substrate half: a real TCP bind function (`BindFn`-compatible) and an accept loop producing `(peer, TargetHandle)` pairs, plus the direct-op convenience dial closure composition (the `DialFn` the direct op shares with the base -op — likely already satisfied by `local_dial`; verify, don't +op, likely already satisfied by `local_dial`; verify, don't duplicate). ### The bind half @@ -26,7 +26,7 @@ duplicate). ```rust /// The real-socket BindFn source (the `local` feature's shape 3 /// backing): bind `addr` (port 0 = OS-chosen), report the REAL -/// bound address (the `bound` reply field's source — ADR-008 §2), +/// bound address (the `bound` reply field's source, ADR-008 §2), /// expose the accept loop that feeds the dispatcher. pub async fn bind_tcp_first(addr: &str) -> Result; @@ -35,28 +35,28 @@ pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first ``` - `BoundListener.listener` carries the `TcpListener` (type-erased in - the plan — `tunnels/bindfirst-producer`'s shape note); the accept - loop is assembly code composed ON it — the protocol crate stays + the plan; `tunnels/bindfirst-producer`'s shape note); the accept + loop is assembly code composed ON it: the protocol crate stays socket-blind, so the loop lives here (`local` = substrate code, ADR-004): - **`TcpListenerHalves` already has an accept loop** (`accept_loop` - feeding an `AcceptQueue` — the accept-first shape). The bind-first + feeding an `AcceptQueue`, the accept-first shape). The bind-first shape needs a DIFFERENT loop: per-accept it must produce `(peer_addr, TargetHandle)` for the accept dispatcher (which opens forwarded channels per accept), not feed a queue for the establisher to pop. Add a sibling method/struct (e.g. `TcpListenerHalves::accept_pairs` or a new - `BindFirstAcceptLoop` adapter) — decide the honest shape, document + `BindFirstAcceptLoop` adapter); decide the honest shape, document in Notes. The peer address rides from `accept()`'s return value - (reply#2 fidelity's source — ADR-008 §3's `peer` carrier). + (reply#2 fidelity's source, ADR-008 §3's `peer` carrier). - The direct-op `DialFn`: `local_dial()` already renders - `(Substrate, backing)` → halves with the UDP framed adapter — the + `(Substrate, backing)` → halves with the UDP framed adapter; the direct establisher calls the SAME closure with the rendered target - string. Verify the UDP wrap happens INSIDE the closure (it does — + string. Verify the UDP wrap happens INSIDE the closure (it does; review 001 U-1) so direct-udp gets the framed adapter free; add a test pinning direct-udp-through-`local_dial` framing if missing. -### Tests (`tests/`, real sockets — the `local` suite's posture) +### Tests (`tests/`, real sockets; the `local` suite's posture) - Bind port 0 → `bound` reports the REAL OS-chosen address (the reply#1 fidelity pin). @@ -64,17 +64,17 @@ pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first channels (via the bind-first harness) → both pump independently (peer fidelity: each forwarded open carries the REAL accepted peer). -- Unix-domain bind (OQ-TN-14 parity): `bind_unix_first`-equivalent — - decide whether unix bind-first ships in this task or is deferred +- Unix-domain bind (OQ-TN-14 parity): `bind_unix_first`-equivalent. + Decide whether unix bind-first ships in this task or is deferred with a note (the wire enum already carries unix; the local dial - path exists — the bind path is the same shape; prefer shipping + path exists, and the bind path is the same shape; prefer shipping both, matching dial parity). ## Acceptance Criteria - [x] `bind_tcp_first` + the accept-pairs loop (peer + halves per accept) behind `local` -- [x] The bound address is the REAL OS-chosen one (port 0 resolved — +- [x] The bound address is the REAL OS-chosen one (port 0 resolved, pinned by test) - [x] Direct-op `DialFn` reuse verified (no duplicate dial code; UDP framed-adapter wrap composes) @@ -87,96 +87,96 @@ pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first ## References - docs/architecture/decisions/008-listen-metadata-forwarded-op.md - (§1 shape 3 — the bind-first establisher; §3 the peer carrier) + (§1 shape 3, the bind-first establisher; §3 the peer carrier) - docs/architecture/producer.md §The Registration API (`BindFn` shape) - tasks/tunnels/local-socket-halves.md (the `local` pattern + the existing `bind_tcp`/`TcpListenerHalves`/`AcceptQueue` accept-first shapes) - tasks/tunnels/bindfirst-producer.md (the `BindFn`/ `BindFirstAcceptLoop` contracts this implements) -- OQ-TN-14 (unix ships with `local` v1; stdio out — alktty's) +- OQ-TN-14 (unix ships with `local` v1; stdio is out, as in alktty's) ## Notes -- **The task sketch's `bind_tcp_first(addr: &str)` signature held** — +- **The task sketch's `bind_tcp_first(addr: &str)` signature held**: the committed [`BindFn`] contract takes `(substrate, backing)`; the substrate dispatch lives inside [`local_bind_first`] (tcp → [`bind_tcp_first`], unix → [`bind_unix_first`], udp → fails loud), matching the type-erased closure precedent (the protocol crate stays socket-blind; the closure is the assembly side). - **The accept-pairs shape: a `*_pair` method, not a sibling loop - struct** — [`TcpListenerHalves::accept_pair`] (one accept per call → + struct**: [`TcpListenerHalves::accept_pair`] (one accept per call → `(peer, halves)`). The task floated `accept_pairs` or a `BindFirstAcceptLoop` adapter struct; one method is the honest shape: the DISPATCHER owns the loop pacing (the committed `BindFirstAcceptLoop` contract is one closure call = one accept), so the substrate half is just the single-accept step with the peer - attached. `local_bindfirst_accept_loop()` is the adapter — it wraps + attached. `local_bindfirst_accept_loop()` is the adapter: it wraps the `*_pair` methods with the downcast (the assembly side of the type-erased injection; a foreign listener type fails loud as - `handler_error` — pinned by test). The accept-FIRST + `handler_error`, pinned by test). The accept-FIRST [`TcpListenerHalves::accept_loop`] (queue-feeding) is untouched. -- **UDP bind-first is NOT here — refused loud** (a decision the task +- **UDP bind-first is NOT here: refused loud** (a decision the task left open): a UDP socket has no accept, so the bind-first shape is not DEFINED for udp; [`local_bind_first`] maps `Substrate::Udp` to a `handler_error` rather than faking a listener (pinned by `udp_bind_first_fails_loud`). The per-peer associate shape (an - accept-analogue for UDP) would be a wire/protocol design decision — - an ADR, if it ever ships. The direct/forward udp dial shapes cover + accept-analogue for UDP) would be a wire/protocol design decision, + an ADR if it ever ships. The direct/forward udp dial shapes cover the substrate. -- **Unix bind-first SHIPPED** (the task's preferred posture — OQ-TN-14 - dial parity): `bind_unix` + `UnixListenerHalves` (a sibling of +- **Unix bind-first SHIPPED** (the task's preferred posture, + OQ-TN-14 dial parity): `bind_unix` + `UnixListenerHalves` (a sibling of [`TcpListenerHalves`]: `local_addr`, the queue-feeding `accept_loop`, and the bind-first `accept_pair`) + [`bind_unix_first`]. The bind-first `accept_pair`'s peer is the ACCEPTED client's bound path; an unnamed (autobind) client fails - loud — the forwarded op's `peer` param is required on the wire, an + loud: the forwarded op's `peer` param is required on the wire, an addressless peer cannot be represented (ADR-008 §3). The unix - parity test binds the CLIENT to a named path first (socket2 — std + parity test binds the CLIENT to a named path first (socket2; std has no `UnixStream::bind`), making the peer representable. - **`bind_unix` maps bind failure to `ResourceShortage`** (matching - [`bind_tcp`]'s posture — the held-resource class), not + [`bind_tcp`]'s posture, the held-resource class), not `DialFailed`: the bind IS the dial for the establisher's error table (shape-3: unbindable/held → `dial_failed`-class rides the establisher's passthrough), but the substrate function's own class follows the sibling bind functions. -- **Direct-op `DialFn` reuse: verified, no duplication** — +- **Direct-op `DialFn` reuse: verified, no duplication**: [`local_dial`] is unchanged and shared; the UDP wrap happens INSIDE the closure (review 001 U-1). The missing pin was added: `direct_udp_through_local_dial_round_trips_framed` (tests/ local_halves.rs) drives `TunnelSession::open_direct` + `Substrate::Udp` through [`local_dial`] against a REAL udp echo - socket — datagram boundary preserved through the direct op's + socket, with the datagram boundary preserved through the direct op's establisher path. ## Summary Implemented the `local` feature's bind-first substrate half (shape 3, -ADR-008 as amended) per the task: `src/local/mod.rs` — `bind_tcp_first` +ADR-008 as amended) per the task: `src/local/mod.rs` now has `bind_tcp_first` (the real-socket [`BindFn`] source: port 0 resolved via `local_addr`, the `bound` reply field's data), `TcpListenerHalves::accept_pair` (one -accept → `(peer, halves)` — the peer rides `accept()`'s return, the +accept → `(peer, halves)`, with the peer riding `accept()`'s return, the forwarded op's `peer` carrier), `bind_unix` + `UnixListenerHalves` + -`bind_unix_first` (unix bind-first parity SHIPPED — OQ-TN-14; unnamed +`bind_unix_first` (unix bind-first parity SHIPPED per OQ-TN-14; unnamed peer fails loud, the forwarded `peer` param requires an address), `local_bind_first()` (the [`BindFn`] convenience: tcp/unix dispatch, -udp fails loud — no accept on a UDP socket, the shape is not defined +udp fails loud: no accept on a UDP socket, the shape is not defined there, never a fake listener) and `local_bindfirst_accept_loop()` (the [`BindFirstAcceptLoop`] convenience: downcasts the plan's opaque listener to the module's wrappers, one pair per call; a foreign listener fails loud). The accept-FIRST shapes (`accept_loop` + `AcceptQueue`) are untouched. -Tests: `tests/local_bindfirst.rs` (4, `#![cfg(feature = "local")]`) — -the tcp e2e over REAL sockets (bind port 0 → the reply's `bound` is -the REAL OS-chosen address the test then DIALS — a fake bound would +Tests: `tests/local_bindfirst.rs` (4, `#![cfg(feature = "local")]`), +covering the tcp e2e over REAL sockets (bind port 0 → the reply's `bound` is +the REAL OS-chosen address the test then DIALS; a fake bound would strand the dial; two clients → two accepts → two forwarded channels → independent interleaved pumps, each forwarded open carrying the REAL accepted peer, distinct ports), unix parity (real socket path in `bound`; a named-path client connects; the forwarded open carries the REAL peer path), the udp refusal, and the downcast-miss probe. Plus `direct_udp_through_local_dial_round_trips_framed` in tests/ -local_halves.rs — the direct-op `DialFn` reuse pin (the framed udp +local_halves.rs, the direct-op `DialFn` reuse pin (the framed udp adapter composes through the direct op; review 001 U-1). Verified: `cargo test` (default) and `cargo test --all-features` green @@ -184,4 +184,4 @@ Verified: `cargo test` (default) and `cargo test --all-features` green `--all-targets --all-features -D warnings` clean, fmt clean, `wasm32-unknown-unknown` check passes (the default crate stays wasm-clean; the bind-first half lives behind `local`, non-wasm by -design). \ No newline at end of file +design). diff --git a/tasks/tunnels/local-socket-halves.md b/tasks/tunnels/local-socket-halves.md index 2848b87..eb77f75 100644 --- a/tasks/tunnels/local-socket-halves.md +++ b/tasks/tunnels/local-socket-halves.md @@ -1,6 +1,6 @@ --- id: tunnels/local-socket-halves -name: "local feature — real socket halves functions (TCP dial, UDP connect, unix)" +name: "local feature: real socket halves functions (TCP dial, UDP connect, unix)" status: completed depends_on: [tunnels/producer-open-op, tunnels/wire-codec] scope: moderate @@ -27,10 +27,10 @@ pub async fn dial_tcp(target: &str) -> Result Result; // UdpSocket::bind(("0.0.0.0", 0)) → connect(target) → the FRAMED adapter -// (UdpHalf + codec — ADR-003; truncation fail-loud per OQ-TN-13) +// (UdpHalf + codec per ADR-003; truncation fails loud per OQ-TN-13) pub async fn dial_unix(path: &str) -> Result; -// UnixStream::connect → into_split (OQ-TN-14: ships with local v1 — cheap, +// UnixStream::connect → into_split (OQ-TN-14: ships with local v1; it is cheap, // same halves shape as TCP) pub struct TcpListenerHalves { listener: tokio::net::TcpListener } @@ -45,10 +45,11 @@ pub struct UdpAssociateHalves { sock: Arc } // the POC's associate s - **UDP adapter (`UdpHalf`)**: port from the forward POC (`params.rs`'s `UdpHalfRead`/`UdpHalfWrite`) with the truncation fix: `poll_recv` into a too-small caller buffer must fail loud - (OQ-TN-13's resolved posture — ADR-003). The codec wraps at this + (OQ-TN-13's resolved posture, i.e. ADR-003). The codec wraps at this boundary; the pump never sees UDP specifics. -- **Unix**: `dial_unix` ships (OQ-TN-14 resolved — same halves shape - as TCP). **Stdio does NOT** — a spawned process's stdio is alktty's +- **Unix**: `dial_unix` ships (OQ-TN-14 resolved, with the same + halves shape as TCP). **Stdio does NOT**, because a spawned + process's stdio is alktty's pipe mode (exit codes + signals are tty semantics, not tunnel semantics); remote command execution composes via alktty, not here. - **Error mapping** to `TunnelEstablishError` variants (the POC's @@ -67,11 +68,11 @@ local = ["tokio/net"] - `tokio/net` is the only dep addition (no new external crates; the POCs proved `rt+sync+io-util+macros+time+net` covers everything). - The default crate stays wasm-clean: `local`-gated code must not be - importable from `producer.rs`/`consumer.rs` (convention 16 — backend + importable from `producer.rs`/`consumer.rs` (convention 16: backend modules are never imported from the shared/producer/consumer modules). - Unix is IN v1 (OQ-TN-14's lean-yes posture; same halves shape as - TCP); stdio bridging is NOT (different lifecycle — deferred). + TCP); stdio bridging is NOT (a different lifecycle, deferred). ### Tests @@ -106,22 +107,22 @@ POC's sizing test). - **`bind_tcp` + `TcpListenerHalves::accept_loop(queue)`** complete the listen side (the task surface sketched `bind`/`accept_fn`): the - accept loop is a spawned task feeding an [`AcceptQueue`] — the + accept loop is a spawned task feeding an [`AcceptQueue`], the protocol-side queue contract from producer-listen. The assembly layer aborts the task on teardown; accept errors end the loop. - **`UdpHalf` truncation probe** (OQ-TN-13): `poll_read` recvs into a 64KiB scratch buffer first, then size-checks against the caller's - buffer — a >buffer datagram is `io::ErrorKind::InvalidData`, never + buffer, so a >buffer datagram is `io::ErrorKind::InvalidData`, never a silent truncation. (`tokio`'s `poll_recv`-into-ReadBuf truncates silently, which is exactly the invariant F-2/OQ-TN-13 forbids.) WouldBlock polls `poll_recv_ready` for waker registration. - **The task sketch's `TcpListenerHalves::accept_fn(&self, queue)` became `accept_loop(&self, queue)`** (an owned async loop to spawn) - — the `accept_fn` borrow-shape fought the spawn model; the loop + ; the `accept_fn` borrow-shape fought the spawn model, while the loop returning on listener failure is the assembly-abort contract. - **Test plumbing note** (tests): the listen-over-real-socket test drives the client socket via std `try_clone` BEFORE `from_std` - (O_NONBLOCK is shared across dup — both ends go async); the session + (O_NONBLOCK is shared across dup, so both ends go async); the session uses borrowed halves (the producer's wrapper-managed pump already pumps channel <-> accepted handle; `pump_against` there would loop the same socket back into itself). @@ -132,7 +133,7 @@ POC's sizing test). Implemented the `local` feature (src/local/mod.rs, feature = `["tokio/net"]`): `dial_tcp` (5s timeout), `connect_udp` (ephemeral -bind + connect + the FRAMED `UdpHalf` adapter — truncation fails +bind + connect + the FRAMED `UdpHalf` adapter (truncation fails loud), `dial_unix` (OQ-TN-14 in v1), `bind_tcp`/`TcpListenerHalves:: accept_loop` (the listen shape's assembly half), and `local_dial()` (the one `DialFn` covering all three substrates). No socket type @@ -141,8 +142,8 @@ imported from producer/consumer/shared; `TargetHandle` re-used from producer.rs). Stdio bridging is NOT here (alktty's pipe mode owns process stdio). -Tests: `tests/local_halves.rs` (7, `#![cfg(feature = "local")]`) — -TCP dial round-trip through the real establisher path, UDP framed +Tests: `tests/local_halves.rs` (7, `#![cfg(feature = "local")]`) with +the TCP dial round-trip through the real establisher path, UDP framed round-trip, the 1400-byte MTU datagram, unix dial round-trip, the OQ-TN-13 truncation fail-loud probe (60k datagram vs 16KiB read → InvalidData), dial-refusal → `dial_failed`, and the listen producer diff --git a/tasks/tunnels/params.md b/tasks/tunnels/params.md index c9b9589..82a71a7 100644 --- a/tasks/tunnels/params.md +++ b/tasks/tunnels/params.md @@ -14,8 +14,8 @@ tags: [wire, params, open-op] Implement `src/params.rs` per ADR-001 + wire.md §The Open Op: the `TunnelParams` wire type, the `Substrate` discriminator, and the open-op -`OperationSpec` builder. This is the wire-stable surface (one-way door) — -exact conformance to the ADR is the point. +`OperationSpec` builder. This is the wire-stable surface (a one-way + door), so exact conformance to the ADR is the point. ### Types @@ -35,13 +35,14 @@ pub enum Substrate { Tcp, Udp, Unix } - `deny_unknown_fields` is the loud-rejection posture (ADR-001: unknown fields fail schema validation, never silently ignored). - `Substrate::Unix` is included in the type AND in the open-op schema's - enum list v1 (`["tcp", "udp", "unix"]` — OQ-TN-14 resolved: unix has + enum list v1 (`["tcp", "udp", "unix"]`; OQ-TN-14 resolved, so unix + has an implementation path via the `local` feature). The schema list is the authoritative gate for newer-substrate negotiation: an older producer rejecting a newer substrate value is the SSH "unknown channel type" posture (loud, not silent). -### The open-op spec (in producer.rs or params.rs — put it where +### The open-op spec (in producer.rs or params.rs; put it where `tunnels/producer-open-op` can consume; the spec builder lives in params.rs per the module map, producer.rs re-exports) @@ -56,8 +57,8 @@ pub fn tunnel_open_spec() -> OperationSpec - `OperationType::Sub`; `Visibility::External`; channel-open marker `ChannelOpenSpec::new(TUNNEL_ALPN)`. - `input_schema`: `{resource: string (required), substrate: string enum - ["tcp","udp","unix"] (required)}` — JSON Schema Draft shape as the - POCs used (unix in the list per OQ-TN-14's resolution). + ["tcp","udp","unix"] (required)}`, which is the JSON Schema Draft + shape as the POCs used (unix in the list per OQ-TN-14's resolution). - `output_schema`: `{channel_id: integer > 0}`. - `AccessControl.required_scopes: [TUNNEL_OPEN_SCOPE]` (ADR-006). - `description`: a one-line human hint (round-trips through discovery; @@ -100,7 +101,8 @@ errors (filled incrementally by later tasks); plus the re-export of enum). `Eq` added to `TunnelParams` (test equality). - `tunnel_open_spec()` lives in params.rs per the module map; producer.rs re-exports. Output schema pins `channel_id` with - `minimum: 1` + `required` (wire.md: integer > 0) — the POC's + `minimum: 1` + `required` (wire.md says integer > 0), which corrects + the POC's `minimum: 0` / no-required was loose. - `establishment_reason` delegates to alkcall 0.7.0's `ChannelOpenError::establishment_reason` method (upstream has it); @@ -108,7 +110,7 @@ errors (filled incrementally by later tasks); plus the re-export of re-exports through it). - Schema-validation tests use the `TunnelParams` deserializer as the enum validator (same deny-unknown/enum posture as the registry's - JSON Schema check; no jsonschema dep added for v1 — the + JSON Schema check; no jsonschema dep added for v1, and the input_schema string is asserted structurally in `open_spec_matches_wire_md`). - 6 unit tests: round-trip ×3 substrates, unknown-field rejection, @@ -123,4 +125,5 @@ External visibility, input/output schemas with the 3-value substrate enum, `tunnel:open` ACL, channel-open marker, description), constants, and the `establishment_reason` helper. Verified: `cargo test` (6 passed), clippy --all-targets -D warnings (native + wasm32), -fmt --check, wasm32 check — all clean. \ No newline at end of file +fmt --check, and wasm32 +check are all clean. \ No newline at end of file diff --git a/tasks/tunnels/producer-listen.md b/tasks/tunnels/producer-listen.md index 859a097..dd9dfd4 100644 --- a/tasks/tunnels/producer-listen.md +++ b/tasks/tunnels/producer-listen.md @@ -1,6 +1,6 @@ --- id: tunnels/producer-listen -name: Listen establisher — the producer-side listener variant +name: Listen establisher, the producer-side listener variant status: completed depends_on: [tunnels/producer-open-op] scope: narrow @@ -15,7 +15,7 @@ tags: [producer, establisher, listen, reverse] Implement the listen establisher shape per producer.md §The Establisher (shape 2): a producer whose resource is a LISTENER (SSH `-R` far-side listener; a hub exposing its own port to a third party). Same open op, -same params, same typed errors — the establisher pops the next accepted +same params, same typed errors; the establisher pops the next accepted connection from an assembly-owned listener queue instead of dialing. No new wire surface (ADR-004). @@ -36,8 +36,8 @@ pub fn listen_establisher(registry: ResourceRegistry, accept: AcceptFn) -> OpenE - Error mapping (producer.md's table): empty queue during establishment → `resource_shortage`; closed listener → `dial_failed`; registry miss → `unknown_resource` (same as dial). -- The pump handler is UNCHANGED (the same `make_tunnel_pump_handler`) — - the listen variant is plan-flow with a different halves source. +- The pump handler is UNCHANGED (the same `make_tunnel_pump_handler`), + since the listen variant is plan-flow with a different halves source. ### Assembly-side listener helper (the `local`-gated half lands in `tunnels/local-socket-halves`; here, define the protocol-side queue @@ -52,7 +52,7 @@ impl AcceptQueue { ``` - `pop` during establishment: bounded (the establisher's own deadline - applies — ADR-049 §2); `None` → `resource_shortage`. + applies; ADR-049 §2). `None` → `resource_shortage`. ### Tests @@ -67,7 +67,7 @@ queue → `resource_shortage`, closed listener → `dial_failed`. - [ ] `listen_establisher` + `AcceptFn` + the queue contract compile and wire through `register_tunnel_openable`'s shape (a listen - variant of the registration or a dial/accept enum — pick the + variant of the registration or a dial/accept enum (pick the honest shape, document it) - [ ] The pump handler is untouched (same function registered for listen producers) @@ -81,7 +81,7 @@ queue → `resource_shortage`, closed listener → `dial_failed`. - docs/architecture/decisions/004-no-backend-trait.md (the listen variant rationale) - Reverse POC (the hub's accept loop stands in for the assembly - listener — `docs/research/reverse-poc-summary.md` §The -R template) + listener; see `docs/research/reverse-poc-summary.md` §The -R template) ## Notes @@ -89,7 +89,7 @@ queue → `resource_shortage`, closed listener → `dial_failed`. - **Registration shape (the "pick the honest shape" decision):** a separate `register_tunnel_listen_openable(core, registry, on_registry, - auth, accept: AcceptFn)` — not a dial/accept enum on + auth, accept: AcceptFn)`, not a dial/accept enum on `register_tunnel_openable`. Rationale: the establisher is the only difference; `register_openable_with_establisher` takes a pre-built `OpenEstablisher`, so both variants share the same spec + pump @@ -99,17 +99,17 @@ queue → `resource_shortage`, closed listener → `dial_failed`. not need. - **`AcceptQueue::push` is async** (lock-acquiring): the accept loop's step is async anyway; a sync `push` would need a sync mutex with - cross-thread notify — not worth it for a queue that drains one - handle per open. + cross-thread notify, which is not worth it for a queue that drains + one handle per open. - **Empty-queue posture is assembly-owned, not queue-owned:** an empty queue during establishment is NOT inherently an error (a late - accept is legitimate — `accept_wait_resolves_when_push_arrives_late` + accept is legitimate (`accept_wait_resolves_when_push_arrives_late` pins the wait-then-resolve shape). The mapping to `resource_shortage` belongs to the accept closure (the assembly knows its listener's bound: drained listener, accept budget); the wrapper's establishment timeout (10s) is the backstop. The task sketch's "empty queue → resource_shortage" is realized by the - assembly mapping — the test pins the fail-fast closure posture. + assembly mapping, and the test pins the fail-fast closure posture. - **`closed_listener → dial_failed`** is likewise the closure's mapping (the queue's `close()` + pop → `None` → the closure maps). The typed-error table holds: both reasons verified on the wire via @@ -127,12 +127,13 @@ Establisher: `AcceptFn` (the injected accepted-connection source), `AcceptQueue` (the protocol-side queue contract: async push/pop/ close, FIFO with always-before-take ordering, close-while-waiting resolves None), `listen_establisher` (registry namespace gate → -accept() → `Establishment::new(plan)` — the same plan flow, a +accept() → `Establishment::new(plan)`; the same plan flow, but a different halves source; the pump handler is untouched), and `register_tunnel_listen_openable` (the same spec/pump registration, listen establisher). -Tests: `tests/producer_listen.rs` (6) — the listen flow end-to-end +Tests: `tests/producer_listen.rs` (6), covering the listen flow +end-to-end (pre-accepted handle round-trips through the pump), FIFO ordering across two opens (the R-01 plan-flow property, listen-flavored), empty queue → `resource_shortage` (fail-fast closure posture pinned), diff --git a/tasks/tunnels/producer-open-op.md b/tasks/tunnels/producer-open-op.md index feb3ee5..c7eb428 100644 --- a/tasks/tunnels/producer-open-op.md +++ b/tasks/tunnels/producer-open-op.md @@ -1,6 +1,6 @@ --- id: tunnels/producer-open-op -name: Producer half — establisher (dial shape) + pump handler + register_tunnel_openable +name: Producer half, establisher (dial shape) + pump handler + register_tunnel_openable status: completed depends_on: [tunnels/params, tunnels/wire-codec] scope: broad @@ -14,14 +14,14 @@ tags: [producer, establisher, open-op, pump] Implement `src/producer.rs` per producer.md: the dial-shape establisher, the substrate-agnostic pump handler, and the registration surface. This -is the crate's core — the open op that both POCs validated (the forward +is the crate's core: the open op that both POCs validated (the forward POC's producer on the accept side; the reverse POC's worker on the connect side). Port from the REVERSE POC first -(`/workspace/alktunnels-reverse-poc/src/producer.rs`) — it is the 0.6/0.7 -idiomatic shape (plan flow, no handoff) and its 16 tests are the -reference suite. +(`/workspace/alktunnels-reverse-poc/src/producer.rs`), which is the +0.6/0.7 idiomatic shape (plan flow, no handoff) and whose 16 tests are +the reference suite. -### The establisher (dial shape — the listen shape is a separate task) +### The establisher (dial shape, with the listen shape as a separate task) ```rust pub type DialFn = Arc< @@ -29,21 +29,21 @@ pub type DialFn = Arc< + Send + Sync, >; // TargetHandle: boxed halves (read: Box, write: ...), -// or the framed UDP adapter (the establisher wraps BEFORE returning the plan — +// or the framed UDP adapter (the establisher wraps BEFORE returning the plan per // ADR-003's placement; the pump stays substrate-agnostic) ``` - `tunnel_establisher(registry: ResourceRegistry, dial: DialFn, - identity_witness: Option<...>) -> OpenEstablisher` — the assembly layer + identity_witness: Option<...>) -> OpenEstablisher`; the assembly layer injects the substrate dial function (ADR-004); the establisher: - 1. Parses params (`TunnelParams` — schema-validated upstream, semantic + 1. Parses params (`TunnelParams`: schema-validated upstream, semantic parse here). - 2. Registry lookup `(resource, substrate)` → backing address — + 2. Registry lookup `(resource, substrate)` → backing address, `unknown_resource` on miss (typed reason mapping per ADR-001's table). 3. `dial(substrate, backing)` → the handle (the closure owns real - socket code — behind `local` or assembly-constructed). - 4. UDP: wrap in the framed adapter (ADR-003) — `UdpHalf` + codec, + socket code, behind `local` or assembly-constructed). + 4. UDP: wrap in the framed adapter (ADR-003): `UdpHalf` + codec, truncation fail-loud (OQ-TN-13). 5. Return `Ok(Establishment::new(Arc::new(handle)))` (R-01 plan flow). - `ResourceRegistry`: the assembly-owned @@ -56,14 +56,14 @@ pub type DialFn = Arc< pub fn make_tunnel_pump_handler() -> OpenHandler ``` -1. Downcast the plan to `TargetHandle` (`Arc::downcast` — error path: - log + return; the birth-teardown telemetry catches it). +1. Downcast the plan to `TargetHandle` (`Arc::downcast`; the error + path logs and returns, and the birth-teardown telemetry catches it). 2. `conn.accept_bi()` (yield-once). -3. `pump_bidi(bidi, t_read, t_write)` — **awaited inline** (R-02: the +3. `pump_bidi(bidi, t_read, t_write)`, **awaited inline** (R-02: the returned `JoinHandle` tracks the data-plane lifetime; early return = teardown-at-birth). The handler is substrate-agnostic by - construction — it must not know what the halves came from - (ADR-004). + construction (it must not know what the halves came from, per + ADR-004). ### Registration @@ -89,7 +89,7 @@ Port the reverse POC's test topology (`harness.rs`: worker = `from_connection_with_serving` + `ChannelOperations::register_on` + post-hoc openable; hub = adapter + capturing install hook) into `tests/` with the `dial` closure standing in for real sockets -(in-process duplex halves — no `local` feature needed). Cover: +(in-process duplex halves, so no `local` feature is needed). Cover: establishment success + typed errors (unknown_resource / dial_failed / FORBIDDEN / timeout-adjacent), the plan flow under same-resource concurrency, per-call opener identity (CF-006 witness), @@ -105,7 +105,7 @@ late-registration visibility, pump round-trip through `pump_bidi`. - [x] Integration tests: forward AND reverse topology (the harness shapes from both POCs), ≥8 tests covering the above - [x] Clippy/fmt clean; wasm32 check passes (dial closures are - runtime-injected — the crate compiles without `local`) + runtime-injected, so the crate compiles without `local`) - [x] `cargo test` green ## References @@ -120,16 +120,16 @@ late-registration visibility, pump round-trip through `pump_bidi`. - Ported from the reverse POC's producer.rs (the 0.7.0-idiomatic plan flow, R-01) with the crate generalizations: - - The dial closure is INJECTED (`DialFn`) — the POC had real socket + - The dial closure is INJECTED (`DialFn`): the POC had real socket code inline (tokio TCP/UDP); the crate's establisher is substrate-agnostic and the `local` feature (or the assembly layer) supplies the closure (ADR-004's inversion point, tested via in-process duplex echo + failing dials). - `register_tunnel_openable` takes `dial` + `identity_witness` (the - CF-006 probe seam) — the POC's registration closure had no dial + CF-006 probe seam); the POC's registration closure had no dial injection. - `ResourceRegistry` keys on `(String, Substrate)` (the enum, not a - &'static str — `Hash` added to `Substrate`). + `&'static str`; `Hash` added to `Substrate`). - `TargetHandle` carries `+ Sync` halves (the F-1 plan-payload bound). - Harness (tests/harness.rs): the reverse POC's topology (producer = @@ -139,16 +139,16 @@ late-registration visibility, pump round-trip through `pump_bidi`. `call_with_payload` on channel 0 and adopts the producer-allocated ID. - 11 integration tests: round-trip through `pump_bidi` (wrapper reaps - the producer channel on pump completion — R-02), unknown_resource + + the producer channel on pump completion, per R-02), unknown_resource + dial_failed typed errors (no phantom channel either side), FORBIDDEN identity-less (fails closed), transport-identity open (CF-005 (b)), token precedence (CF-005 (a) + the CF-006 witness), same-resource concurrency (no plan race), late registration (W2), unknown - substrate schema rejection (INVALID_INPUT — the registry's + substrate schema rejection (INVALID_INPUT; the registry's schema-validation runs before the establisher), outbound-calls- resolve-while-serving (ADR-022 §2), substrate-keyed registry lookups. - alkcall 0.7.0 note: the establisher's per-call `auth` carries the - dispatch-resolved identity (CF-005 corollary) — the witness proves + dispatch-resolved identity (CF-005 corollary), and the witness proves the overlay; `INVALID_INPUT` is the wire code for a schema failure (distinct from `channel:open_failed` establishment failures). - `futures` crate used for `BoxFuture` in `DialFn` (already a dep). @@ -161,6 +161,6 @@ pump handler (`pump_bidi` awaited inline, R-02), and `register_tunnel_openable` (spec + establisher + pump + dial injection + witness). 14 unit tests (params + codec) + 11 integration tests over the duplex harness pass. Verified: cargo test, clippy ---all-targets -D warnings (native + wasm32), fmt --check, wasm32 -check — all clean. The listen establisher variant lands with +--all-targets -D warnings (native + wasm32), fmt --check, and the +wasm32 check are all clean. The listen establisher variant lands with `tunnels/producer-listen`. \ No newline at end of file diff --git a/tasks/tunnels/review-002-coverage-gaps.md b/tasks/tunnels/review-002-coverage-gaps.md index 4292b75..7b8766f 100644 --- a/tasks/tunnels/review-002-coverage-gaps.md +++ b/tasks/tunnels/review-002-coverage-gaps.md @@ -1,6 +1,6 @@ --- id: tunnels/review-002-coverage-gaps -name: "G-4 + G-5 — close the remaining cheap coverage gaps (unix accept-loop test, unnamed unix fail-loud guards)" +name: "G-4 + G-5: close the remaining cheap coverage gaps (unix accept-loop test, unnamed unix fail-loud guards)" status: completed depends_on: [] scope: narrow @@ -14,40 +14,40 @@ tags: [tests, local, unix, coverage, review-002, review-remediation] The two remaining cheap-to-close coverage gaps from review 002's coverage appendix (`docs/reviews/ -002-graduation-implementation-review.md`), batched — both are +002-graduation-implementation-review.md`), batched: both are `local`-feature test additions (no protocol-layer changes). -### G-4 — `UnixListenerHalves::accept_loop` never driven +### G-4: `UnixListenerHalves::accept_loop` never driven `src/local/mod.rs:591-609` has zero coverage: no test constructs it (the unix dial test hand-rolls its own echo listener; the bind-first unix test uses `accept_pair`). It is the unix parity twin of the -covered `TcpListenerHalves::accept_loop` (hit at ~107-125) — 15 lines +covered `TcpListenerHalves::accept_loop` (hit at ~107-125), 15 lines of the same shape, and the largest single uncovered block in the crate. Test: mirror `tests/producer_listen.rs`'s `accept_loop_stops_feeding_after_close_and_maps_the_drop` with -`bind_unix` + a temp path — closes the entire 593-607 range and +`bind_unix` + a temp path, which closes the entire 593-607 range and exercises `UnixListenerHalves` beyond the bind-first path. Low value (copy-shaped) but the largest single uncovered block. -### G-5 — the unnamed-peer/unnamed-bind fail-loud arms (unix) +### G-5: the unnamed-peer/unnamed-bind fail-loud arms (unix) -Two fail-loud guards the ADR pins (ADR-008 §3 — "a producer that +Two fail-loud guards the ADR pins (ADR-008 §3, where "a producer that cannot determine the peer does not serve a bind-first listener over that substrate") never fire: -- `src/local/mod.rs:626-633` — the `accept_pair` unnamed-peer +- `src/local/mod.rs:626-633`: the `accept_pair` unnamed-peer `handler_error` (an autobind client) -- `src/local/mod.rs:578-583` — the `local_addr` unnamed-bind error +- `src/local/mod.rs:578-583`, the `local_addr` unnamed-bind error The existing unix bind-first test binds a named client socket deliberately (the parity case); no test binds an abstract/autobind unix listener. Test: bind an abstract unix listener (Linux: a leading-`\0` path via -`socket2` — already a `local` dependency, or `UnixListener::bind` on +`socket2`, already a `local` dependency, or `UnixListener::bind` on an unnamed autobind socket), call `local_addr()` expecting the "no filesystem path" `handler_error`, and drive one autobind-client accept through `accept_pair` expecting the unnamed-peer error. Cheap, @@ -56,18 +56,18 @@ promise. ### Explicitly NOT in this task (review guidance) -- **G-7** — kernel-dependent readiness arms; leave uncovered. -- **G-6** — the UDP empty-resolve arm; only if the `ResolveFn` seam - refactor is ever wanted (optional, separate decision — do not do it - here). -- **G-1/G-3** — unreachable-by-construction serde/JoinError arms; +- **G-7**, kernel-dependent readiness arms; leave uncovered. +- **G-6** (the UDP empty-resolve arm:; only if the `ResolveFn` seam + refactor is ever wanted; optional, separate decision, so do not do + it here). +- **G-1/G-3**, unreachable-by-construction serde/JoinError arms; leave uncovered. ## Acceptance Criteria -- [ ] The unix `accept_loop` test added (G-4 — the 591-609 range +- [ ] The unix `accept_loop` test added (G-4, the 591-609 range covered) -- [ ] The abstract-listener test added (G-5 — both fail-loud arms +- [ ] The abstract-listener test added (G-5, both fail-loud arms covered: `local_addr` unnamed-bind + `accept_pair` unnamed-peer) - [ ] `cargo test --features local` green; clippy `--all-targets @@ -91,7 +91,7 @@ promise. ### Implementation (2026-09-20) -**G-4** — `unix_accept_loop_stops_feeding_after_close_and_maps_the_drop` +**G-4**: `unix_accept_loop_stops_feeding_after_close_and_maps_the_drop` in `tests/producer_listen.rs` (mirroring the TCP twin above it): `bind_unix` + a temp path under the test's own pid-scoped dir, the listener's `accept_loop` spawned against a pre-closed `AcceptQueue`, @@ -99,19 +99,19 @@ one unix client connect, and the loop's `handler_error` exit asserted within a 5s bound. Covers the full 591-609 range (the unix accept-first loop was previously never driven). -**G-5** — `unix_unnamed_bind_and_unnamed_peer_fail_loud` in -`tests/local_bindfirst.rs` (`#[cfg(target_os = "linux")]` — abstract +**G-5**: `unix_unnamed_bind_and_unnamed_peer_fail_loud` in +`tests/local_bindfirst.rs` (`#[cfg(target_os = "linux")]`; abstract addresses + autobind are Linux-shaped). Both fail-loud arms pinned via the crate's own public API (no private-constructor seam needed): -- **Unnamed bind**: `bind_unix("")` — tokio passes the empty path +- **Unnamed bind**: `bind_unix("")`, so tokio passes the empty path through to `bind(2)`, which the kernel autobinds (addlen == - `offsetof(sun_path)`, an abstract name, no filesystem path) — - `local_addr` maps it to the "no filesystem path" handler error + `offsetof(sun_path)`, an abstract name, no filesystem path), + so `local_addr` maps it to the "no filesystem path" handler error (the 578-583 arm). Probed on this host: the bound addr reports `as_pathname() == None`. - **Unnamed peer**: a socket2 client bound to the empty abstract - sockaddr (the autobind shape — verified `Ok` on this kernel) + sockaddr (the autobind shape, verified `Ok` on this kernel) connects to a NAMED `bind_unix` listener; `accept_pair` refuses with the unnamed-peer handler error (the 626-633 arm). A plain unbound client behaves identically: mio's UDS accept maps ANY @@ -120,7 +120,7 @@ the crate's own public API (no private-constructor seam needed): catches both client shapes through tokio's accept. `bind_unix_first` itself stays untested for the unnamed-bind arm at -the establisher level (it composes `bind_unix` + `local_addr` — the +the establisher level (it composes `bind_unix` + `local_addr`, and the arm is the same code path the test drives directly); the local_addr assertion covers the composition's error leg. diff --git a/tasks/tunnels/review-002-doc-sync.md b/tasks/tunnels/review-002-doc-sync.md index 6bc82db..1196182 100644 --- a/tasks/tunnels/review-002-doc-sync.md +++ b/tasks/tunnels/review-002-doc-sync.md @@ -1,6 +1,6 @@ --- id: tunnels/review-002-doc-sync -name: "D-1 + D-2 — sync wire.md's schema bounds + ADR-007's oneOf→if/then + the establisher-time→registry-gate sentence" +name: "D-1 + D-2. Sync wire.md's schema bounds + ADR-007's oneOf→if/then + the establisher-time→registry-gate sentence" status: completed depends_on: [] scope: narrow @@ -15,23 +15,23 @@ tags: [docs, wire-format, adr-007, review-002, review-remediation] Two doc-only review 002 findings (`docs/reviews/002-graduation-implementation-review.md` §D-1, §D-2) batched: the shipped code is the wire-stable side and is correct; the -*docs* lag it. Wire-contract-adjacent — do this before the first +*docs* lag it. This is wire-contract-adjacent, so do it before the first consumer (alksocks) ships. -### D-1 — wire.md omits the `port` bounds the shipped schema enforces +### D-1: wire.md omits the `port` bounds the shipped schema enforces The shipped direct-op and forwarded-op schemas pin `"port": {"minimum": 0, "maximum": 65535}` plus `"additionalProperties": false` on the target/peer objects (`src/params.rs:208-222, 453-467`), and `listen` with `"minimum": 1` (`src/params.rs:442`). wire.md (the normative WHAT) -says only `port: ` and "integer > 0" in prose — it does not +says only `port: ` and "integer > 0" in prose; it does not record the bounds as schema properties, nor the `additionalProperties: false` gate that makes cross-shape targets fail the registry's `INVALID_INPUT` class BEFORE the establisher (the behavior `malformed_target_is_schema_class_and_leaves_no_channel` pins on the wire). The BAST doc (`bast.md`) covers only the -data-plane codec, not the params schemas — no second source records +data-plane codec, not the params schemas; no second source records these bounds. Fix: record the exact schema constraints in wire.md's §The Direct Op @@ -41,15 +41,15 @@ sentence). The risk addressed: a future consumer implementing from wire.md alone accepting a shape the reference producer rejects (or vice versa). -### D-2 — ADR-007's `oneOf` wording is stale; the "validated at establisher time" sentence contradicts the shipped design +### D-2: ADR-007's `oneOf` wording is stale; the "validated at establisher time" sentence contradicts the shipped design 1. ADR-007 §2 (line ~82) says "the target sub-schema is per-substrate (`oneOf` on the sibling `substrate` value, validated at establisher - time — see Consequences)". The shipped schema (and wire.md §The + time; see Consequences)". The shipped schema (and wire.md §The Forwarded Op, and the unit tests pinning it) use **`allOf` of two `if`/`then` branches** instead. `oneOf` of full-target-schemas would actually be WRONG here (a `unix`-substrate open would need to - fail the `tcp` branch — plain `oneOf` on sibling-conditioned + fail the `tcp` branch, so plain `oneOf` on sibling-conditioned subschemas is awkward; `if`/`then` composes correctly with the base `properties`/`required`). The code is right; the ADR's one-word schema-structure reference is stale. @@ -59,11 +59,11 @@ vice versa). the REGISTRY gate fires first (the establisher's dial never runs). The ADR/wire.md sentence should read "validated registry-side (the schema gate), before the establisher; re-checked by serde at - establisher time as the backstop" — which is exactly what + establisher time as the backstop", which is exactly what `src/params.rs`'s doc comments already say. -Fix: a one-line ADR-007 erratum (or an amendment note — house style -has precedent: ADR-008 carries numbered amendments) replacing +Fix: a one-line ADR-007 erratum (or an amendment note; the house +style has precedent: ADR-008 carries numbered amendments) replacing `oneOf` with the shipped `allOf`-of-`if`/`then` structure, plus the corrected gate-ordering sentence; mirror the same gate-ordering correction in wire.md §The Direct Op's @@ -80,8 +80,8 @@ gate-ordering correction in wire.md §The Direct Op's - [ ] The "validated at establisher time" sentence corrected to "registry-side schema gate first; serde backstop at establisher time" in ADR-007 (and wire.md if it repeats it) -- [ ] `cargo doc --no-deps` clean (docs changed — the verification - battery's doc gate) +- [ ] `cargo doc --no-deps` clean (docs changed, which trips the + verification battery's doc gate) ## References @@ -98,11 +98,11 @@ gate-ordering correction in wire.md §The Direct Op's ## Implementation Notes (2026-09-20) - **D-1 (wire.md §The Direct Op + §The Forwarded Op)**: both sections - now record the exact shipped schema constraints — the per-branch + now record the exact shipped schema constraints: the per-branch target/peer objects (`type: object`, `additionalProperties: false`, `port` bounds `minimum: 0`/`maximum: 65535`, the `unix` `path` branch) and the forwarded `listen` `minimum: 1` + its serde-pin - backstop — with the gate-ordering sentence (registry-side schema + backstop, plus the gate-ordering sentence (registry-side schema gate before the establisher, serde backstop) and the cross-shape-INVALID_INPUT behavior the integration test pins. No second source existed for these bounds (bast.md covers only the diff --git a/tasks/tunnels/review-002-g2-graceful-teardown-observation.md b/tasks/tunnels/review-002-g2-graceful-teardown-observation.md index 7c333dc..cd5318b 100644 --- a/tasks/tunnels/review-002-g2-graceful-teardown-observation.md +++ b/tasks/tunnels/review-002-g2-graceful-teardown-observation.md @@ -1,6 +1,6 @@ --- id: tunnels/review-002-g2-graceful-teardown-observation -name: "G-2 — observe close()'s EOF sentinel at the far accept dispatcher (+ N-2's write-happy-peer teardown pin)" +name: "G-2. Observe close()'s EOF sentinel at the far accept dispatcher (+ N-2's write-happy-peer teardown pin)" status: completed depends_on: [] scope: narrow @@ -13,19 +13,19 @@ tags: [tests, consumer, bind-first, teardown, review-002, review-remediation] ## Description Review 002 coverage gaps G-2 and the N-2 branch note, batched (the -review itself suggests the same harness shape covers both — see its +review itself suggests the same harness shape covers both; see its §Branch-coverage notes, last bullet). -### G-2 — `TunnelListener::close`'s `shutdown` arm never observed at the far end +### G-2: `TunnelListener::close`'s `shutdown` arm never observed at the far end `src/consumer.rs:529-531`: `close()` runs `write.shutdown().await` -(the explicit EOF sentinel) then reaps — but no test currently +(the explicit EOF sentinel) then reaps, but no test currently observes the FAR accept dispatcher's idle-read firing from a `close()` (the graceful path). The Drop path (implicit EOF → far teardown) IS observed end-to-end (`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`), and the close path is pinned only at the reap level -(`close_and_drop_reap_the_listen_channel`) — the sentinel's far-side +(`close_and_drop_reap_the_listen_channel`), so the sentinel's far-side effect for `close()` specifically is asserted nowhere: the "the pushed accept was never consumed" assertion runs only in the Drop-shaped tests. @@ -36,15 +36,15 @@ observation. Test: mirror `bindfirst_full_flow…`'s harness (the `tests/consumer_listener.rs` bind-first topology) with a `close()` -instead of a drop — open against a bind-first producer, push an +instead of a drop: open against a bind-first producer, push an accept, `listener.close().await`, then assert the far accept loop is gone (the same queue-len / never-consumed assertion the Drop tests use) and the serving-side listen channel reaped. -### N-2's testable residue — the write-happy-peer teardown posture +### N-2's testable residue: the write-happy-peer teardown posture The idle-stream EOF watch (`src/producer.rs:781-794`) triggers -teardown on `Ok(0)`, `Err(_)`, AND `Ok(1..)` — a misbehaving opener +teardown on `Ok(0)`, `Err(_)`, AND `Ok(1..)`; a misbehaving opener that writes to the idle listen channel also tears the listener down (fail-closed; the channel is not a data plane; the doc comment owns this). N-2 records the shape as correct, but the review's @@ -52,7 +52,7 @@ branch-coverage notes flag that the `Ok(_)` (non-empty read) arm is untested: a test would write a byte to the idle channel and assert far teardown, pinning the "any non-empty read tears down" posture. -Test: same harness — open the listen channel, write a byte into the +Test: same harness. Open the listen channel, write a byte into the idle stream from the opener side, assert the accept loop cancels and the listener tears down (the fail-closed posture). @@ -81,7 +81,7 @@ the listener tears down (the fail-closed posture). Filled (2026-09-20): -- Both tests landed in `tests/consumer_listener.rs` — no `src/` +- Both tests landed in `tests/consumer_listener.rs` with no `src/` changes needed (pure coverage additions): - `graceful_close_eof_sentinel_observed_at_far_accept_dispatcher` (G-2): mirrors the bind-first full-flow harness with a `close()` @@ -89,7 +89,7 @@ Filled (2026-09-20): accept), then asserts after `listener.close().await` that the serving-side listen channel reaped AND a follow-on pushed accept is never consumed (the same queue-len assertion the Drop-shaped - tests use) — the sentinel's far-side effect for the graceful path. + tests use), which is the sentinel's far-side effect for the graceful path. - `write_happy_peer_on_idle_listen_channel_tears_down_far_listener` (N-2 residue): opens the listen channel via the raw client surface (`open_channel_with_reply`, both halves held alive), proves @@ -97,9 +97,9 @@ Filled (2026-09-20): asserts the fail-closed teardown (channel reaped + the follow-on accept never consumed). The typed `TunnelListener` never exposes its idle write half by contract, so the misbehaving-opener shape - requires the raw client — that shape difference is the point. + requires the raw client; that shape difference is the point. - Harness notes: the write-happy test installs NO forwarded-op - serving stub — the pre-write consumed accept fails its forwarded + serving stub. The pre-write consumed accept fails its forwarded open (per-accept isolation), which is fine: the assertion only needs the queue emptied (loop alive), and the isolation path is pinned by `open_only_opener_still_opens_listen_but_forwarded_fails`. diff --git a/tasks/tunnels/review-002-m1-substrate-fallback.md b/tasks/tunnels/review-002-m1-substrate-fallback.md index 81d0ad7..72b996e 100644 --- a/tasks/tunnels/review-002-m1-substrate-fallback.md +++ b/tasks/tunnels/review-002-m1-substrate-fallback.md @@ -1,6 +1,6 @@ --- id: tunnels/review-002-m1-substrate-fallback -name: "M-1 remediation — the accept dispatcher's silent substrate fallback (stamp the plan, fail loud)" +name: "M-1 remediation: the accept dispatcher's silent substrate fallback (stamp the plan, fail loud)" status: completed depends_on: [] scope: narrow @@ -28,7 +28,7 @@ let substrate = serde_json::from_value::(input.clone()) In the wired path the fallback is unreachable (the registry schema gate and the establisher's `parse_params` both validate first), but the shape itself is wrong: a future drift would silently stamp `Tcp` into -every forwarded open — for a UDP listen tunnel that is a +every forwarded open; on a UDP listen tunnel that is a raw-vs-framed data-plane mismatch on the opener side (the exact invariant class review 001's U-1 was about), degrading silently instead of loudly. @@ -36,18 +36,18 @@ instead of loudly. ### The fix (decide deliberately, then implement) The review offers two shapes; **the second is recommended** (the -review calls it strictly better, and the pre-consumer window is open — +review calls it strictly better, and the pre-consumer window is open, `BindFirstPlan` has no external consumer yet): -1. Log-loud-and-return on parse failure (teardown-at-birth — the +1. Log-loud-and-return on parse failure (teardown-at-birth, the same posture as every other early return in the file). Smaller, but keeps the second parse and the fallback branch. 2. **Stamp the substrate into `BindFirstPlan` at establisher time and - drop the second parse entirely** — one parse, one source of truth, + drop the second parse entirely**: one parse, one source of truth, no fallback branch at all. The establisher already parsed the params successfully; the substrate is per-open-static; the plan is already the per-open carrier. `BindFirstPlan` gains a - `substrate: Substrate` field (a plan-payload change — cheap now, + `substrate: Substrate` field (a plan-payload change, cheap now, pre-consumer); the dispatcher reads it from the plan it already downcast. @@ -61,10 +61,10 @@ plan (the dispatcher must not re-parse wire input). ### Tests - Keep the existing suite green (the two-open token-discipline test - pins per-dispatcher stamping from its own plan — it now also pins + pins per-dispatcher stamping from its own plan; it now also pins the substrate's origin implicitly). - A failure-path test: with shape 2 there is no failure branch left - in the dispatcher (assert the removed fallback is gone — e.g. the + in the dispatcher (assert the removed fallback is gone, that is, the forwarded opens for a UDP bind-first listen carry `substrate: udp` from the plan, not a re-parse). With shape 1: a malformed-input accept teardown test (loud return, channel reaps, no phantom @@ -85,7 +85,7 @@ plan (the dispatcher must not re-parse wire input). - docs/reviews/002-graduation-implementation-review.md §M-1 (the finding, both fix shapes, and why not fixed inline) -- src/producer.rs — `BindFirstPlan` (~line 97), the establisher, and +- src/producer.rs: `BindFirstPlan` (~line 97), the establisher, and `make_bindfirst_accept_handler` (~line 755) - tasks/tunnels/bindfirst-producer.md (the original implementation task + Notes on the plan's shape) @@ -94,20 +94,20 @@ plan (the dispatcher must not re-parse wire input). > Agent fills during implementation. -**Chosen shape: 2 — stamp the plan** (the review's recommendation). -Rationale: one parse (the establisher's `parse_params` — which +**Chosen shape: 2, stamp the plan** (the review's recommendation). +Rationale: one parse (the establisher's `parse_params`, which rejects malformed input with `handler_error` before any channel exists), one source of truth; the substrate is per-open-static and the plan is already the per-open carrier; the fallback branch is not -just guarded against, it is REMOVED — a future drift cannot degrade +just guarded against, it is REMOVED: a future drift cannot degrade silently because there is no fallback to degrade into. Pre-consumer -window used (`BindFirstPlan` has no external consumer yet — a +window used (`BindFirstPlan` has no external consumer yet, so a plan-payload change is cheap). Implemented: - `BindFirstPlan` gains `substrate: Substrate` (doc comment states - why it rides the plan — the dispatcher must not re-parse wire + why it rides the plan; the dispatcher must not re-parse wire input; the removed fallback's data-plane mismatch risk is cited). The `Debug` impl stamps it (the opaque listener field stays). - `bindfirst_establisher` stamps the parsed `params.substrate` into @@ -120,7 +120,7 @@ Implemented: Tests: -- Existing suite kept green — the two-open token-discipline test +- Existing suite kept green: the two-open token-discipline test (`token_discipline_two_opens_distinct_tokens`) still pins per-dispatcher stamping from its own plan. - `bindfirst_plan_downcasts_through_channel_plan` (unit): the plan's @@ -130,18 +130,18 @@ Tests: stamps the parsed substrate into the plan (`Tcp` input → `plan.substrate == Tcp`). - **`forwarded_opens_carry_plan_stamped_substrate` (integration, - the M-1 pin):** a UDP bind-first listen over the wire — the + the M-1 pin):** a UDP bind-first listen over the wire; the forwarded open the dispatcher makes carries `substrate: udp` (the plan's stamp), observed through a substrate-recording forwarded-op stub on the opener side. The removed fallback would have stamped `Tcp`; the assert is `vec![Substrate::Udp]`. -Docs synced: `docs/architecture/producer.md` (§3 — the plan carries -`{listener, token, substrate}`; §The Forwarded Op item 1 — the +Docs synced: `docs/architecture/producer.md` (§3, the plan carries +`{listener, token, substrate}`; §The Forwarded Op item 1, the substrate also comes from the plan, never re-parsed), `docs/architecture/decisions/008-listen-metadata-forwarded-op.md` -(§1 — the plan's substrate stamp + the removed fallback, review 002 -M-1 referenced). +(§1, the plan's substrate stamp + the removed fallback; review +002's M-1 referenced). Verification (all green): `cargo test` (145 tests across the lib + 10 integration binaries), `cargo test --all-features` (153), diff --git a/tasks/tunnels/review-002-recorder.md b/tasks/tunnels/review-002-recorder.md index 4eefe2b..32b533f 100644 --- a/tasks/tunnels/review-002-recorder.md +++ b/tasks/tunnels/review-002-recorder.md @@ -1,6 +1,6 @@ --- id: tunnels/review-002-recorder -name: "N-1..N-7 — record the review's no-action notes in the code/ADR trail (N-4's ADR line + N-3's telemetry note)" +name: "N-1..N-7: record the review's no-action notes in the code/ADR trail (N-4's ADR line + N-3's telemetry note)" status: completed depends_on: [] scope: narrow @@ -14,70 +14,71 @@ tags: [docs, notes, review-002, review-remediation] Review 002's [note] findings (`docs/reviews/002-graduation-implementation-review.md` §N-1..N-7) -are correct-as-is with **no code action** — but two of them ask for a +are correct-as-is with **no code action**; but two of them ask for a durable trail beyond the review doc itself, and the rest are recorded here as the tracker (the review's own remediation list only orders M-1/D-*/the coverage gaps). ### The two notes wanting a durable trail -- **N-3** — `make_forwarded_op_handler` parses params BEFORE +- **N-3**: `make_forwarded_op_handler` parses params BEFORE `accept_bi` (`src/producer.rs:993-1006`), so the R-02 accepted-flag - telemetry reads `false` for a malformed-params open — the + telemetry reads `false` for a malformed-params open; the birth-teardown hint fires for a handled-rejection case. The order is deliberately the better one (accepting first would hold a stream open for a params set the producer already got wrong). Record one sentence in the code's doc comment ("the accepted-flag telemetry - reads false here by design — a malformed-params open IS a + reads false here by design; a malformed-params open IS a birth-teardown shape") so a future alkcall reviewer doesn't flag the flag. -- **N-4** — `SubstrateAddr`'s untagged leniency is schema-gated (the +- **N-4**: `SubstrateAddr`'s untagged leniency is schema-gated (the schema's `additionalProperties: false` per branch is the real gate). Two latent sharp edges to keep in view, in the ADR trail: 1. The establisher-level serde backstop keys `render_target` off - the sibling `substrate` — a cross-shape target that sneaked past + the sibling `substrate`, so a cross-shape target that sneaked past a looser registry would render *something* rather than fail. Worth an ADR line if a future consumer runs a schema-less registry variant. Add the line to ADR-007 (Consequences or the - wire-stable section) while the doc-sync task's context is fresh - — one sentence: "the serde backstop renders the sibling-keyed + wire-stable section) while the doc-sync task's context is fresh. + One sentence: "the serde backstop + renders the sibling-keyed target; cross-shape garbage is the schema's absence, not a serde failure." 2. If `SubstrateAddr` grows a variant whose shapes overlap (e.g. a future `{host, port, flowlabel}`), untagged disambiguation - starts ordering — switch to an internally-tagged form at that + starts ordering; switch to an internally-tagged form at that point. This is additive-only, pre-consumer; the ADR line should say "don't let it happen by accident." ### The no-action notes (recorded here as the tracker) -- **N-1** — `parse_listen_reply`'s positive-`listen` requirement +- **N-1**: `parse_listen_reply`'s positive-`listen` requirement matches the forwarded params' wire contract (`minimum: 1`); JSON-null-as-absent is the standard lenient reading; the struct `deny_unknown_fields` warning in the doc comment is doing real - work — keep both. No action. -- **N-2** — the idle-stream EOF watch treats ANY byte as teardown + work, so keep both. No action. +- **N-2**: the idle-stream EOF watch treats ANY byte as teardown (fail-closed). Correct; the ADR sentence is thinner than the - implementation — the write-happy-peer test pin is tracked in + implementation; the write-happy-peer test pin is tracked in `tunnels/review-002-g2-graceful-teardown-observation`. Optionally widen ADR-008 §1's "its EOF cancels the loop" sentence to name the any-byte posture while editing (coordinate with `tunnels/review-002-doc-sync` if both touch ADR-008). -- **N-5** — the token-mint discipline is sound; the 256-channel +- **N-5**: the token-mint discipline is sound; the 256-channel per-connection cap makes the wrap-collision math even more favorable than the doc comment states. No action (optionally tighten the doc comment's math note). -- **N-6** — the sleep-then-assert test pattern +- **N-6**: the sleep-then-assert test pattern (`tests/consumer_listener.rs:276` + siblings) is the established house pattern; if CI ever flakes, the fix is a bounded poll loop on `channel_ids()` extended to the arrivals. No action now. -- **N-7** — `test_bind()`'s bare `Arc` listener is fine (the +- **N-7**: `test_bind()`'s bare `Arc` listener is fine (the downcast-miss probe covers the failure mode). No action. -### G-1/G-3 — the unreachable-by-construction arms (document-only) +### G-1/G-3: the unreachable-by-construction arms (document-only) Per the coverage appendix's recommendations: leave both uncovered, -noted here as the review's "unreachable error arm" record — +noted here as the review's "unreachable error arm" record: `TunnelSession::open`/`open_direct`/`TunnelListener::open` (+ `open_reverse_channel`'s twin) params-serialize closures (serde_json cannot fail on these types) and `TunnelSession::join`'s pump-`Err` @@ -113,7 +114,7 @@ G-1's four duplicated closures into one `params_to_input` helper > Agent fills during implementation. -## Implementation Notes (2026-09-20) — the review's no-action dispositions, recorded +## Implementation Notes (2026-09-20): the review's no-action dispositions, recorded The two durable-trail items landed as specified; the no-action notes' dispositions follow (this section is the tracker the task asked for). @@ -122,24 +123,24 @@ dispositions follow (this section is the tracker the task asked for). - **N-3**: the parse-before-accept telemetry sentence added to `make_forwarded_op_handler`'s doc comment - (`src/producer.rs`) — "the accepted-flag telemetry reads false here + (`src/producer.rs`): "the accepted-flag telemetry reads false here by design; a malformed-params open IS a birth-teardown shape; do not flag the flag (review 002 N-3)". -- **N-4**: both ADR-007 Consequences lines added — (1) the +- **N-4**: both ADR-007 Consequences lines added: (1) the `SubstrateAddr` untagged-leniency-is-schema-gated paragraph (the sibling-keyed `render_target` backstop; "cross-shape garbage is the schema's absence, not a serde failure"; the schema-less-registry ADR-line-if-ever requirement) and (2) the overlapping-variants caution (untagged disambiguation starts ordering → switch to an internally-tagged form, deliberately, never by accident). -- **N-2 (ADR-008 §1 widening — APPLIED, not declined)**: §1's +- **N-2 (ADR-008 §1 widening: APPLIED, not declined)**: §1's accept-dispatcher bullet now states the any-byte idle-read posture (`Ok(0)` / error / any non-empty read all tear down; fail closed; the write-happy-peer test pins it) and names the `Ok(0)`-only hang-on-write-happy-peer trap the narrower reading would produce. Note: the doc-sync task and this task both touched ADR files but never ADR-008, so no coordination conflict materialized. -- **N-5 (optionally tighten — done)**: `mint_token`'s doc comment now +- **N-5 (optionally tighten: done)**: `mint_token`'s doc comment now records the review's cap analysis (the token is per-producer-connection; the 256-channels-per-connection cap makes the live-collision window unreachable by a wide margin). @@ -149,21 +150,21 @@ dispositions follow (this section is the tracker the task asked for). - **N-1**: no action. `parse_listen_reply`'s positive-`listen` requirement matches the forwarded params' `minimum: 1`; JSON-null- as-absent is the standard lenient reading; the `deny_unknown_fields` - warning in `TunnelListenReply`'s doc comment is doing real work — - kept as-is. + warning in `TunnelListenReply`'s doc comment is doing real work and + is kept as-is. - **N-2's code**: no action (fail-closed any-byte watch is correct as shipped; only the ADR sentence was widened, above). - **N-6**: no action. Sleep-then-assert is the established house pattern (3x-repeat stable historically); if CI ever flakes on the forwarded-arrival tests, the fix is a bounded poll loop on - `channel_ids()` extended to the arrivals — recorded here, not + `channel_ids()` extended to the arrivals; recorded here, not implemented. - **N-7**: no action. `test_bind()`'s bare `Arc` listener is fine; the downcast-miss probe (`accept_pair_loop_downcast_miss_fails_loud`) covers the failure mode. - **G-1/G-3 (the unreachable-by-construction arms)**: left uncovered, - per the review's recommendations — `open`/`open_direct`/ + per the review's recommendations: `open`/`open_direct`/ `TunnelListener::open` (+ `open_reverse_channel`'s twin) params-serialize closures (serde_json cannot fail on these types) and `join`'s pump-`Err` arm (unreachable through the public API; the @@ -176,4 +177,4 @@ dispositions follow (this section is the tracker the task asked for). - `cargo fmt --check`; `cargo clippy --all-targets -D warnings` (native + wasm32); `cargo test` (all suites + doc-tests); - `cargo doc --no-deps` — all green. \ No newline at end of file + `cargo doc --no-deps` are all green. \ No newline at end of file diff --git a/tasks/tunnels/review-002-trivial-batch.md b/tasks/tunnels/review-002-trivial-batch.md index 95f5a2e..b3edb0e 100644 --- a/tasks/tunnels/review-002-trivial-batch.md +++ b/tasks/tunnels/review-002-trivial-batch.md @@ -1,6 +1,6 @@ --- id: tunnels/review-002-trivial-batch -name: "Trivial batch — D-3 (rename the overpromising schema test helper) + D-4 (trim the stale CHANGELOG preamble)" +name: "Trivial batch: D-3 (rename the overpromising schema test helper) + D-4 (trim the stale CHANGELOG preamble)" status: completed depends_on: [] scope: narrow @@ -13,13 +13,13 @@ tags: [tests, changelog, review-002, review-remediation] ## Description Two trivial review 002 findings -(`docs/reviews/002-graduation-implementation-review.md`) batched — -each a one-line-scale change; no behavior impact. +(`docs/reviews/002-graduation-implementation-review.md`) batched: +each is a one-line-scale change with no behavior impact. -### D-3 — the test helper's name overpromises +### D-3: the test helper's name overpromises `src/params.rs:628-637`: `jsonschema_valid` parses the value with -serde and pattern-matches the substrate string — it validates the +serde and pattern-matches the substrate string, so it validates the serde layer, NOT the JSON Schema (the registry compiles the real validators; adding a `jsonschema` dev-dependency purely for this unit test is not warranted). The call-site @@ -38,18 +38,18 @@ end-to-end by the integration probes `malformed_target_is_schema_class…`, the forwarded `listen: 0` gate)"). Do NOT add a `jsonschema` dev-dependency. -### D-4 — the CHANGELOG "Unreleased" preamble is stale +### D-4: the CHANGELOG "Unreleased" preamble is stale `CHANGELOG.md`'s Unreleased section still opens with "implementation is decomposed in `tasks/tunnels/` and lands in the next release… the only behavior-affecting change so far (no wire or API change at -this level yet)" — written before the six graduation implementation +this level yet)", written before the six graduation implementation commits. The `### Added` section below it correctly lists all six features; the preamble contradicts the section it introduces. Fix: trim the preamble to a one-liner (e.g. "The graduation -implementation (ADRs 007/008) landed — see Added below." — final -wording agent's choice) or drop it. +implementation (ADRs 007/008) landed; see Added below." (the final +wording is the agent's choice) or drop it. ## Acceptance Criteria @@ -73,14 +73,14 @@ wording agent's choice) or drop it. - **D-3**: inlined the honest serde check directly into the renamed test `serde_rejects_unknown_substrate_value` (the `jsonschema_valid` - helper deleted — it had exactly one call-site, and its + helper deleted, since it had exactly one call-site, and its `matches!`-with-guard body was convoluted beyond its job). The doc comment names the integration probes that pin the real registry-side gate end-to-end. No `jsonschema` dev-dependency added (the structural-assertion sibling `direct_target_schema_gates_shape_per_ substrate` is untouched and stays honest). - **D-4**: trimmed the preamble to the one-liner ("The graduation - implementation … landed — see Added below."); the stale + implementation … landed; see Added below."); the stale `tasks/tunnels/` sentence and the "only behavior-affecting change" claim are gone. - Verification: `cargo fmt --check`, `cargo clippy --all-targets -D diff --git a/tasks/tunnels/review-core-crates.md b/tasks/tunnels/review-core-crates.md index 8bbdb17..7ebf335 100644 --- a/tasks/tunnels/review-core-crates.md +++ b/tasks/tunnels/review-core-crates.md @@ -1,6 +1,6 @@ --- id: tunnels/review-core-crates -name: Mid-phase review — producer + consumer halves before the local feature +name: Mid-phase review, producer + consumer halves before the local feature status: completed depends_on: [tunnels/consumer-session] scope: moderate @@ -12,22 +12,22 @@ tags: [review, injection-point] ## Description -Review injection point (SDD §Review Injection — "high-risk tasks: review +Review injection point (SDD §Review Injection: "high-risk tasks: review before proceeding"): `tunnels/producer-open-op` and `tunnels/consumer-session` are the two `risk: high, impact: phase` tasks and the crate's wire/API surface. Review them BEFORE `tunnels/local-socket-halves` and `tunnels/end-to-end-suite` build -against the shapes — a wire or API mistake found here costs one task's -rework; found later it costs three. +against the shapes; a wire or API mistake found here costs one task's +rework, while found later it costs three. -### Checklist (focused — the full gate is `tunnels/review-impl`) +### Checklist (focused; the full gate is `tunnels/review-impl`) 1. Wire conformance of what landed (params/codec/open-op vs wire.md + - ADR-001/003) — the one-way-door check. + ADR-001/003), the one-way-door check. 2. The pump handler shape: `pump_bidi` inline, JoinHandle tracks the - data plane (R-02) — the POC's hang-bug class must be structurally + data plane (R-02), so the POC's hang-bug class must be structurally absent. -3. Teardown matrix of `TunnelSession` — close/join/Drop soundness, no +3. Teardown matrix of `TunnelSession`: close/join/Drop soundness, no leaks (the W3 class). 4. No substrate types outside `src/local/`-to-be; no hand-rolled two-pump loops; no side-channel handoff. @@ -60,38 +60,38 @@ items were run against `producer.rs`/`consumer.rs`/`params.rs`/ alkcall 0.7.0's wrapper/establisher/pump source. Because both downstream tasks built on the shapes immediately after, the full re-verification (with the complete suite) landed in -`tunnels/review-impl` — consolidated findings live in +`tunnels/review-impl`; consolidated findings live in `docs/reviews/001-implementation-review.md` (the review-impl deliverable). Verdicts, summarized: -1. **Wire conformance — PASS.** Params shape exact +1. **Wire conformance: PASS.** Params shape exact (`{resource, substrate}`, `deny_unknown_fields`, enum - `["tcp","udp","unix"]` — schema + serde both pinned by tests); op + `["tcp","udp","unix"]`, schema + serde both pinned by tests); op id/ALPN/scope constants asserted against wire.md; codec (`[len: u16 BE]`, `len=0` legal, `Oversize` at frame time, no sentinel) correct. -2. **Pump handler shape — PASS.** `pump_bidi` awaited inline inside +2. **Pump handler shape: PASS.** `pump_bidi` awaited inline inside the spawned handler task; `accept_bi` → plan downcast → `Arc::try_unwrap` → inline await; no spawn-and-forget, no hand-rolled two-pump loop (grep-verified); R-02 pinned by an end-to-end test (`pump_handle_tracks_the_data_plane_r02`). -3. **Teardown matrix — PASS.** close (abort + reap), join (await + +3. **Teardown matrix: PASS.** close (abort + reap), join (await + reap + counts, panic arm logged), Drop (abort + sync reap), - pump-less join `(0, 0, reaped)`, failed-adopt no-leak — all pinned + pump-less join `(0, 0, reaped)`, failed-adopt no-leak; all pinned with `channel_ids()` leak asserts on both sides. No `Clone` (compile-fail doctest). 4. **No substrate types / no hand-rolled pumps / no side-channel - handoff — PASS.** The only plan flow is `Establishment::new(plan)` + handoff: PASS.** The only plan flow is `Establishment::new(plan)` → wrapper → handler `plan` param (R-01); the only `Mutex` is the `ResourceRegistry` lookup table (a registry, not a plan handoff); `tokio::net` appears only under the `local` gate. -5. **Tests green + 3× repeat-run stable — PASS** (both configs). +5. **Tests green + 3× repeat-run stable: PASS** (both configs). ### Majors logged (carried to `tunnels/review-impl`, re-verified there) -- **C-1** (`AcceptQueue::pop` lost wakeup — found in the full sweep, +- **C-1** (`AcceptQueue::pop` lost wakeup, found in the full sweep and fixed in-review): see review 001 §C-1. -- **U-1** (the `local` UDP adapter is not the framed adapter — found +- **U-1** (the `local` UDP adapter is not the framed adapter, found by the review-impl probes over real sockets): see review 001 §U-1; remediation task `tunnels/fix-udp-framed-adapter`. diff --git a/tasks/tunnels/review-impl.md b/tasks/tunnels/review-impl.md index 245bf5a..23da814 100644 --- a/tasks/tunnels/review-impl.md +++ b/tasks/tunnels/review-impl.md @@ -30,9 +30,9 @@ surface (ADR-001/002/003) is a one-way door, and the API surface - typed errors: the five establishment reasons + `FORBIDDEN` + `channel:too_many_channels` surfaces 2. **Producer conformance** (producer.md): - - establisher = the awaited bounded phase; plan flow (R-01) — no + - establisher = the awaited bounded phase; plan flow (R-01), meaning no side-channel handoff anywhere (grep for `Mutex` + - poll-loop take patterns — the POC's dead shape) + poll-loop take patterns, the POC's dead shape) - pump handler: `pump_bidi` inline, no substrate types, JoinHandle tracks the data plane (R-02) - registration: post-hoc supported (W2); `ChannelOperations:: @@ -81,21 +81,21 @@ tasks; minors get a follow-up batch. > Agent fills during implementation. -### Review executed 2026-09-08 (tree `2efac9b`) — deliverable: `docs/reviews/001-implementation-review.md` +### Review executed 2026-09-08 (tree `2efac9b`); deliverable: `docs/reviews/001-implementation-review.md` Method: full source read (`src/` + `tests/`) against the complete spec set; alkcall 0.7.0 API surfaces verified in source (wrapper, establisher types, client errors, manager adopt/teardown, `pump_bidi`); executable probes over real sockets for the `local` UDP path (temporary test file, -deleted after the run — probe evidence quoted in the review); the full +deleted after the run, with the probe evidence quoted in the review); the full verification battery (see below). The mid-phase `tunnels/ review-core-crates` checklist is subsumed (consolidated in the review doc; that task's Notes carry its verdicts). ### The five checklist verdicts -1. **Wire conformance — PASS.** Params shape exact (`deny_unknown_ - fields`, required both, enum `["tcp","udp","unix"]` — the OQ-TN-14 +1. **Wire conformance: PASS.** Params shape exact (`deny_unknown_ + fields`, required both, enum `["tcp","udp","unix"]`; the OQ-TN-14 note in this checklist predates the OQ's resolution; `unix` SHIPS per OQ-TN-14's resolution, and the enum carries it correctly); op id/ALPN/scope pinned; codec correct including `len=0` and frame-time @@ -104,58 +104,58 @@ doc; that task's Notes carry its verdicts). `resource_shortage`; `handler_error` through the same `From` arm; `timeout` by the hanging-establisher probe) + `FORBIDDEN` + `channel:too_many_channels`. -2. **Producer conformance — PASS.** Awaited bounded establisher (10s +2. **Producer conformance: PASS.** Awaited bounded establisher (10s default + per-registration override proven); pure R-01 plan flow - (grep-verified — no side-channel handoff, no poll-loop take); pump + (grep-verified: no side-channel handoff, no poll-loop take); pump handler structurally R-02 (`pump_bidi` inline; JoinHandle tracks the data plane, pinned by test); post-hoc registration (W2) supported; listen variant rides the same op with the typed-error table proven. -3. **Consumer conformance — PASS.** Full session surface per ADR-005; - teardown matrix sound (close/join/Drop/pump-less/failed-adopt — all +3. **Consumer conformance: PASS.** Full session surface per ADR-005; + teardown matrix sound (close/join/Drop/pump-less/failed-adopt, all leak-asserted on both managers); no `Clone` (compile-fail doctest). One documented divergence: `adopt` takes the substrate (the task - sketch omitted it — a datagram session cannot exist without it). -4. **Conventions sweep — PASS.** No unwrap/expect/panic outside + sketch omitted it, since a datagram session cannot exist without it). +4. **Conventions sweep: PASS.** No unwrap/expect/panic outside `#[cfg(test)]` (grep-verified); thiserror everywhere; all locks are - `tokio::sync::Mutex` (unpoisoned — the `into_inner` rule has no + `tokio::sync::Mutex` (unpoisoned: the `into_inner` rule has no application point, N/A); substrate types confined to `src/local/` + feature gate; wasm clean (`check` + `clippy --target wasm32-unknown-unknown`, `-D warnings` both); `pump_bidi` consumed, never hand-rolled. Inline `//` comments are the safety-constraint class convention 1 allows. -5. **Docs ↔ implementation sync — PASS with drift fixed in-review.** +5. **Docs ↔ implementation sync: PASS with drift fixed in-review.** Module map matches; ADRs all Accepted (implementation confirms); N-5 (stale doc forward-reference), N-7 (broken doc link), N-8 (missing root re-exports) fixed in-review. ### Findings (full detail in the review doc) -- **U-1 [major]** — the `local` UDP adapter is NOT the framed adapter +- **U-1 [major]**, the `local` UDP adapter is NOT the framed adapter ADR-003 mandates: codec-framed bytes reach real targets; empty datagrams are EOF-shaped at the adapter (tunnel teardown); >8 KiB datagrams split; unframed target-initiated datagrams are - dropped. Executable-pinned by probes. **NOT fixed — remediation + dropped. Executable-pinned by probes. **NOT fixed; remediation task `tunnels/fix-udp-framed-adapter`** (blocks UDP-over-`local` consumers; wire surface unaffected). -- **C-1 [major]** — `AcceptQueue::pop` lost wakeup in the +- **C-1 [major]**: `AcceptQueue::pop` lost wakeup in the check→register window (open op hangs to the establishment bound on a lost push). **Fixed in-review** (create-`Notified`-before-check per iteration, the tokio `notify_waiters` contract; regression test added). -- **U-2/U-3 [minor]** — `connect_udp` v4-only bind posture; 65535-byte +- **U-2/U-3 [minor]**: `connect_udp` v4-only bind posture; 65535-byte per-tunnel scratch. Riders on the U-1 task. -- **N-4/N-5/N-7/N-8/N-9/N-10/N-11 [minor]** — fixed in-review (payload +- **N-4/N-5/N-7/N-8/N-9/N-10/N-11 [minor]**, fixed in-review (payload serialization, doc staleness, doc link, root re-exports, README + publish dry-run, harness comment, `pump_against` UDP framing doc note). -- **N-2** — retracted during the review by executable falsification +- **N-2**, retracted during the review by executable falsification (the codec's buffering is bounded by construction; the u16 cap IS the bound). -- **N-6** — non-finding with rationale (`TunnelEstablishError` has no +- **N-6**, a non-finding with rationale (`TunnelEstablishError` has no `Timeout` variant, correctly). -No criticals — the phase-gate passes. +There are no criticals; the phase-gate passes. ### Verification battery (all green) @@ -173,7 +173,7 @@ No criticals — the phase-gate passes. > Agent fills this on completion. Phase-gate review complete: the wire surface (the one-way door) is -conformant; the API surface matches ADR-005/006; two majors found — +conformant; the API surface matches ADR-005/006; two majors found: U-1 (local UDP framing, remediation task created) and C-1 (queue lost wakeup, fixed with the review commit). The trivial batch + README + publish dry-run also landed with the review. Findings with diff --git a/tasks/tunnels/tunnel-listener.md b/tasks/tunnels/tunnel-listener.md index 5e5a1a5..65be4b9 100644 --- a/tasks/tunnels/tunnel-listener.md +++ b/tasks/tunnels/tunnel-listener.md @@ -1,6 +1,6 @@ --- id: tunnels/tunnel-listener -name: TunnelListener — the listen-flow consumer session (ADR-008 §4) +name: TunnelListener, the listen-flow consumer session (ADR-008 §4) status: completed depends_on: [tunnels/bindfirst-params, tunnels/forwarded-receipt] scope: moderate @@ -12,14 +12,14 @@ tags: [consumer, listener, listen-flow, teardown, tunnel-listener] ## Description -Implement `TunnelListener` (consumer.md §The Listen Flow — ADR-008 +Implement `TunnelListener` (consumer.md §The Listen Flow, ADR-008 §4): the listen-flow sibling of `TunnelSession`. It OPENS a listen -tunnel (`channels/tunnel/sub` — the establisher shape is the +tunnel (`channels/tunnel/sub`; the establisher shape is the producer's choice, invisible here), reads the bind-first reply fields (`bound`/`listen`), presents them, and owns the listen channel's teardown (ADR-005's ownership posture extended to the listen flow). Forwarded connections arrive as ordinary adopted-side `TunnelSession`s -on the opener's manager — `adopt` gains nothing. +on the opener's manager; `adopt` gains nothing. ### The type @@ -39,45 +39,45 @@ impl TunnelListener { pub fn bound(&self) -> Option<&SubstrateAddr>; pub fn listen(&self) -> Option; pub async fn close(self) -> bool; // tear the listen channel down - // Drop: sync-reap, best-effort — ADR-005's Drop posture + // Drop: sync-reap, best-effort (ADR-005's Drop posture) } ``` -- **`open`** — `open_channel_with_reply(OP_TUNNEL_OPEN, params, +- **`open`**: `open_channel_with_reply(OP_TUNNEL_OPEN, params, TUNNEL_ALPN)` (the U-2 surface, landed alkcall 0.8.0): the full reply arrives, `listen_reply_fields` extracts `bound`/`listen`, `adopt`-free construction (the client already - adopted the channel — the streams come back from + adopted the channel, since the streams come back from `open_channel_with_reply`), and the listener holds the channel - entry. The listen channel's own `BiStream` is held but IDLE — the + entry. The listen channel's own `BiStream` is held but IDLE: the listener session's drop/EOF is the teardown signal the far accept dispatcher watches (ADR-008 §1's contract); `TunnelListener` never pumps its own stream. - **The held BiStream (the one new thing):** unlike `TunnelSession` (which presents a data plane), a listen session's - stream has NO data-plane role — its only job is to EOF when the + stream has NO data-plane role; its only job is to EOF when the session drops. Keep the stream (or its write half) alive in the struct so the drop → EOF ordering is structural (the mux sender - drops → EOF sentinel). Do NOT expose the stream publicly — + drops → EOF sentinel). Do NOT expose the stream publicly; pumping on a listen channel is meaningless (the far side's dispatcher ignores it too). -- **`close(self)`/`Drop`** — abort nothing (no pump), reap the +- **`close(self)`/`Drop`**: abort nothing (no pump), reap the adopted channel entry (`teardown_channel`). The far-side teardown cascades (EOF → accept-loop cancel → listener drop). Forwarded sessions are NOT owned by the listener (they arrive as separate - adopted sessions the assembly correlates + owns) — document the + adopted sessions the assembly correlates + owns). Document the split (listener teardown does not cascade to already-adopted forwarded sessions; the far listener dying stops NEW accepts). -- **No `Clone`** — same posture as `TunnelSession` (aliasing would +- **No `Clone`**, same posture as `TunnelSession` (aliasing would alias teardown). Compile-assert with a doc-test like the session's. -- **`forwarded()`**: NOT a method on `TunnelListener` — forwarded +- **`forwarded()`**: NOT a method on `TunnelListener`. Forwarded sessions arrive on the opener's manager as adopted channels the assembly correlates via the `listen` token (consumer.md's posture; the correlation registry is assembly-side). The type hands the assembly the token (`listen()`); it does not own the receipt - queue. (The task sketch in consumer.md showed `listener.forwarded()` - — the honest shape is the assembly's map; document if a v1 - convenience accessor is still wanted — decide, don't silently + queue. (The task sketch in consumer.md showed `listener.forwarded()`, + but the honest shape is the assembly's map; document if a v1 + convenience accessor is still wanted: decide, don't silently drop.) ### The direct path (ADR-007's consumer ctor lives in `tunnels/direct-op`; this task adds nothing to `TunnelSession`) @@ -85,7 +85,7 @@ impl TunnelListener { Per ADR-008 §4: "`TunnelSession::adopt` gains nothing." Verify the claim while implementing: a forwarded channel adopted by the opener's assembly (`manager.adopt_channel` or `TunnelSession::adopt`) presents -the same session/data-plane/teardown semantics — pin it with a test +the same session/data-plane/teardown semantics; pin it with a test (adopt a forwarded channel, pump, teardown). ### Tests @@ -101,7 +101,7 @@ the same session/data-plane/teardown semantics — pin it with a test - Adopt-path pin: the forwarded session adopt + pump + teardown. - Scope interaction: an opener identity with `tunnel:open` but NOT `tunnel:forwarded` still opens the listen channel (that gate is on - the producer side) — forwarded opens to it then fail FORBIDDEN + the producer side), and forwarded opens to it then fail FORBIDDEN (the per-accept isolation path from `tunnels/bindfirst-producer`). ## Acceptance Criteria @@ -112,7 +112,7 @@ the same session/data-plane/teardown semantics — pin it with a test BiStream's EOF is the structural teardown trigger; no `Clone` - [x] Forwarded sessions are assembly-adopted ordinary - `TunnelSession`s (adopt gains nothing — pinned by test) + `TunnelSession`s (adopt gains nothing; pinned by test) - [x] Integration tests: bind-first e2e with the listener type, teardown cascade, Option posture, adopt-path pin - [x] Clippy/fmt clean; wasm32 check passes @@ -121,13 +121,13 @@ the same session/data-plane/teardown semantics — pin it with a test ## References - docs/architecture/decisions/008-listen-metadata-forwarded-op.md - (§4 the consumer API; Amendment 1 — the token the `listen()` view + (§4 the consumer API; Amendment 1, the token the `listen()` view reads) - docs/architecture/decisions/005-consumer-session-owns-teardown.md (the ownership posture extended to the listen flow) - docs/architecture/consumer.md §The Listen Flow - tasks/tunnels/consumer-session.md (the TunnelSession shape this - siblings — close/join/Drop posture, the no-Clone assert) + siblings: close/join/Drop posture, the no-Clone assert) - tasks/tunnels/bindfirst-params.md (the reply reader); tasks/tunnels/forwarded-receipt.md (the serving half the tests compose against) @@ -138,15 +138,15 @@ the same session/data-plane/teardown semantics — pin it with a test `BiStream`.** The task sketch said "hold the stream (or its write half) alive"; the honest shape is the write half alone: the mux sender's drop is the structural EOF trigger (the mux pump writes - the implicit-EOF chunk for the channel — REQ-CH-01's implicit path — + the implicit-EOF chunk for the channel, REQ-CH-01's implicit path, which the far accept dispatcher's idle-stream watch observes as the zero-byte sentinel). Holding the read half too would add nothing (nothing ever reads it) and would keep the demux receiver alive. `close()` additionally calls `shutdown()` first (the explicit - sentinel — the graceful path) before the reap; `Drop` relies on the + sentinel, the graceful path) before the reap; `Drop` relies on the implicit path. - **`forwarded()` decided: not provided.** Per the task's "decide, - don't silently drop": the honest shape is the assembly's map — the + don't silently drop": the honest shape is the assembly's map. The listener hands the assembly the token via `listen()`; it does not own the receipt queue. A v1 convenience accessor would either own a queue (duplicating assembly state) or wrap one (an accessor over @@ -154,9 +154,10 @@ the same session/data-plane/teardown semantics — pin it with a test .forwarded()` sketch line was already marked as the task-sketch shape; the docs (type docs + consumer.md) state the split. - **`open` is the ONLY construction path.** No `adopt` (the listener - is an opener-flow type — the open reply carries the correlation + is an opener-flow type; the open reply carries the correlation material) and no `join` (there is no pump to await; the task's - sketch has no `join` either — teardown is `close`/`Drop` only). + sketch has no `join` either, since teardown is `close`/`Drop` + only). - **Teardown ordering nuance (test-discovered, documented):** the far-side wrapper's teardown waits on its handler's R-02 await, which tracks every per-accept pump it spawned. The listener's EOF @@ -165,15 +166,15 @@ the same session/data-plane/teardown semantics — pin it with a test winds down. The e2e drops the local BIND client's end before asserting the far reap (the forwarded pump's own EOF path). - **`close()` on a dial-producer listener (both fields `None`)** is - ordinary: the Option posture does not affect teardown — the entry + ordinary: the Option posture does not affect teardown; the entry reaps regardless of what the establisher contributed. - **Harness addition (`wire_listener_bindfirst`, ListenerTopology):** - the listen-flow topology — the OPENER dials the transport (connect + the listen-flow topology: the OPENER dials the transport (connect side, `from_connection_with_serving`: it can both open the listen tunnel and serve the forwarded op post-hoc via its captured registry); the producer accepts (serving the bind-first openable; its forwarded-open caller rides the serving session's - `CallConnection` + the producer-side manager — the design pin from + `CallConnection` + the producer-side manager, the design pin from `tunnels/bindfirst-producer`). The existing topologies could not serve this shape: `wire_bindfirst`'s consumer is a hook-captured `CallConnection` (no `ChannelClient`), and `wire_forward`'s @@ -181,47 +182,47 @@ the same session/data-plane/teardown semantics — pin it with a test - **The adopt-path pin lives in its own test** (`forwarded_channel _adopt_presents_ordinary_session_semantics`, the forwarded-serving standalone topology): the gate e2e's forwarded channel on the - opener side is wrapper-managed (the serving wrapper allocated it — - a second `adopt_channel` fails `ChannelExists`, the same property + opener side is wrapper-managed (the serving wrapper allocated it, + so a second `adopt_channel` fails `ChannelExists`, the same property producer_bindfirst.rs pinned), so the pin drives the PRODUCER-side - adoption — `TunnelSession::adopt` + pump + teardown on the - opener-allocated ID — proving the session type gains nothing. + adoption (`TunnelSession::adopt` + pump + teardown on the + opener-allocated ID), proving the session type gains nothing. ## Summary Implemented `TunnelListener` (src/consumer.rs, ~130 lines) per consumer.md §The Listen Flow + ADR-008 §4 as amended: `open` (rides -`ChannelClient::open_channel_with_reply` — the full reply arrives and +`ChannelClient::open_channel_with_reply`: the full reply arrives and `listen_reply_fields` projects `bound`/`listen`; adopt-free construction), `bound()`/`listen()` (the Option posture), `close` -(the graceful path: `shutdown` on the held write half — the explicit -EOF sentinel — then the sync reap), and `Drop` (the held write half -drops → the mux pump's implicit-EOF chunk — the far accept -dispatcher's teardown signal — then the sync reap; never leaks). +(the graceful path: `shutdown` on the held write half, the explicit +EOF sentinel, then the sync reap), and `Drop` (the held write half +drops → the mux pump's implicit-EOF chunk, the far accept +dispatcher's teardown signal, then the sync reap; never leaks). The held stream is the WRITE HALF (`MpscSendStream`) only, IDLE and -private — a listen channel has no data-plane role. No `Clone` +private, since a listen channel has no data-plane role. No `Clone` (compile_fail doc-test, the session's precedent). `forwarded()` -decided: NOT provided — the receipt queue is assembly-side +decided: NOT provided. The receipt queue is assembly-side bookkeeping; the type hands the assembly the token (`listen()`) and the docs state the split (listener teardown does not cascade to already-adopted forwarded sessions; the far listener dying stops NEW accepts). `TunnelSession` gained nothing (the direct path lives in `tunnels/direct-op`, untouched). -Tests: `tests/consumer_listener.rs` — 5 integration tests: the +Tests: `tests/consumer_listener.rs` has 5 integration tests: the bind-first gate (both reply fields surface; the forwarded flow round-trips through the REAL `register_tunnel_forwarded_openable` -+ correlation map; the teardown cascade — drop listener → far -listener gone → a follow-on accept is never consumed), the Option ++ correlation map; the teardown cascade (drop listener → far +listener gone → a follow-on accept is never consumed)), the Option posture (a dial producer's open: both fields `None`; close reaps), close/Drop reaping (the ADR-005 leak-free guarantee, listen-flavored), the adopt-path pin (a forwarded channel adopted -via `TunnelSession::adopt` presents the ordinary session semantics — +via `TunnelSession::adopt` presents the ordinary session semantics; adopt gains nothing), and the scope interaction (`tunnel:open`-only opener still opens the listen channel; forwarded opens to it fail FORBIDDEN before the assembly handler runs). Harness addition: `wire_listener_bindfirst` + `ListenerTopology` (the opener-dials -shape — client + serving on the opener, bind-first serving + +shape: client + serving on the opener, bind-first serving + call-plane forwarded-open caller on the producer). One doc-fix drive- by in producer.rs (two broken intra-doc links, pre-existing). @@ -229,6 +230,6 @@ Verified: `cargo test` green (132 default; 157 `--all-features`), doc tests pass (both `compile_fail` asserts), clippy `--all-targets -D warnings` clean (native + wasm32), fmt clean, `wasm32-unknown-unknown` check passes (the default crate stays -wasm-clean), `cargo doc --no-deps` clean (0 warnings — the 2 +wasm-clean), `cargo doc --no-deps` clean (0 warnings, the 2 pre-existing broken links fixed). CHANGELOG updated (Unreleased → Added). \ No newline at end of file diff --git a/tasks/tunnels/wire-codec.md b/tasks/tunnels/wire-codec.md index b5759b6..594b342 100644 --- a/tasks/tunnels/wire-codec.md +++ b/tasks/tunnels/wire-codec.md @@ -23,7 +23,7 @@ pin. ```rust pub const MAX_DATAGRAM_LEN: usize = u16::MAX as usize; // 65535 -pub struct DatagramCodecError { .. } // thiserror: Oversize(usize, usize) (planned shape; InvalidLength removed post-review — see Notes) +pub struct DatagramCodecError { .. } // thiserror: Oversize(usize, usize) (planned shape; InvalidLength removed post-review, see Notes) pub fn frame_datagram(payload: &[u8]) -> Result // [len: u16 BE][payload]; len=0 is a legal empty datagram; >65535 = Oversize at frame time @@ -41,7 +41,7 @@ impl DatagramReader { spec's executable form) 1. Single datagram round-trip (frame → feed → exact bytes out). -2. Empty datagram (`len=0`): survives as a real datagram — NEVER +2. Empty datagram (`len=0`): survives as a real datagram and is NEVER confused with EOF (the F-2 invariant; the codec layer never emits a zero-length read). 3. Split across chunks (feed a frame in awkward 7-byte chunks; exact @@ -51,15 +51,16 @@ spec's executable form) 6. Mid-datagram state observable (incremental decode correctness). 7. Oversize rejected at frame time (never a wire overflow). 8. Truncation fail-loud (OQ-TN-13): the adapter-level receive that - would truncate surfaces as an error — test at the codec boundary by + would truncate surfaces as an error. Test at the codec boundary by asserting `frame_datagram` + reader round-trip with a buffer smaller than a full datagram is not silently accepted (the concrete API shape lands with `tunnels/local-socket-halves`; here, pin the codec-side invariant: a truncated stream yields an error, not a partial datagram). -No `stream_type` byte, no 5-byte header — a tunnel has one data stream -per direction (ADR-003). No EOF sentinel in the codec — EOF is the +No `stream_type` byte and no 5-byte header: a tunnel has one data +stream per direction (ADR-003). No EOF sentinel in the codec, since +EOF is the channels-level `length=0` chunk, a different layer (the two coexist; the POC proved no collision). @@ -71,8 +72,8 @@ the POC proved no collision). boundaries - [x] `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` clean -- [x] wasm32 check passes (the codec is pure byte work — it must be - wasm-clean) +- [x] wasm32 check passes (the codec is pure byte work, so it must + be wasm-clean) ## References @@ -86,17 +87,17 @@ the POC proved no collision). - Direct port of the POC's wire.rs (17 tests rode it) with the crate conventions: thiserror doc comments, module doc per ADR-003 + - wire.md + bast.md layering (sentinel layering called out — the F-2 - invariant is load-bearing, so the module doc states why raw + wire.md + bast.md layering (sentinel layering is called out; the + F-2 invariant is load-bearing, so the module doc states why raw pass-through is structurally broken for UDP). - Error naming per the task's API: `Oversize(usize, usize)` (was POC's `TooLarge`). (2026-09-09: the `InvalidLength` variant was - REMOVED post-review — `feed` never returned it; the truncated-stream + REMOVED post-review (`feed` never returned it); the truncated-stream contract is `is_mid_datagram` + the session's `TruncatedDatagram` instead, per the coverage-review commit. A completed task doc records its planned shape, not corrections made after.) - `feed` returns `Vec` per the task API (the POC's - `Vec` wrapper dropped — `Bytes` is the payload; the mux + `Vec` wrapper dropped, since `Bytes` is the payload; the mux handoff is zero-copy via `BytesMut::freeze`). - `MAX_DATAGRAM_LEN`/`DATAGRAM_LEN_FIELD` constants; `DATAGRAM_LEN_FIELD` is private (implementation detail), `MAX_DATAGRAM_LEN` public (the @@ -104,8 +105,8 @@ the POC proved no collision). - 8 test families: single round-trip, empty-datagram-not-EOF, 7-byte-chunk split reassembly, two-datagram batch, partial header, mid-datagram observability, oversize at frame time, truncated - stream (feeds an empty chunk + a one-byte-short tail — never yields - a partial datagram; the codec-side half of OQ-TN-13; the + stream (feeds an empty chunk + a one-byte-short tail; it never + yields a partial datagram, the codec-side half of OQ-TN-13; the adapter-level receive shape lands with local-socket-halves). - POC's `Datagram` struct dropped: `Bytes` suffices (the task's `feed -> Vec` signature); `is_empty()` moves to callers @@ -116,7 +117,8 @@ the POC proved no collision). `src/wire.rs` complete: `frame_datagram` (Oversize at frame time, `len=0` legal), `DatagramReader` (incremental decoder, `is_mid_datagram` teardown diagnostic), `DatagramCodecError {Oversize}` (originally -sketched `{Oversize, InvalidLength}`; see the 2026-09-09 note above) — -the ADR-003/BAST contract in executable form. 8 codec tests + 6 params +sketched `{Oversize, InvalidLength}`; see the 2026-09-09 note above). +This is the ADR-003/BAST contract in executable form. 8 codec tests + 6 params tests = 14 passing. Verified: cargo test, clippy --all-targets -D -warnings (native + wasm32), fmt --check, wasm32 check — all clean. \ No newline at end of file +warnings (native + wasm32), fmt --check, and the wasm32 check are all +clean. \ No newline at end of file diff --git a/tests/consumer_listener.rs b/tests/consumer_listener.rs index f4751b0..39391af 100644 --- a/tests/consumer_listener.rs +++ b/tests/consumer_listener.rs @@ -1,5 +1,5 @@ //! Integration tests for `TunnelListener` (the listen-flow consumer -//! session — ADR-008 §4, the `tunnels/tunnel-listener` shape): the +//! session (ADR-008 §4, the `tunnels/tunnel-listener` shape): the //! listen-flow sibling of `TunnelSession`. `open` rides //! `open_channel_with_reply` (the U-2 surface), the bind-first reply //! fields surface via `bound()`/`listen()` (the Option posture), the @@ -17,12 +17,12 @@ //! - `close` reaps the listen channel; `Drop` without close reaps. //! - Adopt-path pin: a forwarded channel adopted via //! `TunnelSession::adopt` presents the ordinary session semantics -//! (adopt gains nothing — pump + teardown on the adopted entry). +//! (adopt gains nothing: pump + teardown on the adopted entry). //! - Scope interaction: an opener identity with `tunnel:open` but NOT //! `tunnel:forwarded` still opens the listen channel; forwarded //! opens to it then fail FORBIDDEN (per-accept isolation). //! - Graceful `close` (review 002 G-2): the explicit shutdown -//! sentinel is observed at the FAR accept dispatcher — the idle-read +//! sentinel is observed at the FAR accept dispatcher; the idle-read //! fires, the loop cancels, the serving side reaps. //! - Write-happy peer (review 002 N-2's testable residue): a //! non-empty read on the idle listen stream tears the listener down @@ -70,7 +70,7 @@ fn test_bind() -> BindFn { } /// The test accept loop: pops from the test's VecDeque + Notify pair -/// (one accepted pair per closure call — the dispatcher invokes the +/// (one accepted pair per closure call; the dispatcher invokes the /// closure per accept). fn queue_loop( queue: Arc>>, @@ -222,10 +222,10 @@ fn opener_only_channel_0(topo: &ListenerTopology) -> bool { .all(|&id| id == 0) } -/// THE gate: `open` against the bind-first producer — both reply +/// THE gate: `open` against the bind-first producer, both reply /// fields surface via `bound()`/`listen()`, the forwarded flow runs /// through a REAL `TunnelSession::adopt` of the producer-allocated -/// forwarded channel (adopt gains nothing — the session type is +/// forwarded channel (adopt gains nothing; the session type is /// ordinary), and dropping the listener tears the far listener down /// (subsequent forwarded opens fail). #[tokio::test] @@ -276,7 +276,7 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() { // The forwarded channel arrives as a PRODUCER-INITIATED open the // opener serves: the opener-allocated ID appears on the opener's - // manager (wrapper-managed — the serving side allocates, ADR-047 + // manager (wrapper-managed: the serving side allocates, ADR-047 // §5; the PRODUCER adopts it inside the harness's forwarded-open // caller and pumps the accepted halves against it). tokio::time::sleep(std::time::Duration::from_millis(300)).await; @@ -293,7 +293,7 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() { // writes on the local BIND client's end cross the assembly pump → // the forwarded channel → the producer-side adopted pump → the // accepted handle's echo → and back. The forwarded session is an - // ordinary channel — `adopt` gains nothing (the standalone adopt + // ordinary channel; `adopt` gains nothing (the standalone adopt // pin below drives the same session type directly). use tokio::io::{AsyncReadExt, AsyncWriteExt}; b_write.write_all(b"bind-ping").await.expect("write"); @@ -311,7 +311,7 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() { // Teardown cascade: drop the listener → the held write half EOFs // the listen channel → the far accept loop cancels → the far // listener is gone (a follow-on accept is never consumed). The - // client end drops first (the forwarded session's own teardown — + // client end drops first (the forwarded session's own teardown: // the pump winds down; the handler's R-02 await unblocks only // when every per-accept pump finished). drop(b_read); @@ -337,11 +337,11 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() { } /// The adopt-path pin (ADR-008 §4: "`TunnelSession::adopt` gains -/// nothing — forwarded channels are ordinary adopted channels"): in +/// nothing; forwarded channels are ordinary adopted channels"): in /// the forwarded-serving standalone shape (the test plays the /// producer), a forwarded channel adopted via `TunnelSession::adopt` -/// presents the ordinary session semantics — substrate-shaped data -/// plane, pump, teardown — identical to any other adopted session. +/// presents the ordinary session semantics (substrate-shaped data +/// plane, pump, teardown) identical to any other adopted session. #[tokio::test] async fn forwarded_channel_adopt_presents_ordinary_session_semantics() { // The forwarded-serving topology: the producer leg (connect side, @@ -366,7 +366,7 @@ async fn forwarded_channel_adopt_presents_ordinary_session_semantics() { correlations.lock().await.insert(TOKEN, local); // The producer calls the forwarded op (valid params + scope) and - // adopts the opener-allocated ID — via `TunnelSession::adopt` (the + // adopts the opener-allocated ID via `TunnelSession::adopt` (the // session type's adopt path; `adopt gains nothing`). let out = topo .producer @@ -412,7 +412,7 @@ async fn forwarded_channel_adopt_presents_ordinary_session_semantics() { assert_eq!(&buf, b"adopt-ping"); // Teardown: the adopted session reaps its OWN entry (per-session, - // ADR-005) — independent of any listener. The far end (the local + // ADR-005), independent of any listener. The far end (the local // BIND client end) drops first: the assembly pump's `pump_bidi` // (R-02, the wrapper's handler task) unwinds when both pumps // finish, and only THEN does the wrapper's handler-exit teardown @@ -433,10 +433,10 @@ async fn forwarded_channel_adopt_presents_ordinary_session_semantics() { ); } -/// The Option posture: `open` against a plain DIAL producer — both +/// The Option posture: `open` against a plain DIAL producer, both /// reply fields `None`, the session is ordinary. (`wire_forward`'s /// producer (accept side) serves the base openable with a DIAL -/// establisher — a dial establisher never binds, so the reply carries +/// establisher; a dial establisher never binds, so the reply carries /// neither field; the listener session is otherwise ordinary.) #[tokio::test] async fn dial_producer_open_both_fields_none() { @@ -499,7 +499,7 @@ async fn close_and_drop_reap_the_listen_channel() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; assert!(!topo.producer_manager.has_channel(id1)); - // Drop(): the best-effort path — no leak. + // Drop(): the best-effort path (no leak). let registry = ResourceRegistry::new(); registry .register("bind", Substrate::Tcp, "assembly-owned") @@ -589,7 +589,7 @@ async fn open_only_opener_still_opens_listen_but_forwarded_fails() { /// The graceful-teardown half of ADR-008 §1's contract (review 002 /// G-2): `TunnelListener::close`'s explicit `shutdown` (the EOF -/// sentinel) is observed at the FAR accept dispatcher — the idle-read +/// sentinel) is observed at the FAR accept dispatcher; the idle-read /// fires, the accept loop cancels, the pushed accept is never /// consumed, and the serving-side listen channel reaps. The Drop path /// (`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`) and @@ -665,7 +665,7 @@ async fn graceful_close_eof_sentinel_observed_at_far_accept_dispatcher() { /// The write-happy-peer teardown posture (review 002 N-2's testable /// residue): a non-empty read on the idle listen stream tears the -/// listener down — the fail-closed arm (`Ok(_)` in the idle-stream EOF +/// listener down; the fail-closed arm (`Ok(_)` in the idle-stream EOF /// watch, src/producer.rs). The channel is not a data plane; ANY /// non-empty read is a teardown trigger, not just the zero-byte /// sentinels. The typed `TunnelListener` never writes, so the test @@ -687,7 +687,7 @@ async fn write_happy_peer_on_idle_listen_channel_tears_down_far_listener() { .await; // The misbehaving opener: the raw client surface (the typed - // `TunnelListener` never exposes the write half — it holds it + // `TunnelListener` never exposes the write half; it holds it // idle by contract). Hold the write half ALIVE: the idle read // must fire from the written byte, not from a dropped session. let input = serde_json::to_value(listen_params("bind")).expect("params serialize"); @@ -703,7 +703,7 @@ async fn write_happy_peer_on_idle_listen_channel_tears_down_far_listener() { assert!(topo.producer_manager.has_channel(channel_id)); // Prove the accept loop is alive FIRST (a consumed accept), then - // write a byte into the idle channel — the dispatcher's idle read + // write a byte into the idle channel, and the dispatcher's idle read // observes the non-empty read and tears the listener down // (fail-closed). push_accept(&queue, ¬ify, (198, 55236)).await; diff --git a/tests/consumer_session.rs b/tests/consumer_session.rs index b1daf76..06149b3 100644 --- a/tests/consumer_session.rs +++ b/tests/consumer_session.rs @@ -1,4 +1,4 @@ -//! Integration tests for the consumer half — `TunnelSession` per +//! Integration tests for the consumer half (`TunnelSession` per //! consumer.md + ADR-005, over the duplex harness. Ported from the //! reverse POC's `ReverseTunnel` suite (the W3/W4 probes) plus the //! forward POC's session shapes, generalized to the spec's session @@ -7,9 +7,9 @@ //! Covered: forward open (session halves drive a duplex round-trip), //! reverse open+adopt+pump_against (round-trip, W4 half-close, join //! copy counts, out-of-band close + self-reaping, pump-less join), -//! datagram variant (round-trip incl. the empty datagram — the F-2 +//! datagram variant (round-trip incl. the empty datagram, the F-2 //! layering), wrong-substrate errors, and the teardown matrix -//! (close/join/Drop — no leaked channel entries via `channel_ids()`). +//! (close/join/Drop; no leaked channel entries via `channel_ids()`). mod harness; @@ -105,7 +105,7 @@ async fn forward_open_session_halves_round_trip() { #[tokio::test] async fn forward_open_take_halves_then_pumpless_join() { // consumer.md's pinned semantics: after take_halves the session is - // pump-less — join completes immediately, reaps only, (0, 0, reaped). + // pump-less: join completes immediately, reaps only, (0, 0, reaped). let registry = ResourceRegistry::new(); registry .register("echo", Substrate::Tcp, "in-process") @@ -145,7 +145,7 @@ async fn forward_open_take_halves_then_pumpless_join() { #[tokio::test] async fn datagram_round_trip_including_empty() { // The datagram variant over the codec (ADR-003): round-trip incl. - // the F-2 empty datagram — `len=0` is a legal datagram, never EOF. + // the F-2 empty datagram (`len=0` is a legal datagram, never EOF). let registry = ResourceRegistry::new(); registry.register("dns", Substrate::Udp, "in-process").await; let topo = wire_forward(registry, framed_udp_echo_dial()).await; @@ -177,13 +177,13 @@ async fn datagram_round_trip_including_empty() { .await .expect("recv timed out") .expect("no io error") - .expect("second datagram (empty round-trips — F-2)"); + .expect("second datagram (empty round-trips, F-2)"); assert!(dg2.is_empty(), "the empty datagram round-trips (F-2)"); let reaped = session.close().await; assert!(reaped); // The producer-side wrapper reap is async (the pump task exits, - // the wrapper reaps) — settle before asserting both managers. + // the wrapper reaps); settle before asserting both managers. tokio::time::sleep(std::time::Duration::from_millis(200)).await; assert!(only_channel_0(&topo)); } @@ -191,7 +191,7 @@ async fn datagram_round_trip_including_empty() { #[tokio::test] async fn datagram_backlog_tracks_pending_and_drains_fifo() { // The §1.3/§3.3 session-shape diagnostic: `datagram_backlog()` is - // the `DatagramHalves.pending` count — None for stream sessions, + // the `DatagramHalves.pending` count: None for stream sessions, // 0 at rest, batched arrivals queue FIFO, and a drained queue // reads 0 (the wave-2 harness asserts the bound against it). let registry = ResourceRegistry::new(); @@ -224,7 +224,7 @@ async fn datagram_backlog_tracks_pending_and_drains_fifo() { // Backlog drain posture: two echoed datagrams land back-to-back; // whether the echo batches them into one chunk is timing-shaped, - // so the suite pins only the rest state (empty after drains) — + // so the suite pins only the rest state (empty after drains); // the batched-arrival bound is the wave-2 harness's (§3.3). session.send_datagram(b"first").await.expect("send"); session.send_datagram(b"second").await.expect("send"); @@ -409,7 +409,7 @@ async fn reverse_half_close_semantics_w4() { async fn out_of_band_close_then_session_self_reap() { // The POC's reverse_channel_close_out_of_band shape: a peer-initiated // `channel/close` tears the SERVING side down; the consumer's session - // still owns its adopted entry — close/join reaps it (W3 split). + // still owns its adopted entry; close/join reaps it (W3 split). let registry = ResourceRegistry::new(); registry .register("echo", Substrate::Tcp, "in-process") @@ -473,7 +473,7 @@ async fn drop_never_leaks_the_channel_entry() { ); // Drop with a session-owned pump: the pump aborts too (the reverse - // topology — pump_against is the reverse-flow use). + // topology, where pump_against is the reverse-flow use). let topo = wire(registry, echo_dial()).await; let channel_id = open_reverse_channel(&topo.consumer_call, ¶ms("echo", Substrate::Tcp), None) @@ -522,7 +522,7 @@ async fn oversize_datagram_rejected_at_frame_time() { async fn datagram_clean_eof_is_none_not_error() { // consumer.md's EOF contract for pump-less datagram sessions: a // clean stream EOF (the channels-level sentinel, mid-frame - // state clear) resolves `Ok(None)` — the caller's shutdown + // state clear) resolves `Ok(None)`, the caller's shutdown // signal, distinct from both an empty datagram (`Some(b"")`, // F-2) and a truncated stream (`TruncatedDatagram`). let registry = ResourceRegistry::new(); @@ -571,7 +571,7 @@ async fn datagram_mid_frame_eof_fails_loud_truncated() { // The echo's far end forwards the frame but drops the connection // BEFORE the payload completes (the 2-byte length prefix rides; - // the payload is cut — mid-datagram EOF). + // the payload is cut (mid-datagram EOF). let err = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram()) .await .expect("recv timed out") @@ -590,7 +590,7 @@ async fn datagram_mid_frame_eof_fails_loud_truncated() { async fn datagram_data_plane_after_take_halves_is_channel_taken() { // After the halves were taken, the data plane is gone: datagram // accessors fail with `ChannelTaken` (the pinned `ChannelTaken` - // surface — the session is still the channel-entry owner). + // surface; the session is still the channel-entry owner). let registry = ResourceRegistry::new(); registry.register("dns", Substrate::Udp, "in-process").await; let topo = wire_forward(registry, framed_udp_echo_dial()).await; @@ -617,7 +617,7 @@ async fn datagram_data_plane_after_take_halves_is_channel_taken() { assert!(matches!(err, alktunnels::TunnelIoError::ChannelTaken)); // The taken halves carry the WIRE framing (ADR-005's N-3 pin): - // the raw bytes are `[len: u16 BE][payload]` — framing duty + // the raw bytes are `[len: u16 BE][payload]`; framing duty // transferred to the caller, visible on the wire. use tokio::io::{AsyncReadExt, AsyncWriteExt}; write @@ -646,7 +646,7 @@ async fn datagram_data_plane_after_take_halves_is_channel_taken() { async fn reverse_udp_pump_against_framed_adapter_round_trips() { // The reverse UDP shape: the session's codec data plane is // consumed by `pump_against` and the accepted local half speaks - // the FRAMED wire (the N-11 contract — the pump copies raw bytes, + // the FRAMED wire (the N-11 contract: the pump copies raw bytes, // so the `[len: u16 BE]` framing lives at the boundary; the test's // local end stands in for the framed adapter, e.g. `local::UdpHalf`). use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -672,7 +672,7 @@ async fn reverse_udp_pump_against_framed_adapter_round_trips() { let mut local_end = local_end; // The local end speaks FRAMED bytes end-to-end: a payload frame - // crosses the pump untouched and echoes back framed — decode + // crosses the pump untouched and echoes back framed; decode // locally with the codec (boundary preserved, F-2 shape intact). let sent = alktunnels::wire::frame_datagram(b"rev-udp").expect("frame"); local_end.write_all(&sent).await.expect("write frame"); @@ -686,7 +686,7 @@ async fn reverse_udp_pump_against_framed_adapter_round_trips() { .expect("read"); assert_eq!(framed_back, &sent[..], "framed bytes pass through"); - // An EMPTY datagram (`len=0`) also round-trips — never collapsed + // An EMPTY datagram (`len=0`) also round-trips, never collapsed // to EOF across the pump (the F-2 invariant, reverse direction). local_end .write_all(&[0, 0]) @@ -752,8 +752,8 @@ async fn failed_open_never_yields_a_session() { async fn adopt_failure_is_typed_not_establishment() { // An adopt failure is `AdoptFailed`-class (post-reply local // failure: ID collision), not an establishment error. A bogus-id - // adopt SUCCEEDS by design — the manager parks early arrivals for - // any not-yet-seen ID (the adoption race cover, ADR-047 §5) — so + // adopt SUCCEEDS by design, since the manager parks early arrivals + // for any not-yet-seen ID (the adoption race cover, ADR-047 §5); // the collision path is the honest probe: adopt the same ID twice. let registry = ResourceRegistry::new(); registry diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index d99c08e..81b63e7 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -1,28 +1,28 @@ -//! The end-to-end suite — every validated POC behavior, re-expressed +//! The end-to-end suite: every validated POC behavior, re-expressed //! against the crate's public API (tests/common/harness.rs, the two //! POC harnesses' topologies unified). The spec's executable form: the //! quality gate before the implementation review. //! //! Suites: -//! 1. **Forward (`-L`)** — open → establisher dials → pump → round- +//! 1. **Forward (`-L`)**: open → establisher dials → pump → round- //! trip; 1 MiB backpressure; typed errors; per-call opener identity //! (CF-006); establishment deadline → `timeout` (ADR-049 §2). -//! 2. **Reverse (`-R`)** — `open_reverse_channel` + `adopt` + +//! 2. **Reverse (`-R`)**: `open_reverse_channel` + `adopt` + //! `pump_against`; odd-ID allocation (ADR-047 §5); the identity //! precedence chain (CF-005); half-close (W4); out-of-band close + //! self-reaping (W3); same-resource concurrency (R-01). -//! 3. **Listen producer** — the accept-queue flow + typed errors. -//! 4. **Datagram (`udp`)** — forward + reverse datagram sessions via +//! 3. **Listen producer**: the accept-queue flow + typed errors. +//! 4. **Datagram (`udp`)**: forward + reverse datagram sessions via //! the codec; round-trip, empty datagram (F-2), chunk-split //! survival, TCP+UDP concurrent on one connection. -//! 5. **Teardown matrix** (ADR-005) — close/join/Drop; `channel_ids()` +//! 5. **Teardown matrix** (ADR-005): close/join/Drop; `channel_ids()` //! asserts no leaks on either side. -//! 6. Spec-conformance assertions — no phantom channel on a failed +//! 6. Spec-conformance assertions: no phantom channel on a failed //! open, no leaked session on a failed adopt, R-02's //! JoinHandle-tracks-the-data-plane shape. //! -//! (`local`-feature real-socket suites live in tests/local_halves.rs -//! — the duplex suites here need no sockets.) +//! (`local`-feature real-socket suites live in tests/local_halves.rs; +//! the duplex suites here need no sockets.) mod harness; @@ -45,7 +45,7 @@ fn params(resource: &str, substrate: Substrate) -> TunnelParams { } } -/// Only channel 0 (the pre-negotiated call channel) may remain — +/// Only channel 0 (the pre-negotiated call channel) may remain; /// asserted on both sides of both topologies after every teardown. trait LeakCheck { fn no_data_channels(&self) -> bool; @@ -104,7 +104,7 @@ fn call_error_reason(err: &alktunnels::ReverseOpenError) -> Option { } // ===================================================================== -// Suite 1 — Forward (`-L`): open → establisher → pump → round-trip +// Suite 1, Forward (`-L`): open → establisher → pump → round-trip // ===================================================================== /// A 1 MiB patterned payload round-trips through the two-pump shape @@ -175,7 +175,7 @@ async fn forward_establishment_deadline_maps_to_timeout_typed() { let producer_op_registry = Arc::new(alkcall::registry::registration::OperationRegistry::new()); // The timeout topology: the default wire() shape with the hanging - // establisher replacing the dial establisher — wired by hand here + // establisher replacing the dial establisher, wired by hand here // via wire_with's Timeout mode on the reverse topology, then the // forward direction asserted through the session API on a fresh // forward wiring below. @@ -251,7 +251,7 @@ async fn forward_establishment_deadline_maps_to_timeout_typed() { /// both the transport-identity path and the token path. #[tokio::test] async fn forward_opener_identity_witnessed_on_both_paths() { - // Transport identity path (the mTLS posture — no token). + // Transport identity path (the mTLS posture; no token). let registry = ResourceRegistry::new(); registry .register("echo", Substrate::Tcp, "in-process") @@ -299,7 +299,7 @@ async fn forward_opener_identity_witnessed_on_both_paths() { } // ===================================================================== -// Suite 2 — Reverse (`-R`): open_reverse_channel + adopt + pump_against +// Suite 2 (Reverse, `-R`): open_reverse_channel + adopt + pump_against // ===================================================================== /// Odd-ID allocation under ADR-047 §5: the serving side (producer, @@ -328,7 +328,7 @@ async fn reverse_channel_ids_are_odd_and_distinct() { for id in ids { // The reverse-open'd channel was never adopted: the manager // parked its early arrivals. `adopt_channel` drains the park - // and installs the entry — adopt, then close (the session's + // and installs the entry; adopt, then close (the session's // teardown; teardown_channel alone is UnknownChannel for a // parked-but-never-adopted id). let session = TunnelSession::adopt( @@ -382,8 +382,8 @@ async fn reverse_identity_precedence_chain() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; // (b) The token wins over the transport identity: the provider - // resolves the scoped token to a DISTINCT id — the witness proves - // which one the establisher saw. + // resolves the scoped token to a DISTINCT id, and the witness + // proves which one the establisher saw. struct DistinctTokenProvider; impl alkcall::core::auth::IdentityProvider for DistinctTokenProvider { fn resolve_from_fingerprint(&self, _: &str) -> Option { @@ -522,7 +522,7 @@ async fn reverse_concurrent_same_resource_no_handoff_race() { } // ===================================================================== -// Suite 3 — Listen producer (shape 2, the accept-queue flow) +// Suite 3, Listen producer (shape 2, the accept-queue flow) // ===================================================================== /// The listen-queue flow: FIFO always-before-take across two opens; @@ -584,7 +584,7 @@ async fn listen_fifo_ordering_two_opens_two_handles() { /// The listen-shape typed-error table (producer.md's mapping): /// unknown resource, empty-queue → resource_shortage, closed listener -/// → dial_failed — each leaves no channel (the no-phantom property). +/// → dial_failed; each leaves no channel (the no-phantom property). #[tokio::test] async fn listen_typed_error_table_leaves_no_channel() { // Empty queue (the assembly's fail-fast posture: a bounded accept @@ -647,11 +647,11 @@ async fn listen_typed_error_table_leaves_no_channel() { } // ===================================================================== -// Suite 4 — Datagram (`udp`): forward + reverse via the codec +// Suite 4 (Datagram, `udp`): forward + reverse via the codec // ===================================================================== /// Many datagrams in each direction, boundaries preserved, batched -/// frames survive (one chunk may carry several datagrams — the +/// frames survive (one chunk may carry several datagrams; the /// pending-queue cover). #[tokio::test] async fn datagram_boundaries_preserved_both_directions() { @@ -663,7 +663,7 @@ async fn datagram_boundaries_preserved_both_directions() { .await .expect("udp open"); - // Burst 40 datagrams (varied sizes) — they may batch into shared + // Burst 40 datagrams (varied sizes); they may batch into shared // chunks; each must come back as exactly one datagram. let mut payloads = Vec::new(); for i in 0..40u16 { @@ -683,7 +683,7 @@ async fn datagram_boundaries_preserved_both_directions() { } assert_eq!(received, payloads, "boundaries preserved under batching"); - // The reverse direction: the target echo only repeats — drive the + // The reverse direction: the target echo only repeats, so drive the // other way by sending again after draining; order holds. session.send_datagram(b"final").await.expect("send"); let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram()) @@ -748,7 +748,7 @@ async fn reverse_udp_datagram_session_round_trips() { .await .expect("adopt"); // No pump_against: the session's codec data plane is driven - // directly (the pump-less datagram shape — pump_bidi copies raw + // directly (the pump-less datagram shape; pump_bidi copies raw // bytes, so the pump is the consumer's job here only when it // holds local halves; the direct drive is the session API's). session.send_datagram(b"query").await.expect("send"); @@ -764,7 +764,7 @@ async fn reverse_udp_datagram_session_round_trips() { assert!(only_channel_0(&topo)); } -/// TCP + UDP sessions concurrently on ONE connection — the mux is +/// TCP + UDP sessions concurrently on ONE connection; the mux is /// transparent across substrates (both POCs' concurrent shape). #[tokio::test] async fn tcp_and_udp_concurrent_on_one_connection() { @@ -820,7 +820,7 @@ async fn tcp_and_udp_concurrent_on_one_connection() { } // ===================================================================== -// Suite 5 — Teardown matrix (ADR-005): close / join / Drop +// Suite 5, Teardown matrix (ADR-005): close / join / Drop // ===================================================================== /// close on a session WITH a spawned pump: aborts the pump, reaps the @@ -906,7 +906,7 @@ async fn teardown_join_reports_copy_counts_and_reaps() { assert!(only_channel_0(&topo)); } -/// Drop after pump_against: the pump aborts AND the entry reaps — +/// Drop after pump_against: the pump aborts AND the entry reaps, /// the full teardown, not just the pump (the drop matrix's /// pump-carrying arm). #[tokio::test] @@ -940,7 +940,7 @@ async fn teardown_drop_with_pump_reaps_entry() { } /// A failed adopt leaks nothing: the manager's parked entry is -/// released (the failed-adopt arm of the matrix) — a bogus-ID adopt +/// released (the failed-adopt arm of the matrix); a bogus-ID adopt /// SUCCEEDS by design (the manager parks any unseen id), so the /// collision path is the honest probe; after the probe the queue is /// clean on both sides. @@ -985,7 +985,7 @@ async fn teardown_failed_adopt_leaves_no_leak() { } // ===================================================================== -// Suite 6 — Spec-conformance assertions (kept visible) +// Suite 6, Spec-conformance assertions (kept visible) // ===================================================================== /// A failed open NEVER returns a channel id (the no-phantom-channel @@ -1099,7 +1099,7 @@ struct Taken { } /// An in-process accepted handle: a duplex pair whose far end echoes -/// (the same shape the dial harness echoes with — a listen producer's +/// (the same shape the dial harness echoes with, a listen producer's /// "accepted connection"). fn echo_handle(_label: &str) -> TargetHandle { let (consumer_side, target_side) = tokio::io::duplex(64 * 1024); diff --git a/tests/harness.rs b/tests/harness.rs index d216e01..281e3d3 100644 --- a/tests/harness.rs +++ b/tests/harness.rs @@ -1,13 +1,13 @@ -//! Test harness — the producer↔consumer wiring over a +//! Test harness: the producer↔consumer wiring over a //! `tokio::io::duplex` carrying the channels 8-byte chunk format. #![allow(dead_code)] //! //! Topology (the reverse POC's shape, mirrored for both directions): -//! - **producer** (connect side of the channels connection — it runs +//! - **producer** (connect side of the channels connection: it runs //! `ChannelClient::from_connection_with_serving`): dials the //! duplex, serves the tunnel open op + generic channel ops on its //! serving registry. Its manager allocates the tunnel channel IDs -//! (`ChannelSide::Connect` — ADR-047 §5: the serving side +//! (`ChannelSide::Connect`; ADR-047 §5: the serving side //! allocates; the initiator adopts). //! - **consumer** (accept side): the `ChannelsAdapter` + capturing //! install hook. The hook captures the consumer's channel-0 @@ -43,7 +43,7 @@ use alktunnels::producer::{ }; /// The consumer identity the transport carries (the mTLS/QUIC -/// analogue — key-based identity resolved out-of-band, attached to +/// analogue: key-based identity resolved out-of-band, attached to /// the dialing connection before `from_connection_with_serving`). pub fn consumer_identity() -> Identity { Identity { @@ -54,7 +54,7 @@ pub fn consumer_identity() -> Identity { } /// The direct-egress identity: `tunnel:direct` ONLY (no -/// `tunnel:open`) — the scope-separation probe's identity. +/// `tunnel:open`); this is the scope-separation probe's identity. pub fn direct_identity() -> Identity { Identity { id: "consumer-direct".to_string(), @@ -64,7 +64,7 @@ pub fn direct_identity() -> Identity { } /// Both scopes: `tunnel:open` AND `tunnel:direct` (a deployment -/// granting both grants both explicitly — ADR-006 Amendment 1). +/// granting both grants both explicitly; ADR-006 Amendment 1). pub fn both_scopes_identity() -> Identity { Identity { id: "consumer-both".to_string(), @@ -76,7 +76,7 @@ pub fn both_scopes_identity() -> Identity { } } -/// The scoped token (the hub-forwarding path) — resolves to +/// The scoped token (the hub-forwarding path) resolves to /// [`consumer_identity`] via [`TestIdProvider`]. pub const TEST_AUTH_TOKEN: &str = "test-token-with-tunnel-open-scope"; @@ -105,23 +105,23 @@ pub struct Topology { /// The consumer's manager (accept side). `adopt` installs the /// producer-allocated IDs here; reaping calls `teardown_channel`. pub consumer_manager: ChannelManager, - /// The consumer's channel-0 call connection — its surface for + /// The consumer's channel-0 call connection, the surface for /// calling the producer's open op (and channel/close). pub consumer_call: Arc, /// The producer's serving registry (probes). pub producer_registry: Arc, - /// The consumer's serving registry (the opener-side op stubs — - /// e.g. the forwarded-op serving stub — pre-register here). + /// The consumer's serving registry (the opener-side op stubs, + /// e.g. the forwarded-op serving stub, pre-register here). pub consumer_registry: Arc, /// What the establisher's per-call auth carried as the opener - /// identity id (the CF-006 probe — `None` until an open runs). + /// identity id (the CF-006 probe; `None` until an open runs). pub identity_witness: Arc>>, } /// Wire the topology. `transport_identity` is the key-based identity /// attached to the dialing connection (CF-005 remediation (b)); /// `serving_identity` is the explicit `ServingConfig.identity` -/// override (CF-005 remediation (a)) — `None` in the primary path. +/// override (CF-005 remediation (a)), `None` in the primary path. /// `provider` resolves payload tokens (the hub-forwarding path). /// The open-op registration mode: a dial establisher (shape 1) or a /// listen establisher over an assembly-fed [`AcceptQueue`] (shape 2). @@ -130,10 +130,10 @@ pub enum RegistrationMode { Listen(alktunnels::producer::AcceptFn), /// A hanging establisher registered with an explicit /// per-registration establishment timeout (the deadline-expiry - /// probe — ADR-049 §2's bound override; the open fails with reason + /// probe: ADR-049 §2's bound override; the open fails with reason /// `timeout` and no channel survives). Timeout(std::time::Duration), - /// The no-witness dial establisher (`tunnel_establisher` — the + /// The no-witness dial establisher (`tunnel_establisher`, the /// public default shape, no CF-006 probe seam). DialNoWitness(alktunnels::producer::DialFn), /// The direct op (`channels/tunnel/direct`, ADR-007): the direct @@ -146,7 +146,7 @@ pub enum RegistrationMode { /// establisher + the accept dispatcher over the injected bind + /// accept loop + forwarded-open caller. The forwarded-op caller /// rides the producer-side call plane (`producer_client`'s call - /// connection — captured in the topology) and adopts on the + /// connection, captured in the topology) and adopts on the /// producer-side manager. BindFirst( alktunnels::producer::BindFn, @@ -168,7 +168,7 @@ pub async fn wire_with( tokio::sync::mpsc::channel::(1); // The consumer's serving registry: empty by default; a test that - // installs opener-side ops (the forwarded-op serving stub — the + // installs opener-side ops (the forwarded-op serving stub, the // ADR-008 §3 ingress shape) pre-registers them here so the // dispatch loop serves them from the first inbound call. let consumer_serving_registry = Arc::new(OperationRegistry::new()); @@ -202,8 +202,8 @@ pub async fn wire_with( let _ = call_tx.send(Arc::clone(&call_connection)).await; let _ = manager_tx.send(manager).await; // The consumer serves the ops the test pre-registered on - // its serving registry (empty in the default topology — - // the dispatcher keeps the call loop alive either way; its + // its serving registry (empty in the default topology; + // the dispatcher keeps the call loop alive either way, and its // read loop resolves the producer's responses to the // consumer's pending entries). let dp = Dispatcher::new( @@ -252,7 +252,7 @@ pub async fn wire_with( .expect("producer channel client init"); // The generic channel ops ride the producer's serving registry - // (channel/close tears a channel down out-of-band — the review + // (channel/close tears a channel down out-of-band: the review // 007 recipe). let ops = ChannelOperations::with_default_policy(producer_client.manager().clone()); ops.register_on(&producer_op_registry) @@ -304,12 +304,12 @@ pub async fn wire_with( // closes over the producer-side call plane + manager (the // design pin: the per-channel `Connection` the handler // receives is stream-less and carries no call surface, so - // the registration takes the call-plane handle — here the + // the registration takes the call-plane handle, here the // serving session's `CallConnection`, which the client // holds; in production the assembly layer has the same // handle because it constructed the serving session). The // harness reaches it via `producer_client.call_open_op` + - // `adopt_channel` — the reverse POC's + // `adopt_channel`, the reverse POC's // `open_reverse_channel` shape, inline (the reply's extra // fields are not needed for the forwarded op; the channel // ID extraction + adopt is the whole dance). @@ -369,7 +369,7 @@ pub async fn wire_with( .expect("register tunnel bindfirst openable"); } RegistrationMode::DialNoWitness(dial) => { - // The public no-witness wrapper (tunnel_establisher) — + // The public no-witness wrapper (tunnel_establisher): // registration + establisher body identical to the // witnessed variant minus the CF-006 seam. let core = alkcall::channels::operations::ChannelCore::new( @@ -396,7 +396,7 @@ pub async fn wire_with( // above on the SAME registry are unaffected (per-op // timeout, ADR-049 §2). // A fresh ChannelCore over the SAME manager + policy (both - // cores are cheap facades over the same pair — the second + // cores are cheap facades over the same pair; the second // registration rides the same ledger/cap state; this arm // is local to the probe so the other arms' `core` binding // stays untouched). @@ -442,7 +442,7 @@ pub async fn wire_with( /// The default topology: the consumer's transport identity is the /// primary caller-identity path (no token). The provider is -/// noop-shaped — only the identity chain authorizes. +/// noop-shaped; only the identity chain authorizes. pub async fn wire(registry: ResourceRegistry, dial: alktunnels::producer::DialFn) -> Topology { wire_with( registry, @@ -456,7 +456,7 @@ pub async fn wire(registry: ResourceRegistry, dial: alktunnels::producer::DialFn /// The direct-op topology (ADR-007): the direct op registered with /// the CF-006 witness; the consumer's transport identity is -/// [`both_scopes_identity`] (both grants — the opens authorize). +/// [`both_scopes_identity`] (both grants; the opens authorize). pub async fn wire_direct(dial: alktunnels::producer::DialFn) -> Topology { wire_with( ResourceRegistry::new(), @@ -482,7 +482,7 @@ pub async fn wire_direct_with_identity(identity: Identity) -> Topology { } /// A topology with the consumer's transport identity REPLACED by the -/// serving-side override (`ServingConfig.identity` — CF-005 (a) probe +/// serving-side override (`ServingConfig.identity`, CF-005 (a) probe /// shape) or stripped entirely (the fail-closed probe). The transport /// identity still rides the dialing connection; the override is what /// the serving dispatch resolves (the witness proves which one won). @@ -522,11 +522,11 @@ pub async fn wire_listen( /// establisher binds via the injected [`BindFn`]; the accept /// dispatcher runs the injected accept loop and opens forwarded /// channels back toward the opener (whose serving stub the test -/// installs on the consumer side — see +/// installs on the consumer side; see /// [`serve_forwarded_on_consumer`]). Same identity posture as -/// [`wire`], plus the forwarded scope (the opener-side ingress grant -/// — the consumer identity must hold `tunnel:forwarded` to receive -/// producer-initiated forwarded opens). +/// [`wire`], plus the forwarded scope (the opener-side ingress +/// grant: the consumer identity must hold `tunnel:forwarded` to +/// receive producer-initiated forwarded opens). pub async fn wire_bindfirst( registry: ResourceRegistry, bind: alktunnels::producer::BindFn, @@ -544,7 +544,7 @@ pub async fn wire_bindfirst( /// The forwarded-op opener identity: `tunnel:forwarded` AND /// `tunnel:open` (the opener receives producer-initiated forwarded -/// opens — the ingress grant — and opens the listen tunnel itself — +/// opens, the ingress grant, and opens the listen tunnel itself, /// the egress grant; ADR-008 §3 / ADR-006 Amendment 1). pub fn forwarded_opener_identity() -> Identity { Identity { @@ -558,7 +558,7 @@ pub fn forwarded_opener_identity() -> Identity { } /// [`forwarded_opener_identity`] minus the forwarded grant (the -/// per-accept isolation probe's identity — the forwarded open fails +/// per-accept isolation probe's identity; the forwarded open fails /// FORBIDDEN). pub fn forwarded_scopeless_identity() -> Identity { Identity { @@ -569,18 +569,18 @@ pub fn forwarded_scopeless_identity() -> Identity { } /// Serve the forwarded op (`channels/tunnel/forwarded`, ADR-008 §3) on -/// the CONSUMER side (the listen opener's serving stub — the test +/// the CONSUMER side (the listen opener's serving stub, the test /// stand-in for the opener-side registration that lands with /// `tunnels/forwarded-receipt`): a `register_openable` (no -/// establisher — the data plane starts immediately) whose handler +/// establisher; the data plane starts immediately) whose handler /// correlates the open by the `listen` token against the test's /// correlation map, accepts the `BiStream`, and pumps against the /// "local BIND client" halves the test registered for that token. /// /// `correlations` maps `listen` token → the pump side to pump the -/// forwarded channel against (the assembly-side bookkeeping posture — +/// forwarded channel against (the assembly-side bookkeeping posture: /// the protocol provides the op + params, the test provides the map). -/// The registration is post-hoc on the consumer's captured registry — +/// The registration is post-hoc on the consumer's captured registry, /// but the CONSUMER serves no ops in the default harness (its /// dispatch registry is empty and the dispatcher runs on it), so this /// helper must be called BEFORE the topology is wired; hence it is a @@ -611,7 +611,7 @@ pub type CorrelationMap = /// The forwarded-op handler (the opener-side serving stub's handler): /// correlate by `listen` → accept the `BiStream` → pump against the /// correlated halves (`pump_bidi`, convention 8). A correlation miss -/// is a loud `handler_error`-class open failure (the typed posture — +/// is a loud `handler_error`-class open failure (the typed posture: /// the accept dies at birth; the loop keeps serving). pub fn make_forwarded_stub_handler( correlations: CorrelationMap, @@ -650,7 +650,7 @@ pub fn make_forwarded_stub_handler( } /// The forward topology (the `-L` direction): the consumer dials the -/// transport (connect side — `ChannelClient::from_connection`, a pure +/// transport (connect side, `ChannelClient::from_connection`, a pure /// consumer serving nothing) and the producer accepts (the adapter + /// an install hook that registers the channel ops + the tunnel /// openable on the producer's serving registry). This is the topology @@ -667,7 +667,7 @@ pub struct ForwardTopology { /// manager adopted the producer-allocated channel IDs; the /// session reaps via this manager. pub consumer: Arc, - /// The producer's manager (accept side, serving) — for asserting + /// The producer's manager (accept side, serving), for asserting /// wrapper-managed reaping. pub producer_manager: ChannelManager, /// What the establisher's per-call auth carried (the CF-006 probe). @@ -740,7 +740,7 @@ pub async fn wire_forward( let consumer_conn = CoreConnection::from_bidi(consumer_end, b"alk/channels".to_vec(), None); let producer_conn = CoreConnection::from_bidi(producer_end, b"alk/channels".to_vec(), None); - // The dialer's transport identity (the mTLS/QUIC posture) — set + // The dialer's transport identity (the mTLS/QUIC posture), set // before from_connection. The accept side resolves it out-of-band // (modeled in the hook above). consumer_conn @@ -777,8 +777,9 @@ pub async fn wire_forward( } /// The DIRECT-op forward topology (ADR-007): the producer (accept -/// side, serving) registers the direct op — [`register_tunnel_direct_openable`] -/// over the injected dial, CF-006 witness attached — and the consumer +/// side, serving) registers the direct op +/// ([`register_tunnel_direct_openable`] over the injected dial, CF-006 +/// witness attached) and the consumer /// (connect side, pure client) holds the identity the session tests /// authorize with. This is the topology `TunnelSession::open_direct` /// is normative for. @@ -812,7 +813,7 @@ pub async fn wire_direct_forward_with_identity( let (writer, reader) = split_single_stream(channel0_bidi); // The accept-side transport-identity posture: the transport // authenticated the dialer; attach the RESOLVED identity - // (scopes included — resolved out-of-band) to channel 0. + // (scopes included, resolved out-of-band) to channel 0. let _ = channel0_conn.set_identity(caller); let call_connection = Arc::new(CallConnection::new_single_stream( channel0_conn, @@ -877,14 +878,14 @@ pub async fn wire_direct_forward_with_identity( } } -/// The listen-flow topology (ADR-008 §4 — the `TunnelListener` shape): -/// the OPENER dials the transport (connect side — +/// The listen-flow topology (ADR-008 §4, the `TunnelListener` shape): +/// the OPENER dials the transport (connect side, /// `ChannelClient::from_connection_with_serving`, so it can both open /// the listen tunnel and serve the forwarded op), and the producer /// accepts (the adapter + an install hook registering the bind-first /// openable over the injected bind + accept loop). The producer-side /// forwarded-open caller rides the accept-side call plane (the -/// serving session's `CallConnection` — the same surface the serving +/// serving session's `CallConnection`, the same surface the serving /// dispatch loop runs on; both-sides sanity, ADR-022 §2) and adopts on /// the producer-side manager. The forwarded op registers post-hoc (W2) /// on the opener's captured `opener_registry`. @@ -897,10 +898,10 @@ pub struct ListenerTopology { /// The opener's serving registry (the forwarded op registers here /// post-hoc). pub opener_registry: Arc, - /// The producer's manager (accept side, serving) — for asserting + /// The producer's manager (accept side, serving), for asserting /// wrapper-managed reaping + for the test's adopt dance. pub producer_manager: ChannelManager, - /// The producer-side call plane (`Arc`) — the + /// The producer-side call plane (`Arc`), the /// forwarded-op caller's surface (and the test's direct-call /// surface for the adopt-pin dance). pub producer_call: Arc, @@ -967,9 +968,9 @@ pub async fn wire_listener_bindfirst( .expect("register channel ops on producer"); // The forwarded-open caller (the call plane): call + adopt - // (the reverse POC's `open_reverse_channel` shape — the - // serving session's `CallConnection` is the per-accept call - // plane; the producer-side manager adopts the + // (the reverse POC's `open_reverse_channel` shape: the + // serving session's `CallConnection` is the per-accept + // call plane, and the producer-side manager adopts the // opener-allocated ID). let call_for_opener = Arc::clone(&call_connection); let manager_for_opener = manager.clone(); @@ -1040,7 +1041,7 @@ pub async fn wire_listener_bindfirst( let (opener_end, producer_end) = tokio::io::duplex(64 * 1024); let opener_conn = CoreConnection::from_bidi(opener_end, b"alk/channels".to_vec(), None); let producer_conn = CoreConnection::from_bidi(producer_end, b"alk/channels".to_vec(), None); - // The dialer's transport identity (the mTLS/QUIC posture) — set + // The dialer's transport identity (the mTLS/QUIC posture), set // before from_connection_with_serving. The accept side resolves it // out-of-band (modeled in the hook above). opener_conn @@ -1090,7 +1091,7 @@ pub async fn wire_listener_bindfirst( } /// An echo dial closure (the ADR-004 injection point under test): an -/// in-process pipe per dial — the "target" echoes bytes until EOF. +/// in-process pipe per dial; the "target" echoes bytes until EOF. /// The consumer side gets split `DuplexStream` halves; a spawned task /// echoes the other end. No `local` feature, no sockets. pub fn echo_dial() -> alktunnels::producer::DialFn { @@ -1142,7 +1143,7 @@ pub fn all_substrate_dial() -> alktunnels::producer::DialFn { } /// A framed UDP echo dial whose target end CLOSES after the first -/// echoed datagram — the EOF-path harness. `payload_completes` +/// echoed datagram, the EOF-path harness. `payload_completes` /// controls the truncation shape: `true` forwards the whole frame /// (echo + payload intact) then drops → the clean-EOF path; /// `false` forwards only the 2-byte length prefix then drops → the @@ -1164,7 +1165,7 @@ pub fn closing_udp_echo_dial(payload_completes: bool) -> alktunnels::producer::D // echo-closes-after-first shape): forward it // whole (clean-EOF shape) or forward only its // length prefix (truncated shape), then drop - // the far end — the channel read half EOFs. + // the far end; the channel read half EOFs. let n = match t_read.read(&mut buf).await { Ok(0) | Err(_) => return, Ok(n) => n, @@ -1182,7 +1183,7 @@ pub fn closing_udp_echo_dial(payload_completes: bool) -> alktunnels::producer::D let _ = t_write.flush().await; } else { // The truncated shape: forward ONLY - // the 2-byte length prefix, then drop — + // the 2-byte length prefix and drop; // the declared payload never arrives. if t_write.write_all(&framed[..2]).await.is_err() { return; @@ -1219,7 +1220,7 @@ pub fn failing_dial(message: &'static str) -> alktunnels::producer::DialFn { }) } -/// A framed UDP echo dial (the ADR-003 codec at the target boundary — +/// A framed UDP echo dial (the ADR-003 codec at the target boundary, /// the in-process stand-in for the real `local` UDP adapter): the /// dial's halves carry the `[len: u16 BE]` wire framing; a spawned /// echo task decodes frames from the channel side and re-frames the diff --git a/tests/local_bindfirst.rs b/tests/local_bindfirst.rs index 4966611..ea1c018 100644 --- a/tests/local_bindfirst.rs +++ b/tests/local_bindfirst.rs @@ -1,6 +1,6 @@ //! Tests for the `local` feature's BIND-FIRST half (shape 3, ADR-008): //! the real-socket `BindFn` sources (`bind_tcp_first` / -//! `bind_unix_first` — the `bound` reply field's data, port 0 +//! `bind_unix_first`, the `bound` reply field's data, port 0 //! resolved), the accept-pair loops (peer + halves per accept), and //! the assembly convenience closures (`local_bind_first` + //! `local_bindfirst_accept_loop`), through the real bind-first @@ -10,7 +10,7 @@ //! OS-chosen one (a fake `bound` would strand the test's dials), two //! clients dial → two accepts → two forwarded channels pumping //! independently (peer fidelity: each forwarded open carries the REAL -//! accepted peer), unix bind-first parity (OQ-TN-14 — the bind path is +//! accepted peer), unix bind-first parity (OQ-TN-14; the bind path is //! the same shape as the dial path), and the udp bind-first refusal //! (a UDP socket has no accept). @@ -37,14 +37,14 @@ fn init_tracing() { /// The forwarded-op recording stub's peer log: the `peer` param of /// every forwarded open the producer's dispatcher made (the -/// reply#2-fidelity probe surface — ADR-008 §3). +/// reply#2-fidelity probe surface (ADR-008 §3). type PeerLog = Arc>>; /// The forwarded-op opener-side serving stub (the queue-fed shape): /// each forwarded open pops the NEXT app-side pair (the "local BIND /// client" halves the test registered) and pumps the forwarded /// channel against it, recording the open's `peer` param (the -/// reply#2-fidelity probe surface — ADR-008 §3). One listener session +/// reply#2-fidelity probe surface (ADR-008 §3). One listener session /// = one token = MANY forwarded opens (per accept), so the /// correlation is a queue, not a one-shot map. fn recording_forwarded_handler(app_sides: AppSideQueue, peers: PeerLog) -> OpenHandler { @@ -80,13 +80,13 @@ fn recording_forwarded_handler(app_sides: AppSideQueue, peers: PeerLog) -> OpenH } /// The app-side pairs the stub pops per forwarded open (the "local -/// BIND client" halves — the stub's pump target; the far end is the +/// BIND client" halves, the stub's pump target; the far end is the /// test's echo task). type AppSideQueue = Arc>>; /// Install the recording stub on the opener (consumer) side (post-hoc -/// on the captured serving registry — W2). +/// on the captured serving registry (W2). async fn install_recording_stub(topo: &harness::Topology) -> (AppSideQueue, PeerLog) { let app_sides: AppSideQueue = Arc::new(tokio::sync::Mutex::new(Default::default())); let peers: PeerLog = Arc::new(tokio::sync::Mutex::new(Vec::new())); @@ -105,7 +105,7 @@ async fn install_recording_stub(topo: &harness::Topology) -> (AppSideQueue, Peer } /// Call the producer's open op on channel 0 with the FULL reply (the -/// bind-first reply projection probe — `bound` + `listen`). +/// bind-first reply projection probe (`bound` + `listen`). async fn open_listen_with_reply( topo: &harness::Topology, resource: &str, @@ -130,7 +130,7 @@ async fn open_listen_with_reply( } /// One app-side session: a raw duplex pair whose FAR end echoes (the -/// app-side session shape) — the stub pumps the forwarded channel +/// app-side session shape); the stub pumps the forwarded channel /// against the near end (pushed into the stub's queue); the test /// drives the REAL accepted socket at the tunnel's other side, whose /// bytes echo through the app-side session. @@ -161,7 +161,7 @@ fn echo_app_side() -> alktunnels::producer::TargetHandle { /// THE bind-first flow over REAL TCP sockets (the `local` shape-3 /// gate): `bind_tcp_first` binds port 0 → the reply's `bound` is the -/// REAL OS-chosen address (the test dials it — a fake bound would +/// REAL OS-chosen address (the test dials it; a fake bound would /// strand the dial) → two clients dial → two accepts → two forwarded /// channels → both pump independently, and each forwarded open /// carries the REAL accepted peer (peer fidelity, reply#2 class). @@ -197,7 +197,7 @@ async fn bindfirst_tcp_real_sockets_bound_fidelity_and_two_pumps() { let (app_sides, peers) = install_recording_stub(&topo).await; // The opener adopts the LISTEN channel (the idle side). The - // halves are HELD — dropping them EOFs the idle stream and tears + // halves are HELD; dropping them EOFs the idle stream and tears // the listener down (the ADR-008 sentinel is live even here). let (_idle_send, _idle_recv) = topo .consumer_manager @@ -251,7 +251,7 @@ async fn bindfirst_tcp_real_sockets_bound_fidelity_and_two_pumps() { assert_eq!(ports.len(), 2, "two accepts carried distinct real peers"); } -/// Unix bind-first parity (OQ-TN-14 — the bind path ships with the +/// Unix bind-first parity (OQ-TN-14; the bind path ships with the /// dial path): `bind_unix_first` binds a temp-path listener → the /// reply's `bound` is the REAL bound socket path → a unix client /// (bound to its own path, so the peer is representable) connects → @@ -295,7 +295,7 @@ async fn bindfirst_unix_real_sockets_bound_and_pumped() { let (app_sides, peers) = install_recording_stub(&topo).await; - // The opener adopts the LISTEN channel (the idle side) — held for + // The opener adopts the LISTEN channel (the idle side), held for // the test's lifetime (dropping the halves EOFs the idle stream // and tears the listener down, the ADR-008 sentinel). let (_idle_send, _idle_recv) = topo @@ -309,7 +309,7 @@ async fn bindfirst_unix_real_sockets_bound_and_pumped() { // The unix client binds its own socket at a named path FIRST (an // unnamed peer cannot be represented in the forwarded `peer` - // param — ADR-008 §3; the named-peer shape is the parity case). + // param (ADR-008 §3); the named-peer shape is the parity case). // socket2: std has no `UnixStream::bind`; a socket2 socket binds // the sockaddr at the path, then connects (the peer address is // the client's own bound path). @@ -350,7 +350,7 @@ async fn bindfirst_unix_real_sockets_bound_and_pumped() { } /// The udp bind-first refusal: a UDP socket has no accept, so the -/// bind closure fails loud (`handler_error`-class) — the shape is not +/// bind closure fails loud (`handler_error`-class); the shape is not /// defined there, never a silent fake listener. #[tokio::test] async fn udp_bind_first_fails_loud() { @@ -383,7 +383,7 @@ async fn udp_bind_first_fails_loud() { /// The accept-pair loop's mismatch probe: a listener NOT produced by /// this module's bind closures fails loud inside the closure (the -/// downcast is the assembly side of the type-erased injection — a +/// downcast is the assembly side of the type-erased injection; a /// foreign listener type is `handler_error`, never a phantom accept). #[tokio::test] async fn accept_pair_loop_downcast_miss_fails_loud() { @@ -401,27 +401,28 @@ async fn accept_pair_loop_downcast_miss_fails_loud() { ); } -/// The unnamed-peer/unnamed-bind fail-loud arms (G-5 — ADR-008 §3's +/// The unnamed-peer/unnamed-bind fail-loud arms (G-5, ADR-008 §3's /// two pinned sentences: a producer that cannot determine the peer /// does not serve a bind-first listener over that substrate): /// -/// - **Unnamed bind** — an empty-path `bind_unix` is the kernel's +/// - **Unnamed bind**: an empty-path `bind_unix` is the kernel's /// autobind (the kernel assigns an abstract name, addlen == /// offsetof(sun_path)); the bound addr has no filesystem path, so /// `local_addr` (and thus `bind_unix_first`) fails loud with the /// "no filesystem path" handler error instead of serving an /// unrepresentable `bound`. -/// - **Unnamed peer** — an addressless client (socket2 autobind +/// - **Unnamed peer**: an addressless client (socket2 autobind /// here; a plain unbound client behaves identically through tokio -/// accept — mio maps any abstract-namespace peer to unnamed) is +/// accept; mio maps any abstract-namespace peer to unnamed) is /// accepted by a NAMED listener; `accept_pair` fails loud because /// the forwarded op's `peer` param is required on the wire. #[cfg(target_os = "linux")] #[tokio::test] async fn unix_unnamed_bind_and_unnamed_peer_fail_loud() { - // Arm 1: the autobind listener — `bind_unix("")`. tokio passes the - // empty path through to `bind(2)` with addlen == offsetof(sun_path): - // the kernel autobinds an abstract name, no filesystem path exists. + // Arm 1 probes the autobind listener: `bind_unix("")`. tokio + // passes the empty path through to `bind(2)` with addlen == + // offsetof(sun_path): the kernel autobinds an abstract name, no + // filesystem path exists. let halves = alktunnels::local::bind_unix("") .expect("autobind bind succeeds (the kernel assigns the abstract name)"); let err = halves @@ -436,9 +437,9 @@ async fn unix_unnamed_bind_and_unnamed_peer_fail_loud() { "unnamed bind maps to the handler error: {err:?}" ); - // Arm 2: the unnamed peer — a socket2 autobind client connects to a - // NAMED listener; `accept_pair` must refuse (the forwarded `peer` - // param requires an address). + // Arm 2 probes the unnamed peer: a socket2 autobind client + // connects to a NAMED listener; `accept_pair` must refuse (the + // forwarded `peer` param requires an address). let dir = std::env::temp_dir().join(format!("alktunnels-g5-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); let listen_path = dir.join("named.sock"); diff --git a/tests/local_halves.rs b/tests/local_halves.rs index 22001bc..01b4b6d 100644 --- a/tests/local_halves.rs +++ b/tests/local_halves.rs @@ -5,8 +5,8 @@ //! //! The forward topology (`wire_forward`) carries the full path: the //! consumer opens (`TunnelSession::open`), the producer's establisher -//! dials via `local_dial`, the pump handler pumps — real sockets at -//! both ends. +//! dials via `local_dial`, and the pump handler pumps. Real sockets +//! sit at both ends. #![cfg(feature = "local")] @@ -115,7 +115,7 @@ async fn udp_datagram_round_trip_through_the_framed_adapter() { async fn direct_udp_through_local_dial_round_trips_framed() { // The DIRECT op's DialFn reuse pin (the local-bind-halves task): // `local_dial` is the ONE dial closure the base op and the direct - // op share — the UDP wrap (the FRAMED `UdpHalf` adapter, ADR-003) + // op share. The UDP wrap (the FRAMED `UdpHalf` adapter, ADR-003) // happens INSIDE the closure (review 001 U-1), so direct-udp gets // datagram-boundary framing free. Driven against a REAL UDP echo // socket: the session sends the codec frame, the direct establisher @@ -219,14 +219,14 @@ async fn truncation_fails_loud_oq_tn_13() { // The fail-loud posture (OQ-TN-13) at the FRAMED adapter: the // socket-facing recv uses the full 65535-byte scratch (try_recv // truncates silently at the buffer size), so a well-formed - // >caller-buffer datagram is received whole and STAGED — never + // >caller-buffer datagram is received whole and STAGED, never // silently truncated into a shorter frame. The staging surface is // pinned by `oversized_frame_stages_across_reads` (below); this // test pins the fail-loud path directly at the unit level: a // scratch smaller than the datagram WOULD have corrupted the // framing (the U-3 re-validation's warning), so the scratch must // be full-size. A datagram at the IP/UDP max (65507) is within - // the u16 frame bound (65535) — it must arrive intact. + // the u16 frame bound (65535); it must arrive intact. let sock_a = tokio::net::UdpSocket::bind(("127.0.0.1", 0)) .await .expect("bind a"); @@ -244,14 +244,14 @@ async fn truncation_fails_loud_oq_tn_13() { // The producer-side adapter (what the establisher dials): sock_a // wrapped as halves. A 65507-byte datagram (the UDP theoretical - // max — a well-formed wire's largest possible frame) from the + // max, a well-formed wire's largest possible frame) from the // wire side. let (read, _write) = alktunnels::local::UdpHalf::split(sock_a); let mut read = read; let payload: Vec = (0..65507u32).map(|i| (i % 251) as u8).collect(); sock_b.send(&payload).await.expect("send max-size"); - // The pump reads with the codec's chunk size (16 KiB) — far + // The pump reads with the codec's chunk size (16 KiB), far // smaller than the datagram: the adapter must STAGE, never // truncate. Drain through small reads and reassemble the frame. use tokio::io::AsyncReadExt; @@ -269,12 +269,12 @@ async fn truncation_fails_loud_oq_tn_13() { assert_eq!( &got[2..], &payload[..], - "full payload intact — no truncation" + "full payload intact; no truncation" ); } /// The >caller-buffer (but within-frame-bound) datagram: the framed -/// adapter STAGES the frame and hands it out across reads — the pump's +/// adapter STAGES the frame and hands it out across reads; the pump's /// far-side de-framer reassembles the true length (no silent /// truncation, no shorter-datagram misread). The reassembly is pinned /// by the end-to-end gates below; here the staging surface is pinned @@ -301,7 +301,7 @@ async fn oversized_frame_stages_across_reads() { let payload: Vec = (0..20000u32).map(|i| (i % 251) as u8).collect(); sock_b.send(&payload).await.expect("send 20k"); - // Read through a small caller buffer (16 KiB — smaller than the + // Read through a small caller buffer (16 KiB, smaller than the // ~20002-byte frame): the first read fills it completely, the // second hands out the staged remainder. use tokio::io::AsyncReadExt; @@ -333,7 +333,7 @@ fn len_lo(n: usize) -> u8 { /// A RECORDING UDP "target" (review 001's verification-gate probe /// shape, made permanent): it records every datagram it receives /// (byte-for-byte, to the shared log) and replies with canned, -/// configurable datagrams — including EMPTY and RAW-UNFRAMED ones the +/// configurable datagrams, including EMPTY and RAW-UNFRAMED ones the /// echo server can't produce. struct RecordingTarget { addr: std::net::SocketAddr, @@ -376,7 +376,7 @@ fn params(resource: &str, substrate: Substrate) -> TunnelParams { } } -/// GATE 1a (review 001): the target receives PAYLOAD BYTES ONLY — no +/// GATE 1a (review 001): the target receives PAYLOAD BYTES ONLY, no /// `[len: u16 BE]` codec prefix (the U-1 defect's primary face). #[tokio::test] async fn gate_target_receives_payload_bytes_only() { @@ -408,7 +408,7 @@ async fn gate_target_receives_payload_bytes_only() { session.close().await; } -/// GATE 1b: an EMPTY datagram round-trips over the REAL socket path — +/// GATE 1b: an EMPTY datagram round-trips over the REAL socket path: /// `recv_datagram` yields `Some(b"")` and the channel stays alive /// (the F-2 fix end-to-end; the raw adapter collapsed it to EOF). #[tokio::test] @@ -434,7 +434,7 @@ async fn gate_empty_datagram_round_trips_over_the_real_socket_path() { assert!(dg.is_empty(), "the empty datagram round-trips"); // The channel is still alive: a second exchange works (the - // canned reply cycles — the target sends b"still-alive" back). + // canned reply cycles; the target sends b"still-alive" back). session.send_datagram(b"second").await.expect("send 2"); let dg2 = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram()) .await @@ -446,7 +446,7 @@ async fn gate_empty_datagram_round_trips_over_the_real_socket_path() { } /// GATE 2: a >8 KiB datagram round-trips as ONE datagram at the -/// target (the 8 KiB copy-buffer split is gone — the frame reassembles +/// target (the 8 KiB copy-buffer split is gone; the frame reassembles /// at the adapter before crossing the pump). #[tokio::test] async fn gate_large_datagram_arrives_as_one_datagram() { @@ -460,7 +460,7 @@ async fn gate_large_datagram_arrives_as_one_datagram() { let mut session = TunnelSession::open(&topo.consumer, params("big", Substrate::Udp)) .await .expect("udp open"); - // 9000 bytes — the review's probe size that split as 8192 + 810. + // 9000 bytes, the review's probe size that split as 8192 + 810. let payload: Vec = (0..9000u32).map(|i| (i % 251) as u8).collect(); session.send_datagram(&payload).await.expect("send 9000"); for _ in 0..100 { @@ -482,10 +482,10 @@ async fn gate_large_datagram_arrives_as_one_datagram() { /// GATE 3: an unframed target-initiated datagram resolves at the /// session (the session codec decodes the framed bytes the adapter -/// produced — real-target traffic is not parked mid-frame). +/// produced; real-target traffic is not parked mid-frame). #[tokio::test] async fn gate_unframed_target_datagram_resolves_at_the_session() { - // A raw "HELLO" — a real server's greeting the raw adapter parked + // A raw "HELLO": a real server's greeting the raw adapter parked // mid-frame (the probe's 2s timeout). let target = recording_udp_target(vec![b"HELLO".to_vec()]).await; let registry = ResourceRegistry::new(); @@ -500,14 +500,14 @@ async fn gate_unframed_target_datagram_resolves_at_the_session() { session.send_datagram(b"ping").await.expect("send"); let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram()) .await - .expect("recv timed out — unframed target traffic was parked/dropped") + .expect("recv timed out; unframed target traffic was parked/dropped") .expect("io ok") .expect("datagram"); assert_eq!(&dg[..], b"HELLO"); session.close().await; } -/// U-2 (review 001): `connect_udp`'s bind is family-correct — a v6 +/// U-2 (review 001): `connect_udp`'s bind is family-correct. A v6 /// target dials from a v6-bound socket (the hardcoded `0.0.0.0` bind /// broke every v6-only target at connect). A real ::1 target /// round-trips through the full path. @@ -583,7 +583,7 @@ async fn listen_producer_over_a_real_tcp_listener() { }); // A local client connects (the accepted connection's source). The - // driving end comes from `try_clone` — the pump owns the accepted + // driving end comes from `try_clone`; the pump owns the accepted // handle; the driver pushes/reads the echo through the same real // socket. // std-level try_clone BEFORE converting to tokio streams: one fd @@ -629,7 +629,7 @@ async fn listen_producer_over_a_real_tcp_listener() { .await .expect("adopt"); // The producer's pump (wrapper-managed) copies channel <-> the - // accepted handle; the accepted handle IS the client socket — so + // accepted handle; the accepted handle IS the client socket, so // the session's borrowed halves drive it directly: the driver end // writes into the client socket, the pump carries it over the // channel, and the session halves read it (and back). @@ -653,13 +653,13 @@ async fn listen_producer_over_a_real_tcp_listener() { // UdpHalf write-side machinery (review 001's U-1 invariants, the // never-blocks paths the end-to-end gates don't reach): WouldBlock // stashing, the accepted-byte high-water backpressure, the flush -// drain, and the shutdown best-effort drain — driven with poll_fn +// drain, and the shutdown best-effort drain, driven with poll_fn // over a shrunken-SO_SNDBUF socket (deterministic, no timing). // ===================================================================== /// An UNCONNECTED UdpHalf write half: `try_send` fails /// deterministically with `EDESTADDRREQ` ("destination address -/// required") — the deterministic probe for the write path's ERROR +/// required"), the deterministic probe for the write path's ERROR /// arms on this host, where no UDP sender-blocking shape was /// reachable (loopback/veth drop silently; see the host-notes on the /// drain test). @@ -671,7 +671,7 @@ fn unconnected_write_half() -> Box( write: &mut W, @@ -700,11 +700,11 @@ async fn udp_half_write_queue_drains_on_flush_and_shutdown() { // accepts frames (the de-framer's queue + accepted accounting), // flush drains them onto the socket (Ready(Ok)), and shutdown's // best-effort drain reports Ready(Ok) (UDP has no shutdown). - // The peer reads (a real probe) — the datagrams land. + // The peer reads (a real probe), and the datagrams land. // // Host-notes (probed 2026-09-09): (a) a fresh-socket first // `try_send` may report WouldBlock spuriously on this kernel - // (one readiness+retry cycle clears it, ~20% of runs) — the + // (one readiness+retry cycle clears it, ~20% of runs); the // flush loop tolerates it, which also exercises the `sending` // stash + re-offer path whenever it fires. (b) The test runs // multi-thread: on a current-thread runtime the kernel's @@ -746,7 +746,7 @@ async fn udp_half_write_queue_drains_on_flush_and_shutdown() { ); } - // Flush drains the queue onto the socket — bounded cycles: the + // Flush drains the queue onto the socket (bounded cycles): the // quirk's stash+Pending resolves on the next flush (poll_send_ready // is Ok), a saturated socket stays queued (not this host's case). let mut flushed = false; @@ -792,12 +792,12 @@ async fn udp_half_write_queue_drains_on_flush_and_shutdown() { #[tokio::test] async fn udp_half_write_error_maps_loud_on_unconnected_socket() { // The ERROR arms: an unconnected socket's `try_send` fails - // deterministically (`EDESTADDRREQ`) — poll_write's drain maps + // deterministically (`EDESTADDRREQ`), so poll_write's drain maps // the socket error loud (the `pump_queue_to_socket` Err arm), // and flush re-raises it (the `poll_flush` error surface). The // high-water backpressure and the WouldBlock stash remain - // host-dependent (see connected_udp_pair's note) — covered by the - // leftover-coverage task. + // host-dependent (see connected_udp_pair's note); coverage lives + // with the leftover-coverage task. let mut write = unconnected_write_half(); let frame = [0, 3, b'x', b'y', b'z']; @@ -822,9 +822,9 @@ async fn udp_half_write_error_maps_loud_on_unconnected_socket() { #[tokio::test] async fn udp_half_poll_shutdown_drains_best_effort_and_is_idempotent() { // UDP has no shutdown: poll_shutdown drains the queue best-effort - // and reports Ready(Ok) — the drop-is-close posture. On a + // and reports Ready(Ok), the drop-is-close posture. On a // saturated socket the drain is best-effort too (the error is - // swallowed — shutdown never fails the pump). + // swallowed; shutdown never fails the pump). let mut write = unconnected_write_half(); use tokio::io::AsyncWriteExt; write @@ -843,7 +843,7 @@ async fn udp_half_flush_drains_repeatedly_until_the_queue_is_empty() { // The flush loop's pump-again shape (tokio's copy polls flush // after every window drain): each flush drains that window's // queued datagrams; five write+flush cycles → five datagrams at - // the peer (the fresh-socket quirk tolerated per cycle — see the + // the peer (the fresh-socket quirk tolerated per cycle; see the // host-notes on the drain test; multi-thread runtime for the same // delivery-timing reason). let peer = tokio::net::UdpSocket::bind(("127.0.0.1", 0)) @@ -874,7 +874,7 @@ async fn udp_half_flush_drains_repeatedly_until_the_queue_is_empty() { for i in 0..5u8 { let frame = [0, 1, i]; poll_write_once(&mut write, &frame).await.expect("accept"); - // Each cycle flushes (bounded retries — the fresh-socket + // Each cycle flushes (bounded retries: the fresh-socket // quirk's stash clears on the second readiness cycle). let mut flushed = false; for _ in 0..8 { @@ -906,7 +906,7 @@ async fn udp_half_flush_drains_repeatedly_until_the_queue_is_empty() { } /// The resolve arm (`connect_udp`'s no-addresses mapping): a target -/// whose name cannot be resolved (an RFC 6761 `.invalid` name — +/// whose name cannot be resolved (an RFC 6761 `.invalid` name, /// guaranteed NXDOMAIN, resolved instantly and without network I/O) /// maps to `dial_failed` (the resolve step's error posture, ADR-004's /// `DialFn` contract). Rootless, deterministic. @@ -928,14 +928,14 @@ async fn connect_udp_unresolvable_target_maps_to_dial_failed() { // ===================================================================== // The sender-block machinery (the coverage-weak-spots task's option 1, -// CLOSED — the recipe was found): a veth fixture with the peer +// CLOSED; the recipe was found): a veth fixture with the peer // address UNASSIGNED and the neighbor table flushed puts the // connected UDP sender into a PERMANENT WouldBlock/ENOSPC state // (the unresolved neighbor never resolves; the blocked write path's // skb stays charged to the socket's wmem with a shrunken SO_SNDBUF). // Assigning the peer address + binding the peer resolves ARP; the // queued/stashed datagrams drain and DELIVER. Validated on this host -// (kernel 6.8.0-110-generic, tokio 1.53.1, socket2 0.6.5) — 3/3 +// (kernel 6.8.0-110-generic, tokio 1.53.1, socket2 0.6.5): 3/3 // deterministic probe runs, 2026-09-09. // // Root-gated (`ip link add`): auto-skipped without root. Never @@ -944,7 +944,7 @@ async fn connect_udp_unresolvable_target_maps_to_dial_failed() { /// The veth fixture (created and torn down per run): `alktx/alktx-p` /// with 10.240.0.1/24 (the sender side) and 10.240.0.9 (the peer -/// side, UNASSIGNED during the block phase — its absence is what +/// side, UNASSIGNED during the block phase; its absence is what /// pins the sender). The peer address is assigned mid-test (the /// resolve step). A dead-simple guard skips the test when `ip` is /// unavailable or the command fails (non-root hosts). @@ -992,7 +992,7 @@ impl VethFixture { return None; } let _ = ip_cmd(&["addr", "add", "10.240.0.1/24", "dev", "alktx"]).await; - // A jumbo MTU (65536): a 60 KB datagram crosses as ONE frame — + // A jumbo MTU (65536): a 60 KB datagram crosses as ONE frame, // no IP fragmentation, so no fragment-reassembly races at the // peer (fragment-ID collisions under the drain burst merged // datagrams on an MTU-1500 veth during the probe runs). @@ -1039,14 +1039,14 @@ impl Drop for VethFixture { /// A sender socket blocked on the unresolved neighbor: connect to the /// UNASSIGNED peer address, flush the neighbor table, shrunken /// SO_SNDBUF. `try_send` on a 60 KB datagram (truesize beyond the -/// buffer) then reports WouldBlock/ENOSPC persistently — the state +/// buffer) then reports WouldBlock/ENOSPC persistently; the state /// the write-side machinery is for. Returns the tokio socket ready /// for `UdpHalf::split`. async fn blocked_sender_socket(peer_port: u16) -> Option { // The BLOCK-STATE PROBE runs on a THROWAWAY socket: an oversized // probe datagram's partially-built skb chain stays corked in // sk_write_queue on EAGAIN (the kernel flushes it on the next - // successful send) — poisoning the socket with a stale partial + // successful send), poisoning the socket with a stale partial // datagram. The test socket below is created fresh afterwards. let probe = socket2::Socket::new( socket2::Domain::IPV4, @@ -1065,7 +1065,7 @@ async fn blocked_sender_socket(peer_port: u16) -> Option // Confirm the block actually holds (the host invariant): with a // 64 KiB SO_SNDBUF, the FIRST 60 KB datagram queues on the - // unresolved neighbor (Ok — its skb is charged to wmem); every + // unresolved neighbor (Ok; its skb is charged to wmem); every // SUBSEQUENT send EAGAINs (the charge exceeds the buffer) until // the neighbor resolves. That persistent EAGAIN/ENOBUFS pair IS // the block state. A resolved neighbor would send Ok throughout. @@ -1101,10 +1101,10 @@ async fn blocked_sender_socket(peer_port: u16) -> Option Some(snd) } -/// THE sender-block machinery, end to end (option 1 — one test, the +/// THE sender-block machinery, end to end (option 1: one test, the /// coherent behavior): the WouldBlock stash (`poll_write`'s /// opportunistic drain stashes the mid-send datagram and registers -/// the send waker — `pump_queue_to_socket`'s Pending arms), the +/// the send waker, `pump_queue_to_socket`'s Pending arms), the /// accepted-byte high-water backpressure (`poll_write` Pends when the /// queue stays over [`alktunnels::local::WRITE_QUEUE_HIGH_WATER`]), /// the flush re-pump (readiness → drain), and the post-resolve @@ -1185,7 +1185,7 @@ async fn udp_half_write_side_blocks_stashes_and_drains_on_resolve() { } assert!(pending_flushes > 0, "the blocked socket Pends the flush"); - // Phase B: resolve — assign the peer address (the neighbor + // Phase B: resolve. Assign the peer address (the neighbor // completes), bind the peer, wait for ARP; the queued datagrams // drain and DELIVER. assert!(veth.assign_peer(), "assign the peer address"); @@ -1212,10 +1212,10 @@ async fn udp_half_write_side_blocks_stashes_and_drains_on_resolve() { assert!(flushed, "the queued datagrams drained after the resolve"); // The peer received the queued datagrams (delivery across the - // blocked→resolved window — the stash/queue never lost them; at - // least the stashed tail must arrive — the periodic - // INCOMPLETE→FAILED drops may consume some queued ones, which - // is kernel-side loss, not adapter loss). + // blocked→resolved window): the stash/queue never lost them, and + // at least the stashed tail must arrive. The periodic + // INCOMPLETE→FAILED drops may consume some queued ones; that + // loss is kernel-side, not adapter loss). tokio::time::sleep(std::time::Duration::from_millis(1500)).await; let mut got = 0usize; let mut buf = vec![0u8; 65535]; diff --git a/tests/producer_bindfirst.rs b/tests/producer_bindfirst.rs index 68ac856..7a56744 100644 --- a/tests/producer_bindfirst.rs +++ b/tests/producer_bindfirst.rs @@ -1,8 +1,8 @@ //! Integration tests for the bind-first establisher + accept -//! dispatcher (shape 3 — ADR-008 §1): the producer whose establisher +//! dispatcher (shape 3, ADR-008 §1): the producer whose establisher //! BINDS (via the injected `BindFn`), mints the correlation token, //! contributes `bound` + `listen` to the open reply, and whose pump -//! handler is the accept dispatcher — per accept it opens +//! handler is the accept dispatcher, which per accept opens //! `channels/tunnel/forwarded` back toward the listen opener and pumps //! the accepted halves against the adopted channel. //! @@ -48,7 +48,7 @@ fn listen_params(resource: &str) -> TunnelParams { } /// Call the producer's open op on channel 0 with the FULL reply -/// (the consumer's bind-first surface — the reply projection probe). +/// (the consumer's bind-first surface, the reply projection probe). async fn open_listen_with_reply( topo: &Topology, params: &TunnelParams, @@ -112,7 +112,7 @@ async fn push_accept( } /// The assembly's bind: produces a "listener" (the queue's identity) -/// and the OS-chosen bound address (the test's fake — a real +/// and the OS-chosen bound address (the test's fake; a real /// `bind_tcp` variant lives in the `local` feature). fn test_bind() -> BindFn { Arc::new(|_substrate, _backing| { @@ -128,7 +128,7 @@ fn test_bind() -> BindFn { }) } -/// A failing bind (the `dial_failed`-class probe — the bind IS the +/// A failing bind (the `dial_failed`-class probe, since the bind IS the /// dial here). fn failing_bind(message: &'static str) -> BindFn { Arc::new(move |_substrate, _backing| { @@ -150,7 +150,7 @@ fn only_channel_0(topo: &Topology) -> bool { } /// The bind-first topology keeps the LISTEN channel open (the -/// dispatcher holds it) — the "no stray channels" probe excludes it +/// dispatcher holds it); the "no stray channels" probe excludes it /// (and channel 0, the call plane) on both sides. fn no_stray_channels(topo: &Topology, listen_id: u32) -> bool { topo.consumer_manager @@ -208,7 +208,7 @@ async fn bindfirst_full_flow_end_to_end() { ) .await; - // The opener opens the listen tunnel (with-reply surface — the + // The opener opens the listen tunnel (with-reply surface, the // bind-first reply projection). let (channel_id, reply) = open_listen_with_reply(&topo, &listen_params("bind")) .await @@ -232,7 +232,7 @@ async fn bindfirst_full_flow_end_to_end() { let correlations = install_forwarded_stub(&topo).await; // The LOCAL BIND client (the app-server-side session on the - // opener): a raw duplex pair — the map's halves are the stub's + // opener): a raw duplex pair; the map's halves are the stub's // pump target, the far end is the test's client end. The stub's // `pump_bidi` bridges the forwarded channel ↔ these halves, so // bytes the test writes on the far end traverse the whole path @@ -247,7 +247,7 @@ async fn bindfirst_full_flow_end_to_end() { }, ); - // The opener adopts the LISTEN channel (the idle side — its EOF + // The opener adopts the LISTEN channel (the idle side; its EOF // is the teardown signal). topo.consumer_manager .adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None) @@ -259,7 +259,7 @@ async fn bindfirst_full_flow_end_to_end() { // The dispatcher opens forwarded; the opener's stub handler // correlates by token and pumps against the local halves. The - // forwarded channel ID arrives on the opener's manager — find it + // forwarded channel ID arrives on the opener's manager; find it // (the only non-0, non-listen channel on the opener side after // the adopt + forwarded open settle). tokio::time::sleep(std::time::Duration::from_millis(300)).await; @@ -291,7 +291,7 @@ async fn bindfirst_full_flow_end_to_end() { // Teardown follows the accepted side: the far echo closes when the // accepted handle's pump sees the test client's EOF. (No channel - // assertions here — the listen channel legitimately stays open.) + // assertions here: the listen channel legitimately stays open.) } /// The listen-channel EOF teardown: drop the listener session (close @@ -323,7 +323,7 @@ async fn listen_channel_eof_cancels_loop_and_tears_listener_down() { .expect("token"); // The opener adopts the listen channel and then DROPS its stream - // (the listener session is dropped — the zero-byte EOF sentinel). + // (the listener session is dropped, hence the zero-byte EOF sentinel). let (send, _recv) = topo .consumer_manager .adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None) @@ -338,7 +338,7 @@ async fn listen_channel_eof_cancels_loop_and_tears_listener_down() { "the listen channel reaped after the session dropped" ); - // A follow-on accept attempt: the loop is gone — the accept queue + // A follow-on accept attempt: the loop is gone, so the accept queue // accepts nothing new (the listener was torn down with the plan). // Observable via the queue: nothing pumps it anymore. let _ = token; @@ -358,7 +358,7 @@ async fn listen_channel_eof_cancels_loop_and_tears_listener_down() { async fn forwarded_open_failure_isolates_the_accept() { init_tracing(); // The topology's consumer identity holds `tunnel:open` but NOT - // `tunnel:forwarded` — every forwarded open fails FORBIDDEN at the + // `tunnel:forwarded`, so every forwarded open fails FORBIDDEN at the // opener's ACL. The loop must keep serving (a second accept // produces a second forwarded-open attempt). let registry = ResourceRegistry::new(); @@ -383,7 +383,7 @@ async fn forwarded_open_failure_isolates_the_accept() { .await .expect("listen open"); - // First accept: the forwarded open fails (scope-less opener) — + // First accept: the forwarded open fails (scope-less opener), // the accepted connection drops, the loop continues. push_accept(&queue, ¬ify, (198, 55233)).await; tokio::time::sleep(std::time::Duration::from_millis(300)).await; @@ -392,7 +392,7 @@ async fn forwarded_open_failure_isolates_the_accept() { "a failed forwarded open leaves no channel beyond the listen channel" ); - // Second accept: the loop is still alive (per-accept isolation) — + // Second accept: the loop is still alive (per-accept isolation); // this one fails the same way, but the LOOP's health is what the // test pins: the accept was CONSUMED (the queue drained). push_accept(&queue, ¬ify, (198, 55234)).await; @@ -459,7 +459,7 @@ async fn establishment_errors_are_typed_on_the_wire() { /// Token discipline: two opens on the same producer mint distinct /// tokens; each dispatcher stamps ITS OWN plan's token (the second -/// open's forwarded opens carry the second token — pinned via the +/// open's forwarded opens carry the second token, pinned via the /// correlation stub's map: the FIRST token's entry is left /// unconsumed). #[tokio::test] @@ -507,7 +507,7 @@ async fn token_discipline_two_opens_distinct_tokens() { // Both dispatchers are live. Correlate BOTH tokens (each opener // session keys its own), but only register the local halves for - // t1 — a forwarded open stamped t2 would correlation-miss. Push + // t1; a forwarded open stamped t2 would correlation-miss. Push // one accept AFTER open 2: whichever dispatcher consumed it, the // STAMPED token is the plan's own. To pin "each dispatcher stamps // ITS OWN plan's token", correlate both but track which token the @@ -541,7 +541,7 @@ async fn token_discipline_two_opens_distinct_tokens() { /// The forwarded-op serving stub's forwarded-substrate log: the /// `substrate` param of every forwarded open (the M-1 remediation -/// probe surface — the substrate's SOURCE: the plan, not a re-parse). +/// probe surface, the substrate's SOURCE: the plan, not a re-parse). pub type SubstrateLog = Arc>>; /// The forwarded-op serving stub variant that records each forwarded @@ -610,16 +610,16 @@ async fn install_substrate_recording_stub( /// M-1 remediation pin: the forwarded opens' `substrate` comes from /// the PLAN (stamped at establisher time), not a re-parse of wire -/// input — a UDP bind-first listen produces forwarded opens carrying +/// input: a UDP bind-first listen produces forwarded opens carrying /// `substrate: udp` even though the re-parse-and-fallback shape would /// have stamped `tcp` if it ever fell back. (The fallback itself is -/// REMOVED — this test pins the plan as the source by observing the +/// REMOVED, so this test pins the plan as the source by observing the /// forwarded params through a real dispatcher round trip.) #[tokio::test] async fn forwarded_opens_carry_plan_stamped_substrate() { init_tracing(); // A UDP-flavored bind-first registry: the open is - // `{resource: "bind", substrate: "udp"}` — the plan must stamp + // `{resource: "bind", substrate: "udp"}`, so the plan must stamp // Udp (the old fallback shape would have stamped Tcp on any // parse failure). let registry = ResourceRegistry::new(); @@ -639,7 +639,7 @@ async fn forwarded_opens_carry_plan_stamped_substrate() { let correlations = install_substrate_recording_stub(&topo, Arc::clone(&substrates)).await; // The listen open over the UDP substrate (the harness's - // open-call helper hardcodes "tcp" — call with the udp input + // open-call helper hardcodes "tcp", so call with the udp input // directly; the schema gate accepts the registered UDP resource). let payload = serde_json::json!({ "operationId": "channels/tunnel/sub", @@ -668,7 +668,7 @@ async fn forwarded_opens_carry_plan_stamped_substrate() { .await .expect("adopt listen channel"); - // One accept: the dispatcher opens forwarded — the forwarded + // One accept: the dispatcher opens forwarded, and the forwarded // params' `substrate` must be the PLAN's (Udp), not a fallback. push_accept(&queue, ¬ify, (198, 55236)).await; tokio::time::sleep(std::time::Duration::from_millis(300)).await; @@ -682,7 +682,7 @@ async fn forwarded_opens_carry_plan_stamped_substrate() { } /// A plain dial open sees NEITHER reply field (the projection -/// property — the reserved-key guard upstream guarantees `channel_id` +/// property; the reserved-key guard upstream guarantees `channel_id` /// stays wrapper-owned; a dial establisher contributes nothing). #[tokio::test] async fn dial_open_reply_has_neither_field() { @@ -721,7 +721,7 @@ fn harness_queue_loop( let notify = Arc::clone(¬ify); Box::pin(async move { // The queue-driven accept: check once, wait for a push, - // check again (the Notify lost-wakeup pattern — the + // check again (the Notify lost-wakeup pattern: the // `Notified` future is created BEFORE the state check on // the waiting path). One accepted pair per closure call // (the dispatcher invokes the closure per accept). @@ -747,7 +747,7 @@ fn harness_queue_loop( }) } -/// A never-accepting loop (the establishment-error probes — the loop +/// A never-accepting loop (the establishment-error probes; the loop /// never runs: the open fails at the establisher). fn noop_loop() -> BindFirstAcceptLoop { Arc::new(|_listener| { @@ -756,7 +756,7 @@ fn noop_loop() -> BindFirstAcceptLoop { } /// The "local BIND client" pair (the opener-side pump target): a raw -/// duplex pair — the map's halves are the stub's pump target, the far +/// duplex pair; the map's halves are the stub's pump target, the far /// end is the test's client end (driven directly; the accepted side's /// echo task closes the loop). fn bind_client_pair() -> ( diff --git a/tests/producer_direct_op.rs b/tests/producer_direct_op.rs index 4fd20fd..d01978f 100644 --- a/tests/producer_direct_op.rs +++ b/tests/producer_direct_op.rs @@ -2,12 +2,12 @@ //! ADR-007) end to end over the duplex harness: the params → spec → //! establisher → registration → `TunnelSession::open_direct` chain. //! -//! Covered: e2e round-trips through `pump_bidi` (tcp + udp — the +//! Covered: e2e round-trips through `pump_bidi` (tcp + udp, with the //! framed adapter wraps at the dial closure, datagram boundary //! preserved), the schema-class failure for malformed targets (no //! phantom session), the `NOT_FOUND` posture on a producer without //! the direct op registered, the structural scope separation -//! (`tunnel:open` does NOT imply `tunnel:direct` — FORBIDDEN, and +//! (`tunnel:open` does NOT imply `tunnel:direct`: FORBIDDEN, and //! vice versa), and the CF-006 per-call opener identity on the //! direct establisher (witness). @@ -68,7 +68,7 @@ async fn adopt_and_pump( (l_read, l_write, pump) } -/// Only channel 0 may remain — a failed open leaves no data channel +/// Only channel 0 may remain: a failed open leaves no data channel /// on either side (the phantom-channel property). fn no_data_channels(topo: &Topology) -> bool { let consumer = topo.consumer_manager.channel_ids(); @@ -128,7 +128,7 @@ async fn direct_tcp_round_trips_through_pump_bidi() { #[tokio::test] async fn direct_udp_round_trips_framed() { // The UDP dial closure wraps the framed adapter (ADR-003) at the - // boundary — the establisher stays substrate-agnostic; the + // boundary, and the establisher stays substrate-agnostic; the // session's datagram plane sees boundary-preserved frames. let topo = wire_direct(all_substrate_dial()).await; @@ -168,7 +168,7 @@ async fn direct_udp_round_trips_framed() { async fn malformed_target_is_schema_class_and_leaves_no_channel() { // The registry's input-schema gate runs before the establisher: a // unix target without `path` (and a tcp target without `port`) - // fails as `INVALID_INPUT` — never a phantom session, never a + // fails as `INVALID_INPUT`; never a phantom session, never a // establisher dial. let topo = wire_direct(echo_dial()).await; @@ -228,7 +228,7 @@ async fn dial_failure_passes_through_typed() { #[tokio::test] async fn producer_without_direct_op_rejects_loudly() { // An old producer (only the base op registered) rejects the - // unknown op loudly — NOT_FOUND, the SSH "unknown channel type" + // unknown op loudly (NOT_FOUND, the SSH "unknown channel type" // posture. let registry = alktunnels::producer::ResourceRegistry::new(); registry @@ -256,7 +256,7 @@ async fn producer_without_direct_op_rejects_loudly() { #[tokio::test] async fn tunnel_open_scope_does_not_imply_tunnel_direct() { // ADR-006 Amendment 1: the scopes are structurally separate - // grants — an identity holding `tunnel:open` (but not + // grants (an identity holding `tunnel:open` but not // `tunnel:direct`) is FORBIDDEN on the direct op. let topo = wire_direct_with_identity(consumer_identity()).await; @@ -308,7 +308,7 @@ async fn direct_scope_does_not_imply_tunnel_open() { async fn direct_establisher_witnesses_per_call_identity() { // CF-006: the direct establisher's per-call auth carried the END // CALLER's identity (the direct op is the arbitrary-egress - // capability — identity visibility matters more, not less). + // capability, so identity visibility matters more, not less). let topo = wire_direct(echo_dial()).await; let out = call_direct( @@ -334,7 +334,7 @@ async fn direct_establisher_witnesses_per_call_identity() { async fn establisher_parse_backstop_maps_handler_error() { // The establisher's serde parse is the backstop behind the schema // gate: feed an input the schema would have rejected but a direct - // establisher call never schema-checks (the raw-call shape — + // establisher call never schema-checks (the raw-call shape: // assembly variants, or a registry whose schema gate is looser) // and observe the typed `handler_error` mapping (ADR-007 §4). // An unknown substrate value fails the enum parse. @@ -360,7 +360,7 @@ async fn establisher_parse_backstop_maps_handler_error() { async fn direct_establisher_renders_target_to_backing_string() { // The design pin: the establisher renders the target to the SAME // backing-string form the registry backings use and calls the - // injected DialFn with it — the dial closure observes the exact + // injected DialFn with it, and the dial closure observes the exact // rendered form ("host:port" / path), per ADR-007 §4. let observed: Arc>> = Arc::new(tokio::sync::Mutex::new(Vec::new())); @@ -508,8 +508,8 @@ async fn open_direct_malformed_target_no_phantom_session() { #[tokio::test] async fn open_direct_scope_separated_session_level() { // Session-level scope separation (ADR-006 Amendment 1): a - // tunnel:open-only consumer client is FORBIDDEN on open_direct — - // the scopes are separate grants, and the session constructor + // tunnel:open-only consumer client is FORBIDDEN on open_direct + // (the scopes are separate grants, and the session constructor // states which capability it invokes. let topo = harness::wire_direct_forward_with_identity(echo_dial(), consumer_identity()).await; diff --git a/tests/producer_forwarded.rs b/tests/producer_forwarded.rs index ac72df9..d78bea9 100644 --- a/tests/producer_forwarded.rs +++ b/tests/producer_forwarded.rs @@ -1,6 +1,6 @@ //! Integration tests for the forwarded op's SERVING side (the -//! listen-opener half — ADR-008 §3, the `tunnels/forwarded-receipt` -//! shape): `register_tunnel_forwarded_openable` — the +//! listen-opener half: ADR-008 §3, the `tunnels/forwarded-receipt` +//! shape): `register_tunnel_forwarded_openable`, the //! `register_openable` shape (no establisher; the data plane starts //! immediately), scope-gated `tunnel:forwarded`, whose handler parses //! `TunnelForwardedParams`, hands the correlation material (parsed @@ -14,8 +14,8 @@ //! `tunnel:forwarded` → FORBIDDEN, never implied by //! `tunnel:open`/`tunnel:direct`), malformed params (unknown field, //! `listen: 0`, missing `peer` → loud rejection; the serde-gated shape -//! tears the channel down at birth — no phantom), and the -//! correlation-miss teardown (the assembly's rejection — drop the +//! tears the channel down at birth (no phantom), and the +//! correlation-miss teardown (the assembly's rejection: drop the //! stream, the channel reaps). //! //! The reply reader's unit tests land in `src/params.rs` (this task's @@ -24,7 +24,7 @@ //! //! Topology (the harness's producer-leg stub, mirrored): the producer //! leg is the connect side (it holds the `ChannelClient` whose -//! `call_open_op` is the per-accept call plane — the reverse POC's +//! `call_open_op` is the per-accept call plane (the reverse POC's //! `open_reverse_channel` shape, reduced); the opener leg is the //! accept side whose install-hook serving registry carries the //! forwarded op under test. The test plays the producer: per probe it @@ -33,7 +33,7 @@ //! producer-side manager, and pumps the adopted channel against a //! driving duplex (the dispatcher-pump dance, test-driven). The //! opener-side assembly correlates by the `listen` token and pumps the -//! forwarded channel against the local "BIND client" halves — bytes +//! forwarded channel against the local "BIND client" halves; bytes //! the test writes on the client end traverse the whole path and echo //! back. @@ -50,7 +50,7 @@ use alktunnels::producer::{register_tunnel_forwarded_openable, TargetHandle}; use harness::{wire_with, RegistrationMode}; /// The opener-side identity holding ONLY `tunnel:forwarded` (the -/// ingress grant — pinned separate from `tunnel:open`/`tunnel:direct`, +/// ingress grant, pinned separate from `tunnel:open`/`tunnel:direct`, /// never implied by either; this identity holds neither). fn forwarded_only_identity() -> Identity { Identity { @@ -60,7 +60,7 @@ fn forwarded_only_identity() -> Identity { } } -/// A plain dial-egress identity (`tunnel:open` only — the +/// A plain dial-egress identity (`tunnel:open` only, the /// never-implied probe's identity). fn open_only_identity() -> Identity { Identity { @@ -83,10 +83,10 @@ type ReceivedWitness = Arc>; /// Serve the forwarded op on the opener side with the injected /// assembly handler: record the parsed params, pop the correlated /// halves for the token, and `pump_bidi` the channel `BiStream` -/// against them (convention 8 — the assembly owns the pump; the +/// against them (convention 8: the assembly owns the pump; the /// protocol-side built-in handler only bridges wrapper→assembly). A /// correlation miss (no halves for the token) is the assembly's -/// rejection: drop the stream, return — teardown-at-birth reaps the +/// rejection: drop the stream, return; teardown-at-birth reaps the /// forwarded channel. fn serve_forwarded_on( consumer_registry: &Arc, @@ -121,7 +121,7 @@ fn serve_forwarded_on( } /// A topology for the forwarded-op serving probes: the producer leg -/// (connect side) serves only the unused base op (failing dial — the +/// (connect side) serves only the unused base op (a failing dial, the /// base op is not what's under test); the opener leg is the accept /// side whose serving registry carries the forwarded op. The test /// plays the producer: `topo.producer.call_open_op(OP_TUNNEL_FORWARDED, @@ -147,7 +147,7 @@ fn forwarded_input(listen: u32, peer: (u32, u16)) -> serde_json::Value { } /// Call the forwarded op on the producer's per-accept call plane -/// (`call_open_op` — the producer-side initiator's surface; the +/// (`call_open_op`, the producer-side initiator's surface; the /// opener's wrapper allocates the channel ID, the producer adopts). async fn call_forwarded( producer: &alkcall::channels::client::ChannelClient, @@ -160,7 +160,7 @@ async fn call_forwarded( } /// The accepted-side echo pair (the producer-side pump target): a -/// duplex pair — the driving end an echo task (the app server's +/// duplex pair; the driving end an echo task (the app server's /// socket stand-in), the far end the pump's halves. fn driving_halves() -> TargetHandle { let (drive_end, target_end) = tokio::io::duplex(64 * 1024); @@ -187,7 +187,7 @@ fn driving_halves() -> TargetHandle { } /// The "local BIND client" pair (the opener-side pump target): a raw -/// duplex pair — the map's halves are the assembly pump's target, the +/// duplex pair; the map's halves are the assembly pump's target, the /// far end is the test's client end (driven directly). fn bind_client_pair() -> ( tokio::io::ReadHalf, @@ -226,7 +226,7 @@ async fn forwarded_serving_round_trip_end_to_end() { Arc::clone(&local_halves), ); - // Correlate FIRST (the waiting listener session's bookkeeping — + // Correlate FIRST (the waiting listener session's bookkeeping: // the assembly pre-registered the local halves for the token // before the forwarded open arrives; a late push is the // correlation-miss shape, probed separately below). @@ -266,7 +266,7 @@ async fn forwarded_serving_round_trip_end_to_end() { // The producer-side adopted pump (the test drives the accepted // side's echo): adopt the opener-allocated ID on the // producer-side manager and pump the adopted channel against a - // driving duplex — the dispatcher-pump dance, test-driven. + // driving duplex (the dispatcher-pump dance, test-driven). let (send, recv) = topo .producer .manager() @@ -298,8 +298,8 @@ async fn forwarded_serving_round_trip_end_to_end() { assert_eq!(&buf, b"forwarded-ping"); } -/// The scope gate: an identity WITHOUT `tunnel:forwarded` is FORBIDDEN -/// — never implied by `tunnel:open` (this identity holds exactly that +/// The scope gate: an identity WITHOUT `tunnel:forwarded` is FORBIDDEN, +/// never implied by `tunnel:open` (this identity holds exactly that /// grant; ADR-006 Amendment 1's separation). #[tokio::test] async fn forwarded_scope_gate_is_independent_grant() { @@ -342,7 +342,7 @@ async fn forwarded_scope_gate_is_independent_grant() { } /// Malformed params: `listen: 0` and a missing `peer` fail the -/// registry's schema gate LOUD (`INVALID_INPUT`, before the wrapper — +/// registry's schema gate LOUD (`INVALID_INPUT`; before the wrapper, /// no channel ever); an unknown top-level field passes the schema (the /// additive posture) but fails the serde parse (the loud backstop) → /// the wrapper logs + returns early → the channel tears down at birth @@ -360,7 +360,7 @@ async fn forwarded_malformed_params_tear_down_loud() { local_halves, ); - // `listen: 0` — the schema gate (minimum: 1) fires first. + // `listen: 0`: the schema gate (minimum: 1) fires first. match call_forwarded( &topo.producer, serde_json::json!({ @@ -377,7 +377,7 @@ async fn forwarded_malformed_params_tear_down_loud() { } } - // Missing `peer` — the schema gate again (all three required). + // Missing `peer`, the schema gate again (all three required). match call_forwarded( &topo.producer, serde_json::json!({"listen": 7, "substrate": "tcp"}), @@ -390,10 +390,10 @@ async fn forwarded_malformed_params_tear_down_loud() { } } - // Unknown top-level field — passes the schema (additive posture), + // Unknown top-level field: passes the schema (additive posture), // fails the serde parse (deny-unknown backstop) → the wrapper - // returns early → teardown-at-birth (the channel was allocated — - // the reply IS the channel_id — but reaps promptly). + // returns early → teardown-at-birth (the channel was allocated; + // the reply IS the channel_id, but reaps promptly). let out = call_forwarded( &topo.producer, serde_json::json!({ @@ -415,18 +415,18 @@ async fn forwarded_malformed_params_tear_down_loud() { assert!(received.lock().await.params.is_none()); } -/// The correlation-miss teardown (the assembly's rejection — it holds +/// The correlation-miss teardown (the assembly's rejection; it holds /// the map): a valid-shaped open whose `listen` token matches no /// waiting session → the assembly's error path drops the stream and /// returns → teardown-at-birth reaps the forwarded channel. The /// producer's accept loop drops the accepted connection on the open's -/// failure (the shapes mirror — pinned producer-side in +/// failure (the shapes mirror; pinned producer-side in /// `tunnels/bindfirst-producer`). #[tokio::test] async fn forwarded_correlation_miss_tears_channel_down() { let topo = wire_forwarded_serving(forwarded_only_identity()).await; let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default())); - // NO correlated halves for the token — the map is empty. + // NO correlated halves for the token (the map is empty). let local_halves: Arc>> = Arc::new(tokio::sync::Mutex::new(Vec::new())); serve_forwarded_on( @@ -445,7 +445,7 @@ async fn forwarded_correlation_miss_tears_channel_down() { !topo.consumer_manager.has_channel(channel_id), "a correlation miss tears the forwarded channel down (teardown-at-birth)" ); - // The assembly handler RAN (the invocation was witnessed — the + // The assembly handler RAN (the invocation was witnessed; the // rejection is the assembly's) and its map missed. let witnessed = received.lock().await.params.clone(); assert_eq!(witnessed.expect("invoked").listen, 42113); diff --git a/tests/producer_forwarded_gate.rs b/tests/producer_forwarded_gate.rs index 264ccd5..1724c02 100644 --- a/tests/producer_forwarded_gate.rs +++ b/tests/producer_forwarded_gate.rs @@ -1,5 +1,5 @@ //! Integration tests for the forwarded op's establisher variant -//! (ADR-008 Amendment 2 — +//! (ADR-008 Amendment 2: //! `register_tunnel_forwarded_openable_with_establisher` + the //! injected [`ForwardedGate`]): the four pins plan 003 §W1's //! "Tests:" bullet asks for, mirroring `tests/producer_forwarded.rs` @@ -10,10 +10,10 @@ //! - the gate MISS: a `listen` token with no waiting session → the //! open fails as a typed `channel:open_failed` with //! `details.reason == "unknown_resource"`; the transiently -//! allocated channel is torn down by the wrapper (ADR-049 §5 — the +//! allocated channel is torn down by the wrapper (ADR-049 §5; the //! reply is the typed error, not a phantom channel); the pump //! handler NEVER runs. -//! - the gate PASS: the existing serving flow is unchanged — the +//! - the gate PASS: the existing serving flow is unchanged; the //! injected handler receives the parsed params + the `BiStream`, //! the byte round-trip works end to end. //! - the residual race: gate passes, route gone at handler time → @@ -23,7 +23,7 @@ //! `correlation-miss` class). //! - the ordering: an identity WITHOUT `tunnel:forwarded` → FORBIDDEN //! and the gate closure is never invoked (the registry runs the ACL -//! before the open op's establishment phase — schema → ACL → +//! before the open op's establishment phase (schema → ACL → //! allocate → establisher → handler). //! - the establisher-variant malformed-params delta: an unknown //! top-level field (passes the schema's additive posture, fails the @@ -59,7 +59,7 @@ fn forwarded_only_identity() -> Identity { } } -/// A plain dial-egress identity (`tunnel:open` only — no +/// A plain dial-egress identity (`tunnel:open` only, no /// `tunnel:forwarded`; the ordering probe's identity). fn open_only_identity() -> Identity { Identity { @@ -70,7 +70,7 @@ fn open_only_identity() -> Identity { } /// What the assembly's test handler received per forwarded open (the -/// invocation probe — unmoved = the handler never ran). +/// invocation probe; unmoved = the handler never ran). #[derive(Default)] struct Received { params: Option, @@ -86,12 +86,12 @@ type GateWitness = Arc; /// The waiting-session map: `listen` token → present. The gate's /// sync in-memory lookup surface (convention 11's "the gate is an /// in-memory map lookup"): a token present at gate time = a waiting -/// session; absent = the miss (`TunnelEstablishError::UnknownResource` -/// — the canonical stale-token reason ADR-008 Amendment 2 pins). +/// session; absent = the miss (`TunnelEstablishError::UnknownResource`, +/// the canonical stale-token reason ADR-008 Amendment 2 pins). type WaitingTokens = Arc>>; /// The gate closure under test: set the witness on entry (even for -/// opens it will reject — the ordering probe needs the untouched +/// opens it will reject, because the ordering probe needs the untouched /// signal), then the token lookup. fn gate_with_tokens(waiting: WaitingTokens, invoked: GateWitness) -> ForwardedGate { Arc::new(move |params: &alktunnels::params::TunnelForwardedParams| { @@ -115,7 +115,7 @@ fn gate_with_tokens(waiting: WaitingTokens, invoked: GateWitness) -> ForwardedGa /// variant: the same injected handler shape as /// `tests/producer_forwarded.rs`'s `serve_forwarded_on` (record the /// parsed params, pop the correlated halves for the token, `pump_bidi` -/// against them; a handler-time miss drops the stream and returns — +/// against them; a handler-time miss drops the stream and returns, /// teardown-at-birth) with the gate establisher wrapped around it. fn serve_forwarded_establisher_on( consumer_registry: &Arc, @@ -153,7 +153,7 @@ fn serve_forwarded_establisher_on( /// A topology for the establisher-variant probes (the /// `wire_forwarded_serving` shape): the producer leg is the connect -/// side (the base op fails-dial — not what's under test); the opener +/// side (the base op fails-dial; not what's under test); the opener /// leg is the accept side whose serving registry carries the /// establisher-registered forwarded op. The test plays the producer, /// calling the forwarded op on the producer's call plane; the @@ -238,9 +238,9 @@ fn bind_client_pair() -> ( /// The gate MISS: a `listen` token with no waiting session → the open /// fails as a typed `channel:open_failed` with /// `details.reason == "unknown_resource"`; the transiently allocated -/// channel is torn down by the wrapper (no phantom channel — ADR-049 +/// channel is torn down by the wrapper (no phantom channel, ADR-049 /// §5); the pump handler NEVER runs. The gate DID run (its witness is -/// set — the miss is AT the gate, not before it). +/// set; the miss is AT the gate, not before it). #[tokio::test] async fn gate_miss_fails_typed_no_phantom_no_handler() { let topo = wire_forwarded_establisher(forwarded_only_identity()).await; @@ -302,7 +302,7 @@ async fn gate_miss_fails_typed_no_phantom_no_handler() { } /// The gate PASS: the registration serves exactly like the base shape -/// (`tests/producer_forwarded.rs`'s e2e) — the injected handler +/// (`tests/producer_forwarded.rs`'s e2e): the injected handler /// receives the parsed params + the `BiStream`, and the byte /// round-trip works end to end through the establisher-registered /// variant. @@ -393,7 +393,7 @@ async fn gate_pass_serves_the_unchanged_serving_flow() { /// halves) → the injected handler's error path drops the stream and /// returns → teardown-at-birth reaps the forwarded channel. The /// establisher-pass→handler-run window is the residual -/// `correlation-miss` class — the handler-side fallback is what reaps +/// `correlation-miss` class; the handler-side fallback is what reaps /// it, under both shapes. #[tokio::test] async fn residual_race_still_reaps_cleanly() { @@ -411,7 +411,7 @@ async fn residual_race_still_reaps_cleanly() { ); // The gate passes (the waiting token is present at establisher - // time), but nothing was correlated for the handler — the race. + // time), but nothing was correlated for the handler: the race. const TOKEN: u32 = 42114; waiting .lock() @@ -419,15 +419,15 @@ async fn residual_race_still_reaps_cleanly() { .insert(TOKEN); // The open itself STILL RESOLVES (the gate passed; the call-plane - // success is what makes this the residual — post-open — race). + // success is what makes this a residual, post-open race). let out = call_forwarded(&topo.producer, forwarded_input(TOKEN, (198, 55232))) .await .expect("the gate-pass open"); let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32; - // The handler ran (the invocation was witnessed — the rejection is - // the handler's, the stream dropped) and its miss reaps the - // channel (teardown-at-birth). + // The handler ran (the invocation was witnessed; the rejection is + // the handler's and the stream was dropped), and its miss reaps + // the channel (teardown-at-birth). tokio::time::sleep(std::time::Duration::from_millis(300)).await; let witnessed = received.lock().await.params.clone(); assert_eq!(witnessed.expect("invoked").listen, TOKEN); @@ -439,7 +439,7 @@ async fn residual_race_still_reaps_cleanly() { /// The ordering: an identity WITHOUT `tunnel:forwarded` → FORBIDDEN /// and the gate closure is NEVER invoked (a witness the gate sets on -/// entry stays unmoved) — the registry runs the ACL before the open +/// entry stays unmoved): the registry runs the ACL before the open /// op's establishment phase, so the gate never sees an unauthorized /// open. #[tokio::test] @@ -460,7 +460,7 @@ async fn scope_gate_precedes_the_establisher() { ); // The scope-less open is FORBIDDEN (even though the token WOULD - // have passed if the gate had run first — the tie-break is the + // have passed if the gate had run first; the tie-break is the // order, not the gate's answer). match call_forwarded(&topo.producer, forwarded_input(77, (10, 1))).await { Ok(_) => panic!("a scope-less opener must not reach forwarded establishes"), @@ -481,7 +481,7 @@ async fn scope_gate_precedes_the_establisher() { // channel allocation, no handler execution. assert!( !gate_invoked.load(Ordering::SeqCst), - "the ACL precedes the establisher — the gate never saw the open" + "the ACL precedes the establisher; the gate never saw the open" ); assert!( topo.consumer_manager @@ -497,8 +497,8 @@ async fn scope_gate_precedes_the_establisher() { /// `forwarded_malformed_params_tear_down_loud` covers the base /// posture; this pins the delta): the schema gates (`listen: 0`, /// missing `peer`) fire first exactly as before (`INVALID_INPUT`), -/// but an unknown top-level field — passes the schema (additive -/// posture), fails the establisher's serde parse — becomes a TYPED +/// but an unknown top-level field (passes the schema's additive +/// posture, fails the establisher's serde parse) becomes a TYPED /// `channel:open_failed` with `details.reason == "handler_error"`. /// The parse precedes the gate (the gate's witness is never set), the /// wrapper tears the transient allocation down, and the pump handler @@ -522,7 +522,7 @@ async fn malformed_params_fail_typed_at_the_establisher() { ); // The schema gates are unchanged by the establisher (they precede - // it — the wrapper order's schema step). + // it, the wrapper order's schema step). match call_forwarded( &topo.producer, serde_json::json!({ @@ -548,7 +548,7 @@ async fn malformed_params_fail_typed_at_the_establisher() { // THE DELTA: an unknown top-level field fails the establisher's // serde parse → a typed open failure (vs the base shape's - // success-with-channel_id + birth-teardown — the typed posture is + // success-with-channel_id + birth-teardown; the typed posture is // the improvement ADR-008 Amendment 2 pins). let err = call_forwarded( &topo.producer, @@ -573,7 +573,7 @@ async fn malformed_params_fail_typed_at_the_establisher() { tokio::time::sleep(std::time::Duration::from_millis(300)).await; assert!( !gate_invoked.load(Ordering::SeqCst), - "the establisher parses BEFORE the gate — the gate never ran" + "the establisher parses BEFORE the gate; the gate never ran" ); assert!( topo.consumer_manager diff --git a/tests/producer_listen.rs b/tests/producer_listen.rs index 4d3a6c1..cdb92f6 100644 --- a/tests/producer_listen.rs +++ b/tests/producer_listen.rs @@ -1,9 +1,9 @@ -//! Integration tests for the listen establisher (shape 2 — producer.md +//! Integration tests for the listen establisher (shape 2, producer.md //! §The Establisher): a producer whose resource is a LISTENER. The //! assembly layer owns the listener + its accept loop and feeds an //! [`AcceptQueue`]; the establisher pops one accepted handle during //! the open op's establishment phase. Same open op, same params, same -//! typed errors — the pump handler is unchanged. +//! typed errors; the pump handler is unchanged. //! //! Covered: the listen flow end-to-end (in-process listener queue fed //! by a test accept loop; consumer opens toward it; the accepted @@ -127,7 +127,7 @@ async fn listen_flow_round_trips() { #[tokio::test] async fn pop_during_establishment_is_always_before_take() { // The always-before-take guarantee: two accepts pushed BEFORE an - // open are popped in FIFO order across two opens — each open's + // open are popped in FIFO order across two opens; each open's // establisher sees the handle accepted before it (the plan flow: // every open's result flows to ITS pump). let queue = AcceptQueue::new(); @@ -179,10 +179,10 @@ async fn pop_during_establishment_is_always_before_take() { async fn empty_queue_is_resource_shortage() { // producer.md's error mapping: an empty queue during establishment // → `resource_shortage`. The QUEUE's pop waits (a late accept is - // legitimate — see accept_wait_resolves_when_push_arrives_late); + // legitimate; see accept_wait_resolves_when_push_arrives_late); // the assembly layer decides its own posture. An assembly that // knows nothing is coming (a drained listener, a bounded accept - // budget) closes its queue or maps empty-pop to the typed error — + // budget) closes its queue or maps empty-pop to the typed error; // here, the closure maps it directly (fail-fast posture). let queue = AcceptQueue::new(); queue.close().await; @@ -194,7 +194,7 @@ async fn empty_queue_is_resource_shortage() { let queue = queue.clone(); Box::pin(async move { // Bounded wait (the assembly's own deadline, not the - // wrapper's): a closed queue is an empty listen backlog — + // wrapper's): a closed queue is an empty listen backlog, // resource_shortage per producer.md's table. match tokio::time::timeout(std::time::Duration::from_millis(50), queue.pop()).await { Ok(Some(handle)) => Ok(handle), @@ -288,7 +288,7 @@ async fn accept_wait_resolves_when_push_arrives_late() { // Pop-during-establishment ordering under a LIVE accept loop: the // establisher's pop waits; the accept loop pushes after a tick; // the open resolves once the push lands (the wrapper's bounded - // wait — ADR-049 §2's deadline applies above us). + // wait; ADR-049 §2's deadline applies above us). let queue = AcceptQueue::new(); let accept_loop = tokio::spawn({ let queue = queue.clone(); @@ -323,7 +323,7 @@ async fn accept_wait_resolves_when_push_arrives_late() { #[tokio::test] async fn push_after_close_returns_the_handle_and_pop_resolves_none() { - // The queue contract: a push after close is `Err(handle)` — the + // The queue contract: a push after close is `Err(handle)`, the // accept loop's signal to stop feeding (the accepted connection is // returned untouched; nothing is queued); a closed-and-empty pop // resolves `None` immediately. @@ -340,7 +340,7 @@ async fn push_after_close_returns_the_handle_and_pop_resolves_none() { .push(handle) .await .expect_err("push after close must be Err"); - // The handle comes back (the caller drops it — the accepted + // The handle comes back (the caller drops it, so the accepted // connection closes; nothing leaks into the queue). drop(dropped); drop(target_side); @@ -356,7 +356,7 @@ async fn push_after_close_returns_the_handle_and_pop_resolves_none() { async fn accept_loop_stops_feeding_after_close_and_maps_the_drop() { // The accept loop's queue-closed mapping (the `local` feature's // shape): a `push` after close errors the loop with the handler - // error — the assembly layer aborts the loop task on teardown; + // error; the assembly layer aborts the loop task on teardown; // here the loop itself exits on the mapped error. The unaccepted // far end is dropped (the loop accepted it; nothing pumps it once // the queue is closed). @@ -384,9 +384,9 @@ async fn accept_loop_stops_feeding_after_close_and_maps_the_drop() { ); } -/// The unix accept loop's queue-closed mapping (G-4 — the unix parity +/// The unix accept loop's queue-closed mapping (G-4, the unix parity /// twin of the TCP test above): the same loop shape over -/// `UnixListenerHalves` — a `push` after close errors the loop with +/// `UnixListenerHalves`: a `push` after close errors the loop with /// the handler error, the loop exits on the mapped error, and the /// unaccepted far end is dropped. #[tokio::test] @@ -429,7 +429,7 @@ async fn unix_accept_loop_stops_feeding_after_close_and_maps_the_drop() { /// runs the pop's check and the future's first poll back to back), /// hence the multi-thread flavor. The fix creates the `Notified` /// future BEFORE the state check on every iteration (tokio's -/// documented notify_waiters pattern — the counter comparison at +/// documented notify_waiters pattern (the counter comparison at /// first poll captures a pre-registration broadcast); the naive /// create-after-check shape loses it. Structural: no choreography can /// force the window deterministically, so this test pins the pattern @@ -444,7 +444,7 @@ async fn pop_wakeup_survives_push_landing_in_the_registration_window() { let popper = tokio::spawn(async move { tokio::time::timeout(std::time::Duration::from_millis(500), queue_popper.pop()) .await - .expect("pop timed out — lost wakeup") + .expect("pop timed out (lost wakeup)") .expect("queue closed early") }); // The push races the popper's check→register window on the diff --git a/tests/producer_open_op.rs b/tests/producer_open_op.rs index 8a0a7d6..379c8d6 100644 --- a/tests/producer_open_op.rs +++ b/tests/producer_open_op.rs @@ -1,13 +1,13 @@ -//! Integration tests for the producer half — the open-op path +//! Integration tests for the producer half: the open-op path //! (establisher + pump handler + registration) over the duplex //! harness, ported from the reverse POC's suite (16 tests rode the //! topology) with the dial closure standing in for real sockets. //! //! Covered: establishment success + the typed-error table //! (`unknown_resource` / `dial_failed` / `FORBIDDEN`), the plan flow -//! under same-resource concurrency (R-01 — no handoff race), the +//! under same-resource concurrency (R-01, no handoff race), the //! per-call opener identity (CF-006 witness), late-registration -//! visibility (W2), pump round-trip through `pump_bidi` (R-02 — the +//! visibility (W2), pump round-trip through `pump_bidi` (R-02, the //! wrapper-managed pump, no spawn-and-forget), and out-of-band //! `channel/close`. @@ -67,7 +67,7 @@ async fn call_open_params( } /// Adopt the producer-allocated channel and pump it against in-process -/// halves (the consumer-side data plane — the session's take_halves +/// halves (the consumer-side data plane, the session's take_halves /// shape, driven manually until the consumer session task lands). async fn adopt_and_pump( topo: &Topology, @@ -90,7 +90,7 @@ async fn adopt_and_pump( (l_read, l_write, pump) } -/// Only channel 0 (the pre-negotiated call channel) may remain — a +/// Only channel 0 (the pre-negotiated call channel) may remain; a /// failed open leaves no data channel on either side (the /// phantom-channel property). fn no_data_channels(topo: &Topology) -> bool { @@ -183,7 +183,7 @@ async fn dial_failure_is_typed_and_leaves_no_channel() { #[tokio::test] async fn schema_gate_rejects_missing_substrate_before_the_establisher() { // The registry's input-schema gate runs before the establisher: - // a missing substrate is `INVALID_INPUT` with no reason code — + // a missing substrate is `INVALID_INPUT` with no reason code, and // the establisher's own `handler_error` parse branch is // unreachable past this gate (the schema pins both fields). let registry = ResourceRegistry::new(); @@ -251,7 +251,7 @@ async fn open_on_transport_identity_alone() { #[tokio::test] async fn open_token_overrides_transport_identity() { // CF-005 precedence: the payload token wins. The provider resolves - // the scoped token to the consumer identity — the witness proves + // the scoped token to the consumer identity; the witness proves // the establisher saw it end-to-end. let registry = ResourceRegistry::new(); registry @@ -277,7 +277,7 @@ async fn open_token_overrides_transport_identity() { #[tokio::test] async fn concurrent_opens_same_resource_no_plan_race() { // The R-01 plan flow under concurrency: two opens of the SAME - // resource — the establisher returns each dialed handle via ITS + // resource; the establisher returns each dialed handle via ITS // plan; no handoff map to race. Each round-trips its own marker. let registry = ResourceRegistry::new(); registry @@ -312,7 +312,7 @@ async fn concurrent_opens_same_resource_no_plan_race() { async fn late_registration_is_visible() { // W2: the harness registers the openable AFTER // from_connection_with_serving. If the dispatcher cached - // registrations, opens would fail with unknown-operation — the + // registrations, opens would fail with unknown-operation, which // successful opens elsewhere prove it; assert registry state too. let registry = ResourceRegistry::new(); registry @@ -370,7 +370,7 @@ async fn producer_outbound_calls_still_resolve_while_serving() { #[tokio::test] async fn registry_lookup_respects_substrate_key() { // The registry is keyed by (resource, substrate): a resource - // produced for TCP is unknown for UDP — the same name on a + // produced for TCP is unknown for UDP: the same name on a // different substrate is a different resource (ADR-001). let registry = ResourceRegistry::new(); registry.register("svc", Substrate::Tcp, "in-process").await; @@ -386,7 +386,7 @@ async fn registry_lookup_respects_substrate_key() { #[tokio::test] async fn tunnel_establisher_no_witness_wrapper_opens() { - // The no-witness wrapper (`tunnel_establisher` — the public + // The no-witness wrapper (`tunnel_establisher`, the public // default shape, producer.rs's witness-less establisher body): // a full open through the wrapper round-trips. The wrapper // differs from `tunnel_establisher_with_witness` ONLY in the