Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72820ddc2 | ||
|
|
74e6289a8e | ||
|
|
ecb24678bb | ||
|
|
0ceecf582b | ||
|
|
cd0afe0497 | ||
|
|
6b20924f68 | ||
|
|
92c3cc5123 | ||
|
|
c7e8c875fd | ||
|
|
f42ce47d17 | ||
|
|
5b2f7255f7 | ||
|
|
7d2b6d11f0 | ||
|
|
aa3db39e3e | ||
|
|
bd8a82b4c4 | ||
|
|
ec52a9620d | ||
|
|
9cca6ed3c4 | ||
|
|
200d7d3835 | ||
|
|
3c6fc29271 | ||
|
|
1365272146 | ||
|
|
05b25465aa | ||
|
|
e3c6f6c9f8 | ||
|
|
57285d23bd |
No files matched your search
@@ -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**: <one-way | two-way>
|
||||
- **Priority**: <high | medium | low>
|
||||
- **Impacts**: <what this blocks downstream — be specific>
|
||||
- **Impacts**: <what this blocks downstream; be specific>
|
||||
- **Blocked on**: <concrete dependency> (for deferred(scope))
|
||||
**Investigation**: <what work would make the shape clear> (for deferred(unclear))
|
||||
- **Resolution**: Not yet decidable. <Why — either the information
|
||||
- **Resolution**: Not yet decidable. <Why: either the information
|
||||
doesn't exist yet (deferred(scope)) or the pieces exist but the
|
||||
composition isn't clear (deferred(unclear), cite the pieces).>
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
@@ -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`)
|
||||
|
||||
@@ -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/<task-name>", 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}}. <brief summary>", level: "info"}})
|
||||
|
||||
@@ -230,7 +230,7 @@ For example, if Generation 2 has tasks A (depends on X), B (depends on Y), and C
|
||||
|
||||
When spawning parallel tasks, check if they modify overlapping source files.
|
||||
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/<task-name>", remote: true}})
|
||||
```
|
||||
The `remote: true` flag handles remote branch deletion automatically — no
|
||||
The `remote: true` flag handles remote branch deletion automatically (no
|
||||
separate `git push origin --delete` needed.
|
||||
|
||||
Don't let stale branches accumulate.
|
||||
|
||||
@@ -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(<task-id>): <description>"
|
||||
git push origin $(git branch --show-current)
|
||||
@@ -178,7 +178,7 @@ When task becomes untendable:
|
||||
worktree({action: "notify", args: {message: "Blocked on <task-id>: <detailed explanation including what was attempted, what failed, and suggested resolution>", level: "blocking"}})
|
||||
```
|
||||
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
|
||||
|
||||
@@ -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/<task-id>`
|
||||
- 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
|
||||
|
||||
@@ -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 <glm-5.3-flash@alk.dev>`). 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,63 +160,63 @@ 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.7.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.7.0"` and bump
|
||||
rather than working around them. Pin `alkcall = "0.10.0"` and bump
|
||||
deliberately. The 0.6.0 establishment surface (ADR-049 + amendment
|
||||
2) is load-bearing for this crate: tunnel opens use
|
||||
`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.7.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.
|
||||
+206
-48
@@ -6,6 +6,159 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] - 2026-11-03
|
||||
|
||||
The correlation-gate release: the forwarded op's serving half gains an
|
||||
establishment phase (review 003 W1 absorbed in-repo, ADR-008 Amendment
|
||||
2), prompted by alkssh's consumer input on the composed `-R`
|
||||
deployment's failure vocabulary. Additive public API only (the minor
|
||||
bump, not a patch); the wire delta is empty and the base registration
|
||||
stays byte-for-byte. The rest of the wave is doc-only: the em-dash
|
||||
prose sweep across repo docs, spec docs, doc comments, and task files
|
||||
(no code-path change), and review 003's doc trail closure (W2/W3 are
|
||||
the sibling repos' adoption waves and remain booked there).
|
||||
|
||||
### 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, 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
|
||||
`Cargo.toml`/`Cargo.lock`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **alkcall 0.10.0 → 0.10.1** (crates.io). The bug-fix release
|
||||
(review 013, routed from alkgit's wave-2 gate): the
|
||||
`adopt_channel` install→drain window is now one atomic section, so
|
||||
a chunk routed by the demux inside the window can no longer be
|
||||
delivered ahead of earlier-parked early arrivals (the observed
|
||||
downstream shape was pkt-line desync in alkgit's open-op
|
||||
integration test under multi-process load), and a related loss
|
||||
variant (a chunk parked after the drain removed the queue, parked
|
||||
forever) is closed. Relevant in principle to this crate's
|
||||
reverse-path adopt flow (the bind-first establisher surfaces
|
||||
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
|
||||
only strengthens. 136 tests pass; wasm-clean verified (check +
|
||||
clippy on `wasm32-unknown-unknown`).
|
||||
|
||||
## [0.4.0] - 2026-10-19
|
||||
|
||||
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, 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
|
||||
(`ChannelOpenError`; `Establishment`/`EstablishmentError` shapes in
|
||||
the producer API) make it semver-visible to downstream consumers.
|
||||
|
||||
### Changed
|
||||
|
||||
- **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
|
||||
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
|
||||
`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
|
||||
(`register_openable`/`register_openable_with_establisher`,
|
||||
`Establishment::new`, `OpenHandler`,
|
||||
`ChannelClient::open_channel_with_reply`,
|
||||
`ManagerError::ChannelExists`, `ChannelOpenError`) are unchanged
|
||||
between 0.9.0 and 0.10.0. 136 tests pass; wasm-clean verified
|
||||
(check + clippy on `wasm32-unknown-unknown`).
|
||||
|
||||
## [0.3.0] - 2026-10-14
|
||||
|
||||
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
|
||||
`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
|
||||
semver-visible to downstream consumers (matching the 0.7.1 → 0.8.0
|
||||
ride on 0.2.0).
|
||||
|
||||
### Changed
|
||||
|
||||
- **alkcall 0.8.1 → 0.9.0** (crates.io). The stub-sweep release: the
|
||||
marked-pub open contract (S-01), `channel/control` (S-02),
|
||||
`channel/resources/subscribe` (S-03), and `op/register`
|
||||
forward-through (S-04) replace the four always-failing stubs.
|
||||
None of the four stubs were on this crate's paths (no marked-pub
|
||||
registration, no control-handle subscription, no resource
|
||||
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'
|
||||
`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
|
||||
Ok")`). `ManagerError::InvalidAdopt` (additive),
|
||||
`RelayError::PubTypedOpen` removal, and the
|
||||
`register_imported`/`register_imported_all` return change are
|
||||
untouched by this crate's API use. 136 tests pass; wasm-clean
|
||||
verified.
|
||||
|
||||
## [0.2.1] - 2026-09-28
|
||||
|
||||
The hardening release: fuzzing adopted from the alkcall pattern
|
||||
(`fuzz/` workspace, excluded from the published package) and the
|
||||
alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change.
|
||||
|
||||
### Added
|
||||
|
||||
- **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,
|
||||
infallible decode, size bound, F-2 sentinel layering, re-frame
|
||||
round-trip, retained-bytes bound), `params_parse` (the serde
|
||||
params/reply parsers: no-panic on raw bytes and bounded structured
|
||||
values, clean typed failure, round-trips), and `session_pumps` (the
|
||||
wave-2 stateful target: op sequences over a live channels stack per
|
||||
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
|
||||
`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
|
||||
package. Campaigns (detached, fork mode): ~25M+228k execs across
|
||||
waves 1–2, zero crashes/hangs/OOMs; all findings were
|
||||
harness-model defects, no crate bugs.
|
||||
|
||||
- **`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
|
||||
count (leftover buffer + the in-flight partial datagram); post-feed
|
||||
bounded by one frame (≤ `MAX_DATAGRAM_LEN` + 2), pinned by the
|
||||
`datagram_codec` target.
|
||||
|
||||
### Changed
|
||||
|
||||
- **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
|
||||
reverse-path adopt flow. No alktunnels API or wire change in this
|
||||
bump; the full suite passes unchanged.
|
||||
|
||||
## [0.2.0] - 2026-09-20
|
||||
|
||||
The graduation release: the direct op + the bind-first/forwarded
|
||||
@@ -14,98 +167,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
|
||||
@@ -113,36 +266,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`).
|
||||
@@ -152,20 +305,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
|
||||
@@ -192,8 +345,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
|
||||
@@ -201,7 +354,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`);
|
||||
@@ -209,9 +362,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.
|
||||
@@ -221,6 +374,11 @@ complete (Phase 2, review 001 findings remediated).
|
||||
matrix). Wasm-clean default crate verified (`wasm32-unknown-unknown`
|
||||
check + clippy).
|
||||
|
||||
[Unreleased]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.0...HEAD
|
||||
[Unreleased]: https://git.alk.dev/alkdev/alktunnels/compare/v0.5.0...HEAD
|
||||
[0.5.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.4.1...v0.5.0
|
||||
[0.4.1]: https://git.alk.dev/alkdev/alktunnels/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.1...v0.3.0
|
||||
[0.2.1]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://git.alk.dev/alkdev/alktunnels/releases/tag/v0.1.0
|
||||
Generated
+3
-3
@@ -27,9 +27,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8695d47cee5b5a5fe68471f04a505bc257a7e7ed9e64d827de33ff83adf4db12"
|
||||
checksum = "690d747f42baf385dc22cd120f79d43cf1ea239bc426675e0e1e225b57fc031a"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alktunnels"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"alkcall",
|
||||
"bytes",
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alktunnels"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -9,7 +9,11 @@ repository = "https://git.alk.dev/alkdev/alktunnels"
|
||||
readme = "README.md"
|
||||
keywords = ["tunnel", "proxy", "channels", "alkcall", "network"]
|
||||
categories = ["network-programming", "asynchronous"]
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/research/", "docs/plans/", "tasks/", "docs/sdd_process.md"]
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/research/", "docs/plans/", "tasks/", "docs/sdd_process.md", "fuzz/"]
|
||||
|
||||
[workspace]
|
||||
members = ["."]
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[lib]
|
||||
name = "alktunnels"
|
||||
@@ -19,7 +23,7 @@ default = []
|
||||
local = ["tokio/net"]
|
||||
|
||||
[dependencies]
|
||||
alkcall = "0.8.0"
|
||||
alkcall = "0.10.1"
|
||||
tokio = { version = "1", default-features = false, features = ["rt", "sync", "io-util", "macros", "time"] }
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
|
||||
@@ -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
|
||||
@@ -108,13 +108,23 @@ register_tunnel_bindfirst_openable(&core, ®istry, &op_registry, auth, injecti
|
||||
|
||||
// The forwarded op's opener-side serving half (on the consumer's
|
||||
// connection): correlate the token, pump against the local socket.
|
||||
// The establisher variant (recommended; the gate runs first) fails a
|
||||
// stale/missing token as a typed `channel:open_failed` BEFORE the
|
||||
// channel is born:
|
||||
register_tunnel_forwarded_openable(
|
||||
&core, &op_registry, auth,
|
||||
/* ForwardedHandler: (params, BiStream) -> pump task */,
|
||||
)?;
|
||||
|
||||
// With the correlation gate (stale token = `unknown_resource`); the
|
||||
// handler shape is unchanged:
|
||||
register_tunnel_forwarded_openable_with_establisher(
|
||||
&core, &op_registry, auth, gate /* ForwardedGate */,
|
||||
/* ForwardedHandler: (params, BiStream) -> pump task */,
|
||||
)?;
|
||||
```
|
||||
|
||||
### Consumer — open a tunnel channel (the `-L` path)
|
||||
### Consumer: open a tunnel channel (the `-L` path)
|
||||
|
||||
```rust
|
||||
use alkcall::channels::client::ChannelClient;
|
||||
@@ -131,13 +141,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 +156,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 +183,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 +200,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<SubstrateAddr> — informational, NOT addressing
|
||||
let token = listener.listen(); // Option<u32> — the forwarded op's correlation key
|
||||
let bound = listener.bound().cloned(); // Option<SubstrateAddr>; informational, NOT addressing
|
||||
let token = listener.listen(); // Option<u32>, 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 +229,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 +251,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 +261,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,36 +288,19 @@ 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
|
||||
|
||||
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 bind-first establisher + the forwarded op**
|
||||
(`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` +
|
||||
`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).
|
||||
|
||||
## Features
|
||||
|
||||
| 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).
|
||||
|
||||
|
||||
+25
-25
@@ -3,13 +3,13 @@ status: draft
|
||||
last_updated: 2026-09-16 (the graduation ADRs 007/008 + the amendments)
|
||||
---
|
||||
|
||||
# alktunnels — Architecture
|
||||
# alktunnels: Architecture
|
||||
|
||||
Arbitrary bidirectional tunnels over alkcall channels: the
|
||||
`alk/tunnel`-family producer/consumer protocol crate. This directory
|
||||
holds the architecture spec docs and the ADRs. It is the sibling of
|
||||
alktty's architecture (`/workspace/@alkdev/alktty/docs/architecture/`)
|
||||
— same substrate (alkcall channels), same shape (a producer
|
||||
alktty's architecture (`/workspace/@alkdev/alktty/docs/architecture/`):
|
||||
same substrate (alkcall channels), same shape (a producer
|
||||
open-handler + a typed consumer session), different service (tunnels:
|
||||
raw byte/datagram transport, not terminal sessions).
|
||||
|
||||
@@ -18,41 +18,41 @@ raw byte/datagram transport, not terminal sessions).
|
||||
| Document | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| [overview.md](overview.md) | draft | Crate purpose, the resource model in brief, dependencies, ALPN, feature gates, module map |
|
||||
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001; the direct op's `{substrate, target}` — ADR-007), the `bound` reply field + the forwarded op (ADR-008), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP — ADR-003), sentinels, half-close semantics |
|
||||
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001; the direct op's `{substrate, target}` per ADR-007), the `bound` reply field + the forwarded op (ADR-008), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP per ADR-003), sentinels, half-close semantics |
|
||||
| [producer.md](producer.md) | draft | The producer half: the `channels/tunnel/sub` + `channels/tunnel/direct` open ops, three establisher shapes (dial, accept-first listen, bind-first listen), the pump handler, the forwarded-op opener side, registration, scope gating |
|
||||
| [consumer.md](consumer.md) | draft | The consumer half: `TunnelSession` (stream + datagram variants, `open`/`open_direct`), `TunnelListener` (the listen flow), the reverse-flow initiator, teardown ownership (ADR-005/008) |
|
||||
| [bast.md](bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the data-plane's binary framing (the UDP `[len: u16 BE]` codec — ADR-003); the stream pass-through has no binary framing to describe |
|
||||
| [bast.md](bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the data-plane's binary framing (the UDP `[len: u16 BE]` codec, ADR-003); the stream pass-through has no binary framing to describe |
|
||||
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-TN-01..16; the Phase 0 ledger promoted) |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
alktunnels-native ADRs (001..008). Upstream ADRs (alkcall/alknet) are
|
||||
referenced by their own numbers and are NOT ported — the alkcall
|
||||
referenced by their own numbers and are NOT ported; the alkcall
|
||||
originals at `/workspace/@alkdev/alkcall/docs/architecture/decisions/`
|
||||
remain authoritative for the shared surfaces (establishment, channels
|
||||
wire format, `pump_bidi`, identity seam).
|
||||
|
||||
| ADR | Title | Status |
|
||||
|-----|-------|--------|
|
||||
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted; amended (params layout is per-op — the direct op's params ride ADR-007) |
|
||||
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted; amended (params layout is per-op; the direct op's params ride ADR-007) |
|
||||
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN (Option A) | Accepted; amended (flavor-form op ids: `…/sub`, `…/direct`, `…/forwarded` on the one ALPN) |
|
||||
| [003](decisions/003-codec-and-udp-framing.md) | Data-Plane Codec: Raw Pass-Through (Stream) / Mandatory Length Framing (UDP) | Accepted |
|
||||
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend Trait — Halves Functions at the Assembly Layer | Accepted |
|
||||
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend Trait: Halves Functions at the Assembly Layer | Accepted |
|
||||
| [005](decisions/005-consumer-session-owns-teardown.md) | The Consumer Session Type Owns Teardown | Accepted |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-Control Posture — the Open Gate Is the Security Boundary | Accepted; amended (`tunnel:direct`/`tunnel:forwarded` scopes; the hub re-produce policy sentence) |
|
||||
| [007](decisions/007-direct-open-op.md) | The Direct Open Op — Dynamic-Target Egress | Accepted |
|
||||
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen Metadata — Bind-First Establisher and the Forwarded Op | Accepted |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-Control Posture: the Open Gate Is the Security Boundary | Accepted; amended (`tunnel:direct`/`tunnel:forwarded` scopes; the hub re-produce policy sentence) |
|
||||
| [007](decisions/007-direct-open-op.md) | The Direct Open Op: Dynamic-Target Egress | Accepted |
|
||||
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen Metadata: Bind-First Establisher and the Forwarded Op | Accepted |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **A tunnel is a resource, not an address — with one additive
|
||||
exception.** Base params identify a produced resource + substrate
|
||||
1. **A tunnel is a resource, not an address (with one additive
|
||||
exception).** Base params identify a produced resource + substrate
|
||||
discriminator; the producer owns the backing (local port, docker
|
||||
container, in-process service, unix socket). Rich addressing
|
||||
enters through `-D`/dynamic composition, which speaks its protocol
|
||||
inside the tunnel payload. The exception (ADR-007): the direct op
|
||||
lets a consumer ask the producer to dial an arbitrary target — a
|
||||
different capability on its own op and scope (`tunnel:direct`),
|
||||
lets a consumer ask the producer to dial an arbitrary target. That
|
||||
is a different capability on its own op and scope (`tunnel:direct`),
|
||||
never implied by `tunnel:open`. See
|
||||
[ADR-001](decisions/001-open-params-layout.md),
|
||||
[ADR-007](decisions/007-direct-open-op.md), and
|
||||
@@ -61,7 +61,7 @@ wire format, `pump_bidi`, identity seam).
|
||||
2. **Role follows the resource; connection direction is independent.**
|
||||
Whoever can reach the target is the producer (registers openable
|
||||
channels); whoever wants the bytes is the consumer. `-L` and `-R`
|
||||
are the same open op with the entry point on different machines —
|
||||
are the same open op with the entry point on different machines;
|
||||
no protocol-level direction (the hub-owns-the-connection model,
|
||||
OQ-TN-03). The composed `-R` flow rides the bind-first establisher
|
||||
+ the forwarded op (ADR-008). Validated by the reverse-flow POC:
|
||||
@@ -70,15 +70,15 @@ wire format, `pump_bidi`, identity seam).
|
||||
[consumer.md](consumer.md).
|
||||
|
||||
3. **Raw pass-through for stream substrates; mandatory length framing
|
||||
for UDP.** A tunnel has one data stream per direction — no
|
||||
for UDP.** A tunnel has one data stream per direction: no
|
||||
sub-demux key, no tunnel-level header on stream substrates (0 B
|
||||
tunnel overhead; the channels 8-byte header is the only cost).
|
||||
UDP rides `[len: u16 BE]` framing — mandatory for correctness
|
||||
UDP rides `[len: u16 BE]` framing, which is mandatory for correctness
|
||||
(F-2; the rationale and the executable pin live in ADR-003). See
|
||||
[ADR-003](decisions/003-codec-and-udp-framing.md) and
|
||||
[wire.md](wire.md).
|
||||
|
||||
4. **The two-pump shape is `alkcall::channels::pump_bidi` — never
|
||||
4. **The two-pump shape is `alkcall::channels::pump_bidi`; it is never
|
||||
hand-rolled.** The producer's pump handler and the consumer-side
|
||||
pump both consume the upstream helper (ADR-050); each pump shuts
|
||||
down the opposite sink on completion (half-close semantics fall
|
||||
@@ -87,7 +87,7 @@ wire format, `pump_bidi`, identity seam).
|
||||
[producer.md](producer.md).
|
||||
|
||||
5. **No backend trait.** Stream substrates need "produce boxed halves
|
||||
for a resource" — a function, not a trait (the UDP POC's OQ-TN-05
|
||||
for a resource"; that is a function, not a trait (the UDP POC's OQ-TN-05
|
||||
finding). The inversion point is the assembly layer wiring
|
||||
substrate halves into the establisher/pump; substrate types stay
|
||||
in feature-gated backend modules. The listen variant (producer
|
||||
@@ -96,7 +96,7 @@ wire format, `pump_bidi`, identity seam).
|
||||
|
||||
6. **The consumer session type owns teardown.** One session = one
|
||||
channel = one tunnel (the channel ID is the flow key). The adopted
|
||||
side has no wrapper awaiting its pump (reverse POC W3) — the
|
||||
side has no wrapper awaiting its pump (reverse POC W3): the
|
||||
session must hold the pump handle and reap the channel on
|
||||
completion/close. See [ADR-005](decisions/005-consumer-session-owns-teardown.md)
|
||||
and [consumer.md](consumer.md).
|
||||
@@ -110,13 +110,13 @@ wire format, `pump_bidi`, identity seam).
|
||||
8. **Security posture: the open gate is the boundary.** Scope-gated
|
||||
opens (`TUNNEL_OPEN_SCOPE = "tunnel:open"`,
|
||||
`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`,
|
||||
`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"` — each stable once
|
||||
`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"` (each stable once
|
||||
published; ADR-006 + Amendment 1), alkcall's op-level ACL via
|
||||
`register_openable`; caller identity resolves in the 0.7.0
|
||||
precedence order (token > `ServingConfig.identity` > transport);
|
||||
the establisher/pump handler see the per-call opener identity. No
|
||||
target allowlists in the protocol (an assembly establisher MAY
|
||||
enforce them — policy, not protocol). Hub re-produce: the hub's
|
||||
enforce them, but that is policy, not protocol). Hub re-produce: the hub's
|
||||
ACL governs the final dial (the hub is the network boundary). See
|
||||
[ADR-006](decisions/006-access-control-posture.md),
|
||||
[ADR-007](decisions/007-direct-open-op.md), and
|
||||
@@ -155,10 +155,10 @@ Questions section.
|
||||
(CF-005/CF-006 = the identity seam, resolved in 0.7.0)
|
||||
- The graduation research (the two new ADRs implement it):
|
||||
`docs/research/tunnels-graduation.md`; the upstream alkcall asks
|
||||
(review 008) **landed in alkcall 0.8.0** — flavor-form discovery
|
||||
(review 008) **landed in alkcall 0.8.0**: flavor-form discovery
|
||||
derivation + the establisher → reply `bound` projection, plus the
|
||||
in-tree `ChannelRelay`/`HubLegTemplate` (alkcall ADR-051)
|
||||
- Sibling precedent: alktty (`/workspace/@alkdev/alktty`) — the
|
||||
- Sibling precedent: alktty (`/workspace/@alkdev/alktty`), i.e. the
|
||||
producer/consumer-on-channels shape, wasm-clean default,
|
||||
feature-gated local backend, BAST convention
|
||||
- POC code: `/workspace/alktunnels-udp-poc` (forward),
|
||||
|
||||
+17
-17
@@ -3,11 +3,11 @@ status: implemented
|
||||
last_updated: 2026-09-10
|
||||
---
|
||||
|
||||
# alktunnels — BAST Document for the `alk/tunnel` Wire Format
|
||||
# alktunnels: BAST Document for the `alk/tunnel` Wire Format
|
||||
|
||||
This document is the **BAST (Binary Abstract Syntax Tree)**
|
||||
specification for the binary portions of the `alk/tunnel` wire format
|
||||
— per AGENTS.md convention 12, the mandatory UDP length-framing codec
|
||||
specification for the binary portions of the `alk/tunnel` wire format.
|
||||
Per AGENTS.md convention 12, the mandatory UDP length-framing codec
|
||||
([ADR-003](decisions/003-codec-and-udp-framing.md)) is binary framing
|
||||
beyond pass-through, so the BAST document exists even though the
|
||||
format is trivial. It is a normative JSON document conforming to the
|
||||
@@ -24,31 +24,31 @@ an optional dep and this document is there to feed it.
|
||||
## Scope
|
||||
|
||||
The `alk/tunnel` wire format is: a JSON open op on the channels call
|
||||
plane (a UTF-8 JSON object inside the channels envelope — not a
|
||||
plane (a UTF-8 JSON object inside the channels envelope, not a
|
||||
binary layout; specified in [wire.md](wire.md) §The Open Op and
|
||||
implemented by `TunnelParams`/`tunnel_open_spec` in the Rust source),
|
||||
and a binary data plane inside the channel `BiStream`.
|
||||
|
||||
Per the BAST format spec, a BAST document describes **binary data
|
||||
layouts** — not JSON payloads
|
||||
layouts**, not JSON payloads
|
||||
([`validate_bytes` vs `validate_json`](https://alk.dev/bast/v1/spec)).
|
||||
This document therefore covers only the data plane's binary framing:
|
||||
|
||||
1. **The datagram frame** (`[len: u16 BE][datagram bytes]`) — the
|
||||
1. **The datagram frame** (`[len: u16 BE][datagram bytes]`), which is the
|
||||
UDP-substrate codec (ADR-003; mandatory, F-2). Stream substrates
|
||||
ride raw pass-through — zero tunnel-level framing, so there is
|
||||
ride raw pass-through, i.e. zero tunnel-level framing, so there is
|
||||
nothing for BAST to describe there.
|
||||
|
||||
What this BAST deliberately does **not** cover:
|
||||
|
||||
- **The open-op params JSON shape** (`{resource, substrate}`) — UTF-8
|
||||
- **The open-op params JSON shape** (`{resource, substrate}`), which is UTF-8
|
||||
JSON on the call plane; specified in
|
||||
[ADR-001](decisions/001-open-params-layout.md) and
|
||||
[wire.md](wire.md), implemented by `TunnelParams`.
|
||||
- **The channels 8-byte chunk header** — the channels layer's own
|
||||
- **The channels 8-byte chunk header**, the channels layer's own
|
||||
framing (alknet ADR-071/093); stripped transparently before the
|
||||
tunnel data plane sees bytes. Specified upstream, not here.
|
||||
- **The EOF sentinel** — the channels-level zero-length chunk
|
||||
- **The EOF sentinel**, i.e. the channels-level zero-length chunk
|
||||
(upstream); the tunnel codec never emits it (that is the F-2
|
||||
invariant).
|
||||
|
||||
@@ -72,12 +72,12 @@ What this BAST deliberately does **not** cover:
|
||||
|
||||
## Annotations
|
||||
|
||||
### `DatagramFrame` — binary, exact
|
||||
### `DatagramFrame`: binary, exact
|
||||
|
||||
Maps 1:1 to the on-wire bytes. `len` as `uint16` big-endian (2 bytes)
|
||||
followed by exactly `len` bytes of datagram payload. The zero-byte
|
||||
payload (`len == 0`) is a legal empty datagram — a real datagram, NOT
|
||||
EOF (the F-2 invariant; EOF is the channels-level sentinel, outside
|
||||
payload (`len == 0`) is a legal empty datagram (a real datagram, NOT
|
||||
EOF; the F-2 invariant; EOF is the channels-level sentinel, outside
|
||||
this codec). The `maxLength: 65535` encodes the u16 bound; the codec
|
||||
rejects framing a larger datagram at frame time (`Oversize`) rather
|
||||
than wrapping the length field.
|
||||
@@ -85,13 +85,13 @@ than wrapping the length field.
|
||||
One frame per datagram, in both directions of the channel
|
||||
`BiStream`. Frames are self-delimiting on the byte stream; a decoder
|
||||
is an incremental state machine across chunk boundaries (chunk
|
||||
splitting/batching is transparent — forward POC, 7-byte chunk splits
|
||||
splitting/batching is transparent, per the forward POC's 7-byte chunk splits
|
||||
verified).
|
||||
|
||||
### Layering note
|
||||
|
||||
The codec sits at the substrate/pump boundary (the establisher wraps
|
||||
the dialed UDP half in the framed adapter — ADR-003's placement, so
|
||||
the dialed UDP half in the framed adapter, per ADR-003's placement, so
|
||||
the pump handler stays substrate-agnostic). The BAST describes the
|
||||
wire inside the `BiStream` only; the pump/pump_bidi contract
|
||||
(ADR-050) is upstream's, not re-specified here.
|
||||
@@ -100,9 +100,9 @@ wire inside the `BiStream` only; the pump/pump_bidi contract
|
||||
|
||||
- [ADR-003](decisions/003-codec-and-udp-framing.md) (the codec
|
||||
decision + F-2 mandate), [ADR-001](decisions/001-open-params-layout.md)
|
||||
(the JSON open-op shape — outside BAST scope)
|
||||
(the JSON open-op shape, outside BAST scope)
|
||||
- [wire.md](wire.md) (the normative prose; §Normative byte diagrams)
|
||||
- alktty `tty-bast.md` (the BAST precedent — same meta-schema, same
|
||||
- alktty `tty-bast.md` (the BAST precedent, with the same meta-schema and the same
|
||||
"binary framing only" scope rule)
|
||||
- Forward POC `docs/research/poc-summary.md` §3 (the codec's
|
||||
validation matrix); reverse POC
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (`open_direct`, `TunnelListener`, the forwarded-op serving side — ADR-007/008 as amended: the correlation token)
|
||||
last_updated: 2026-09-18 (`open_direct`, `TunnelListener`, the forwarded-op serving side; ADR-007/008 as amended: the correlation token)
|
||||
---
|
||||
|
||||
# alktunnels — Consumer Half
|
||||
# alktunnels: Consumer Half
|
||||
|
||||
The consumer half: `TunnelSession` — the typed client for tunnel
|
||||
channels — plus `TunnelListener` (the listen-flow session, ADR-008)
|
||||
The consumer half: `TunnelSession`, the typed client for tunnel
|
||||
channels, plus `TunnelListener` (the listen-flow session, ADR-008)
|
||||
and the forwarded-op serving side. The consumer is whoever wants the
|
||||
bytes; it opens tunnel channels toward the producer (forward path), or
|
||||
per local accept toward a connect-side serving producer (the reverse
|
||||
flow). Both are the same session type — role follows the resource, not
|
||||
flow). Both are the same session type; role follows the resource, not
|
||||
the connection (OQ-TN-03). Design decisions:
|
||||
[ADR-005](decisions/005-consumer-session-owns-teardown.md) (session
|
||||
owns teardown), [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
@@ -20,24 +20,24 @@ metadata + the forwarded op); this document is the normative WHAT.
|
||||
|
||||
## Opening a Tunnel
|
||||
|
||||
### Forward path (`-L`/direct — the consumer dials the producer's transport)
|
||||
### Forward path (`-L`/direct, the consumer dials the producer's transport)
|
||||
|
||||
```rust
|
||||
let session = TunnelSession::open(&channel_client, params).await?;
|
||||
```
|
||||
|
||||
- `params`: `{resource, substrate}` (ADR-001) — the produced resource
|
||||
- `params`: `{resource, substrate}` (ADR-001), i.e. the produced resource
|
||||
+ substrate discriminator.
|
||||
- Flow: `ChannelClient::open_channel("channels/tunnel/sub", params,
|
||||
"alk/tunnel")` → adopt the returned channel ID → split the channel
|
||||
`BiStream` → present the substrate-shaped data plane.
|
||||
- Errors: a failed open resolves a typed error —
|
||||
- Errors: a failed open resolves a typed error:
|
||||
`ChannelOpenError::CallFailed` carrying the wire `CallError`;
|
||||
branch on `establishment_reason()` for `channel:open_failed`'s
|
||||
reason (dial_failed / unknown_resource / resource_shortage /
|
||||
handler_error / timeout), or the pre-establishment codes
|
||||
(`FORBIDDEN`, `channel:too_many_channels`). **A failed open never
|
||||
yields a session** — no phantom session, mirroring the
|
||||
yields a session**; no phantom session, mirroring the
|
||||
no-phantom-channel property (POC-verified).
|
||||
|
||||
### Direct path (dynamic-target egress, ADR-007)
|
||||
@@ -51,20 +51,20 @@ let session = TunnelSession::open_direct(&client, target, substrate).await?;
|
||||
caller states which one it holds. The session type, data planes,
|
||||
and teardown API are identical.
|
||||
- `target`: the substrate-shaped JSON object (`{host, port}` /
|
||||
`{path}` — ADR-007 §2); domain-form hosts stay unresolved (the
|
||||
`{path}`; ADR-007 §2); domain-form hosts stay unresolved (the
|
||||
producer resolves).
|
||||
- Flow: `open_channel("channels/tunnel/direct", {substrate, target},
|
||||
"alk/tunnel")` → same adopt/split/data-plane flow as `open`.
|
||||
- Errors: the same typed surface minus `unknown_resource` (no
|
||||
registry lookup); malformed targets are the schema/
|
||||
`invalid_input`-class failure. An old producer rejects the unknown
|
||||
op loudly (`NOT_FOUND` — the SSH posture).
|
||||
op loudly (`NOT_FOUND`, the SSH posture).
|
||||
|
||||
### Reverse path (`-R` — the consumer initiates toward a connect-side serving producer)
|
||||
### Reverse path (`-R`, the consumer initiates toward a connect-side serving producer)
|
||||
|
||||
The hub/accept-side shape (the reverse POC's `ReverseTunnel`, the
|
||||
seed of this path). Construction differs from the forward path only
|
||||
in how the channel is obtained — the session type and teardown API
|
||||
in how the channel is obtained; the session type and teardown API
|
||||
are identical:
|
||||
|
||||
```rust
|
||||
@@ -74,29 +74,29 @@ let session = TunnelSession::adopt(hub_manager, channel_id, substrate, TUNNEL_AL
|
||||
let pumped = session.pump_against(accepted_halves).await; // or drive the halves directly
|
||||
```
|
||||
|
||||
- `open_reverse_channel` — call the worker's open op on channel 0
|
||||
- `open_reverse_channel`: call the worker's open op on channel 0
|
||||
(`call_with_payload`; optional `auth_token`), extract the
|
||||
worker-allocated `channel_id`. Caller identity rides the transport
|
||||
by default (alkcall 0.7.0 CF-005 (b): `Connection::set_identity`
|
||||
before dialing); the payload `auth_token` is the optional
|
||||
hub-forwarding path (precedence: token > `ServingConfig.identity` >
|
||||
transport).
|
||||
- `TunnelSession::adopt(manager, channel_id, substrate, alpn)` —
|
||||
adopt the worker-allocated ID (connection-owner rule, ADR-047 §5 —
|
||||
- `TunnelSession::adopt(manager, channel_id, substrate, alpn)`:
|
||||
adopt the worker-allocated ID (connection-owner rule, ADR-047 §5:
|
||||
the SERVING side allocates; the initiator adopts; early-arrival
|
||||
parking covers the adoption race), install the session's data plane
|
||||
from the adopted halves. The substrate parameter is required — a
|
||||
from the adopted halves. The substrate parameter is required; a
|
||||
datagram session cannot exist without it (the data plane variant is
|
||||
substrate-shaped). `adopt` takes the same typed-error surface as
|
||||
`open` (an adopt failure is `AdoptFailed`-class, not an
|
||||
establishment error).
|
||||
- `pump_against(accepted_halves)` — spawn `pump_bidi` against the
|
||||
- `pump_against(accepted_halves)`: spawn `pump_bidi` against the
|
||||
accepted local connection and hold the pump handle (ADR-005).
|
||||
|
||||
The session type and its teardown API are identical on both paths —
|
||||
The session type and its teardown API are identical on both paths:
|
||||
one API, `-L` and `-R` alike.
|
||||
|
||||
## The Listen Flow — `TunnelListener` (ADR-008)
|
||||
## The Listen Flow: `TunnelListener` (ADR-008)
|
||||
|
||||
The listen-flow sibling of `TunnelSession`: the session that OPENS a
|
||||
bind-first listen tunnel and owns its teardown (ADR-005's ownership
|
||||
@@ -106,25 +106,25 @@ opener's manager (`adopt` gains nothing).
|
||||
|
||||
```rust
|
||||
let listener = TunnelListener::open(&client, params).await?; // bind-first: fast establishment
|
||||
let bound = listener.bound(); // the resolved OS-chosen address (Option — absent when unknown)
|
||||
let bound = listener.bound(); // the resolved OS-chosen address (Option; absent when unknown)
|
||||
let token = listener.listen(); // the correlation token (the producer's; from the `listen` reply field)
|
||||
let sessions = listener.forwarded(); // the forwarded sessions, as they arrive
|
||||
listener.close(self); // or Drop — tears the listen channel + far listener down
|
||||
listener.close(self); // or Drop; tears the listen channel + far listener down
|
||||
```
|
||||
|
||||
- **`TunnelListener::open`** — the same `channels/tunnel/sub` open
|
||||
- **`TunnelListener::open`**: the same `channels/tunnel/sub` open
|
||||
with `{resource, substrate}`; the establisher shape (bind-first vs
|
||||
accept-first) is the producer's per-registration choice, invisible
|
||||
to the constructor. What differs: bind-first replies carry
|
||||
`"bound"` (ADR-008 §2 — the resolved address; informational, never
|
||||
a dial target) and `"listen"` (ADR-008 Amendment 1 — the
|
||||
`"bound"` (ADR-008 §2: the resolved address; informational, never
|
||||
a dial target) and `"listen"` (ADR-008 Amendment 1: the
|
||||
correlation token the producer minted).
|
||||
- **`bound()`** — `Option`-shaped: `Some` when the producer supplied
|
||||
- **`bound()`**: `Option`-shaped: `Some` when the producer supplied
|
||||
the field (bind-first), `None` otherwise (accept-first, dial
|
||||
establishers, or old producers). reply#1-class fidelity (BND.ADDR).
|
||||
- **`listen()`** — `Option<u32>`: the correlation token (bind-first
|
||||
producers). The opener-side correlation registry — token → waiting
|
||||
session — is assembly-side bookkeeping (consumer.md's posture:
|
||||
- **`listen()`**: `Option<u32>`: the correlation token (bind-first
|
||||
producers). The opener-side correlation registry, token → waiting
|
||||
session, is assembly-side bookkeeping (consumer.md's posture:
|
||||
the protocol provides the op and params, not the wait state
|
||||
machine); `TunnelListener` hands the assembly the key.
|
||||
- **The forwarded side:** each accepted connection at the far
|
||||
@@ -135,7 +135,7 @@ listener.close(self); // or Drop — tears the listen channel + far li
|
||||
session, adopts the allocated channel (`TunnelSession::adopt`),
|
||||
and pumps against the local BIND client's halves.
|
||||
- **`peer` fidelity:** the forwarded op's params carry the accepted
|
||||
connection's real remote address (reply#2-class fidelity — the
|
||||
connection's real remote address (reply#2-class fidelity; the
|
||||
alksocks POC's sentinel retires). Producer-asserted metadata, not
|
||||
transport truth.
|
||||
- **Teardown:** dropping/`close`ing the listener EOFs the listen
|
||||
@@ -145,18 +145,18 @@ listener.close(self); // or Drop — tears the listen channel + far li
|
||||
|
||||
## The Data Plane (substrate-shaped)
|
||||
|
||||
- **Stream variant (`tcp`, `unix`):** `stream_halves()` — raw
|
||||
- **Stream variant (`tcp`, `unix`):** `stream_halves()`: raw
|
||||
`AsyncRead`/`AsyncWrite` halves; the halves ARE the tunnel (raw
|
||||
pass-through, ADR-003). For owned access (spawning local pumps),
|
||||
`take_halves()` consumes the session's halves.
|
||||
- **Datagram variant (`udp`):** `send_datagram(&[u8])` /
|
||||
`recv_datagram() -> Option<Bytes>` over the mandatory
|
||||
`[len: u16 BE]` codec (ADR-003). `recv_datagram` returns
|
||||
`Some(bytes)` per datagram — possibly empty (`len=0` is a legal
|
||||
empty datagram) — and `None` only on stream EOF (the channels-level
|
||||
`Some(bytes)` per datagram (possibly empty; `len=0` is a legal
|
||||
empty datagram), and `None` only on stream EOF (the channels-level
|
||||
sentinel; the codec never collides with it, F-2). A >65535-byte
|
||||
send is rejected at frame time (`Oversize`). `take_halves()` on a
|
||||
datagram session yields the RAW halves — the wire bytes carry the
|
||||
datagram session yields the RAW halves; the wire bytes carry the
|
||||
codec framing (ADR-005's N-3 amendment: one `take_halves` semantic
|
||||
for every substrate; keep the session for codec-applied I/O, or
|
||||
compose the framed adapter at your own boundary).
|
||||
@@ -165,27 +165,27 @@ listener.close(self); // or Drop — tears the listen channel + far li
|
||||
|
||||
One session = one channel = one tunnel. The session owns:
|
||||
|
||||
- **The adopted channel entry** — nothing upstream awaits the
|
||||
- **The adopted channel entry**: nothing upstream awaits the
|
||||
adopter's pump (the W3 gap); the session closes it structurally.
|
||||
- **The session-owned pump handle** (when the session spawned one —
|
||||
the reverse path's `pump_against`).
|
||||
- **The session-owned pump handle** (when the session spawned one,
|
||||
i.e. the reverse path's `pump_against`).
|
||||
|
||||
Teardown API:
|
||||
|
||||
- `close(self)` — abort the pump (if session-owned), tear down the
|
||||
- `close(self)`: abort the pump (if session-owned), tear down the
|
||||
adopted channel (`ChannelManager::teardown_channel`), consume the
|
||||
session. The ungraceful path.
|
||||
- `join(self)` — await pump completion (both directions finished;
|
||||
half-close semantics fall out of `pump_bidi` — one direction EOFs,
|
||||
- `join(self)`: await pump completion (both directions finished;
|
||||
half-close semantics fall out of `pump_bidi`: one direction EOFs,
|
||||
the opposite sink shuts down, the other keeps pumping until its own
|
||||
EOF; W4-validated), THEN reap the adopted channel. Returns the
|
||||
`(u64, u64)` copy counts for observability. The graceful path.
|
||||
**Pump-less sessions** (after `take_halves`, or a datagram session
|
||||
the caller drives directly): there is no session-owned pump —
|
||||
the caller drives directly): there is no session-owned pump, so
|
||||
`join` completes immediately (nothing to await) and reaps only;
|
||||
the copy counts are `(0, 0)`.
|
||||
- `Drop` — aborts the pump and sync-reaps the channel (teardown
|
||||
semantics of `close`, best-effort — `Drop` cannot await). Dropping
|
||||
- `Drop`: aborts the pump and sync-reaps the channel (teardown
|
||||
semantics of `close`; best-effort, since `Drop` cannot await). Dropping
|
||||
without close/join never leaks the channel entry.
|
||||
- **`TunnelSession` does not implement `Clone`.** Aliasing a session
|
||||
would alias its teardown; multi-channel consumers hold a
|
||||
@@ -195,7 +195,7 @@ Teardown API:
|
||||
|
||||
A peer-initiated `channel/close` (the generic channel op on the
|
||||
producer's serving registry) tears the SERVING side down; the
|
||||
consumer's session still owns its adopted entry — `close`/`join` reaps
|
||||
consumer's session still owns its adopted entry, so `close`/`join` reaps
|
||||
it. The reverse POC pinned this split (the close ran on the worker;
|
||||
the hub reaped its own entry, W3). EOF propagation is symmetric:
|
||||
either side's teardown reaches the other as the channels-level
|
||||
@@ -208,20 +208,20 @@ roles (alkcall ADR-022 §2): a worker dials the hub (connect side),
|
||||
serves its open ops (serving side), and its own outbound calls still
|
||||
resolve while serving (reverse POC: worker_outbound_calls_still_
|
||||
resolve_while_serving). The consumer half adds nothing to the
|
||||
connection's serving posture — a pure consumer (`from_connection`)
|
||||
connection's serving posture: a pure consumer (`from_connection`)
|
||||
never dispatches inbound requests; a reverse-flow initiator needs no
|
||||
serving registry of its own.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **OQ-TN-12**: hub re-produce composition (deferred — a hub proxy
|
||||
- **OQ-TN-12**: hub re-produce composition (deferred; a hub proxy
|
||||
re-exporting a consumed resource would add a
|
||||
`register_openable_with_establisher` whose establisher opens a
|
||||
consumer channel; no concrete consumer yet. The re-produce policy
|
||||
sentence is pinned: the hub's ACL governs the final dial — ADR-006
|
||||
sentence is pinned: the hub's ACL governs the final dial, per ADR-006
|
||||
Amendment 1)
|
||||
- **OQ-TN-13**: UDP truncation (resolved — fail-loud, ADR-003)
|
||||
- **OQ-TN-15/16**: resolved (ADR-007/008 — `open_direct`,
|
||||
- **OQ-TN-13**: UDP truncation (resolved, fail-loud, ADR-003)
|
||||
- **OQ-TN-15/16**: resolved (ADR-007/008: `open_direct`,
|
||||
`TunnelListener`, the forwarded op)
|
||||
|
||||
## References
|
||||
@@ -232,8 +232,8 @@ serving registry of its own.
|
||||
`ReverseTunnel` open_and_pump / close / join_and_reap; W3, W4)
|
||||
- `docs/research/tunnels-graduation.md` (the graduation research)
|
||||
- Upstream asks (review 008): `alkcall/docs/reviews/
|
||||
008-tunnels-graduation-upstream-review.md` — landed in alkcall
|
||||
008-tunnels-graduation-upstream-review.md`; landed in alkcall
|
||||
0.8.0
|
||||
- alkcall ADR-047 §5 (allocation — the initiator adopts), ADR-050
|
||||
- alkcall ADR-047 §5 (allocation; the initiator adopts), ADR-050
|
||||
(`pump_bidi`), ledger
|
||||
CF-005/CF-006 (identity on the reverse path)
|
||||
@@ -1,8 +1,8 @@
|
||||
# ADR-001: Open-Op Params Layout — Resource + Substrate
|
||||
# ADR-001: Open-Op Params Layout (Resource + Substrate)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-07); amended 2026-09-16 (params layout is per-op —
|
||||
Accepted (2026-09-07); amended 2026-09-16 (params layout is per-op:
|
||||
the direct op pins its own params, see Amendment 1)
|
||||
|
||||
## Context
|
||||
@@ -13,7 +13,7 @@ carrier of "what to open." alknet ADR-071 §ALPN table noted
|
||||
`alknet/tunnel` as `[0, 1]` data in/out only; the addressing scheme
|
||||
was never decided there. Phase 0 resolved the framing of the problem
|
||||
(the OQ-TN-01 reframe, 2026-09-05): the rich "remote addressing"
|
||||
framing was an XY problem — except in the `-D`/dynamic case (which
|
||||
framing was an XY problem. 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.
|
||||
@@ -21,7 +21,7 @@ general-purpose address.
|
||||
Constraints that shaped the decision:
|
||||
|
||||
- `params` is ALPN-specific JSON, interpreted by the open op's
|
||||
establisher/handler — never by the channels layer (alkcall
|
||||
establisher/handler, and never by the channels layer (alkcall
|
||||
ADR-039). The layout is a one-way door once a consumer exists
|
||||
(wire-stable once published; additive changes only).
|
||||
- The identifier must be substrate-extensible without format changes
|
||||
@@ -34,7 +34,7 @@ Constraints that shaped the decision:
|
||||
address. A `host:port` in params would leak the producer's
|
||||
topology into the wire and pin the wire to address-shaped targets.
|
||||
- alkcall's registry schema-validates the open op's input
|
||||
(`input_schema` runs before the establisher — review 007 Part B),
|
||||
(`input_schema` runs before the establisher, per review 007 Part B),
|
||||
so the layout must be schema-describable (no per-substrate
|
||||
polymorphic payloads in v1).
|
||||
- Both POCs used exactly this shape (`TunnelParams {resource,
|
||||
@@ -52,21 +52,21 @@ JSON object:
|
||||
}
|
||||
```
|
||||
|
||||
- **`resource`** (string, required) — the produced resource
|
||||
- **`resource`** (string, required): the produced resource
|
||||
identifier: the producer's stable name for the tunnel target. NOT
|
||||
an address; the producer's registry (an assembly-side construct,
|
||||
OQ-TN-11) maps the name to its backing (a local port, a docker
|
||||
container's port, an in-process service, a unix socket path).
|
||||
- **`substrate`** (string, required) — the extensible discriminator:
|
||||
- **`substrate`** (string, required): the extensible discriminator:
|
||||
`"tcp"` | `"udp"` | `"unix"` in v1. A new substrate is a new value,
|
||||
not a format change — the same additive property SSH gets from
|
||||
not a format change, the same additive property SSH gets from
|
||||
channel-type strings and SOCKS5 gets from ATYP values. The
|
||||
discriminator also selects the data-plane framing (ADR-003): the
|
||||
establisher and both pumps must agree on it, so it rides the open
|
||||
op rather than being inferred.
|
||||
- The open op's `input_schema` pins both fields as required strings;
|
||||
`substrate` is enum-validated against the v1 set. Unknown
|
||||
substrate values are a schema failure (typed open error) — a
|
||||
substrate values are a schema failure (typed open error): a
|
||||
consumer speaking a newer substrate to an older producer fails
|
||||
loudly, not silently.
|
||||
- Substrate-specific detail (the path of a unix socket, the UDP
|
||||
@@ -75,12 +75,12 @@ JSON object:
|
||||
- The open-failure error path rides alkcall ADR-049 (typed
|
||||
`channel:open_failed` with `details.reason` ∈ `dial_failed` /
|
||||
`unknown_resource` / `resource_shortage` / `handler_error` /
|
||||
`timeout`): an unknown resource name is `unknown_resource` — no
|
||||
`timeout`): an unknown resource name is `unknown_resource`; there is no
|
||||
params-level error field, no establishment frame (the pre-ADR-049
|
||||
OQ-TN-09 hunch is superseded).
|
||||
|
||||
Rich in-band addressing (SOCKS5 ATYP, per-datagram remote addresses)
|
||||
enters only through the `-D`/dynamic-target composition path — it is
|
||||
enters only through the `-D`/dynamic-target composition path; it is
|
||||
carried INSIDE the tunnel payload by whatever protocol the tunnel
|
||||
carries (socks5, udpgw-style framing), never in base params.
|
||||
|
||||
@@ -90,7 +90,7 @@ carries (socks5, udpgw-style framing), never in base params.
|
||||
renames are a breaking wire change (do not). Adding an optional
|
||||
field is additive-safe; adding a `substrate` value is
|
||||
consumer-progressive (an older producer rejects with
|
||||
`invalid_input`-class schema error — the SSH "unknown channel type"
|
||||
`invalid_input`-class schema error, in the SSH "unknown channel type"
|
||||
posture, loud not silent).
|
||||
- **The BAST question is moot for params.** Params is JSON inside the
|
||||
channels open op (which is already a JSON envelope); the data
|
||||
@@ -101,7 +101,7 @@ carries (socks5, udpgw-style framing), never in base params.
|
||||
- **Producers own a naming discipline.** Resource ids are
|
||||
producer-scoped names (OQ-TN-11: collision domain = the producer's
|
||||
registry). Consumers learn available resources through the
|
||||
ACL-filtered ops listing (OQ-TN-08) — the spec's
|
||||
ACL-filtered ops listing (OQ-TN-08), so the spec's
|
||||
`OperationSpec.description` SHOULD carry a human-readable hint
|
||||
(e.g. "postgres on the worker's tailnet") since it round-trips
|
||||
through discovery.
|
||||
@@ -125,10 +125,10 @@ carries (socks5, udpgw-style framing), never in base params.
|
||||
The `{resource, substrate}` layout pins the `channels/tunnel/sub` open
|
||||
op. It is **per-op, not per-ALPN**: ADR-007 adds the
|
||||
`channels/tunnel/direct` op on the same `alk/tunnel` ALPN with its own
|
||||
params (`{substrate, target}` — a dynamic target, a different
|
||||
capability with its own scope; the resource-not-address invariant is
|
||||
unchanged because a dynamic target is not a produced resource). The
|
||||
per-op reading was always latent in the layout's basis — params are
|
||||
params (`{substrate, target}`, i.e. a dynamic target, a different
|
||||
capability with its own scope; the resource-not-address invariant is
|
||||
unchanged because a dynamic target is not a produced resource). The
|
||||
per-op reading was always latent in the layout's basis, since params are
|
||||
"the `input` of the operation" (alkcall ADR-047), and ops are
|
||||
per-flavor once flavor-form op ids exist (ADR-002 Amendment 1).
|
||||
Additive-only evolution per op stands: this amendment adds nothing to
|
||||
|
||||
@@ -25,7 +25,7 @@ enum) and the ALPN namespace (a published string is wire-stable).
|
||||
|
||||
Prior art surveyed in Phase 0 (`ssh-socks5-survey.md`):
|
||||
|
||||
- SSH uses ONE channel mechanism for all forwarding flavors — the
|
||||
- SSH uses ONE channel mechanism for all forwarding flavors; the
|
||||
channel type string (`direct-tcpip`, `forwarded-tcpip`,
|
||||
`direct-streamlocal`) is per-open metadata on one transport, not a
|
||||
separate transport per flavor.
|
||||
@@ -35,7 +35,7 @@ Prior art surveyed in Phase 0 (`ssh-socks5-survey.md`):
|
||||
stream (length-framed datagrams inside a TCP tunnel).
|
||||
- With OQ-TN-02 resolved as endpoint-at-open for base UDP resources,
|
||||
the substrate discriminator in `params` tells the establisher and
|
||||
pumps which framing to expect — exactly option A's shape. The
|
||||
pumps which framing to expect, exactly option A's shape. The
|
||||
discriminator is load-bearing (it selects the framing, ADR-003),
|
||||
so it must ride the open op; the ALPN carries no substrate
|
||||
information at all.
|
||||
@@ -49,16 +49,16 @@ pump handler, and consumer session type are shared across substrates
|
||||
session types).
|
||||
|
||||
- The open op id is `channels/tunnel/sub` (the `channels/<alpn>/sub`
|
||||
convention of alkcall ADR-047 — the open op is a `Sub`-typed
|
||||
convention of alkcall ADR-047 (the open op is a `Sub`-typed
|
||||
operation: the reply carries `channel_id` once, the data plane
|
||||
flows on the channel's `BiStream`).
|
||||
- The channel's ALPN marker on the open-op spec is `alk/tunnel`
|
||||
(`ChannelOpenSpec::new("alk/tunnel")`) — the value the
|
||||
(`ChannelOpenSpec::new("alk/tunnel")`, which is the value the
|
||||
per-ALPN dispatch and the adopted-side manager record for
|
||||
observability.
|
||||
- If a future tunnel flavor needs structurally different framing from
|
||||
byte zero with no params-dependent dispatch, that is a NEW ALPN
|
||||
decided by a new ADR before its first consumer — never a change to
|
||||
decided by a new ADR before its first consumer, and never a change to
|
||||
`alk/tunnel`'s meaning. ALPN strings are wire-stable once
|
||||
published.
|
||||
|
||||
@@ -71,12 +71,12 @@ session types).
|
||||
`(resource, substrate)`.
|
||||
- **Framing is params-dependent, not ALPN-dependent.** The establisher
|
||||
validates `substrate` semantically (the registry lookup); the pump
|
||||
handler is framing-agnostic (raw pass-through halves — the codec
|
||||
handler is framing-agnostic (raw pass-through halves; the codec
|
||||
lives on the consumer/producer edge for UDP, ADR-003). A mismatched
|
||||
`substrate` value fails at schema/establisher time, loudly.
|
||||
- **ALPN namespace hygiene:** `alk/tunnel` is published here. The
|
||||
`alknet/tunnel` spelling in the alknet ADR-071 table predates the
|
||||
`alknet/` → `alk/` prefix swap (alkcall v0.1.1) — docs and code
|
||||
`alknet/` → `alk/` prefix swap (alkcall v0.1.1), so docs and code
|
||||
must not perpetuate the old prefix.
|
||||
|
||||
## References
|
||||
@@ -88,20 +88,20 @@ session types).
|
||||
|
||||
## Amendment 1 (flavor-form op ids, 2026-09-16)
|
||||
|
||||
The decision stands — one ALPN, no ALPN split per flavor. What
|
||||
The decision stands: one ALPN, no ALPN split per flavor. What
|
||||
changes: the one-ALPN rule is realized as **one ALPN, several
|
||||
`Sub`-typed open ops**, because ADR-007 (the direct op) and ADR-008
|
||||
(the forwarded op) are different capabilities sharing the tunnel
|
||||
transport — ssh's one-transport-many-channel-types shape.
|
||||
transport, ssh's one-transport-many-channel-types shape.
|
||||
|
||||
- The op-id convention extends to flavor form: `channels/<alpn>/sub`
|
||||
(the original resource op, reserved and unchanged),
|
||||
`channels/<alpn>/direct` (dial an arbitrary target), and
|
||||
`channels/<alpn>/forwarded` (accept-as-open toward the listen
|
||||
opener). New flavors are new op ids — additive; the `…/sub` op is
|
||||
opener). New flavors are new op ids, which is additive; the `…/sub` op is
|
||||
never reused for a different meaning.
|
||||
- The alternative — sibling ALPNs (`alk/tunnel-direct`,
|
||||
`alk/tunnel-forwarded`) with standard `…/sub` op ids — was rejected:
|
||||
- The alternative, sibling ALPNs (`alk/tunnel-direct`,
|
||||
`alk/tunnel-forwarded`) with standard `…/sub` op ids, was rejected:
|
||||
it fragments the tunnel namespace per flavor, forces hub relay to
|
||||
track three ALPNs, and amends this ADR's own new-ALPN justification
|
||||
clause (the flavors share framing; none needs structurally different
|
||||
@@ -109,7 +109,7 @@ transport — ssh's one-transport-many-channel-types shape.
|
||||
- Upstream coordination: alkcall ADR-047 pins discovery's ALPN
|
||||
derivation to exactly the `…/sub`/`…/pub` suffix shapes
|
||||
(`derive_alpn_from_op_name`); flavor-form op ids need that
|
||||
derivation (and the hub-relay reconstruction riding it) extended —
|
||||
derivation (and the hub-relay reconstruction riding it) extended; it was
|
||||
filed upstream
|
||||
(`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`,
|
||||
ask #1) before the first consumer. Runtime was never blocked:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# ADR-003: Data-Plane Codec — Raw Pass-Through (Stream) / Mandatory Length Framing (UDP)
|
||||
# ADR-003: Data-Plane Codec, Raw Pass-Through (Stream) / Mandatory Length Framing (UDP)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-07; supersedes the Phase 0 "codec direction" hunch
|
||||
by making the UDP framing mandatory — reverse POC finding F-2).
|
||||
by making the UDP framing mandatory, per reverse POC finding F-2).
|
||||
Amended (2026-09-08, review 001 U-1 remediation): the substrate-
|
||||
boundary placement is pinned mechanically — the `local` feature's
|
||||
boundary placement is pinned mechanically, so the `local` feature's
|
||||
`UdpHalf` composes the codec at the boundary (channel side framed,
|
||||
target side raw), with the pump-window and recv-scratch disciplines
|
||||
the composition requires. See §Amendment (2026-09-08).
|
||||
@@ -14,10 +14,10 @@ the composition requires. See §Amendment (2026-09-08).
|
||||
|
||||
The tunnel data plane rides a channels data channel: raw bytes inside
|
||||
the channel `BiStream` (the channels layer strips its 8-byte header
|
||||
transparently — alknet ADR-093/ADR-071; AGENTS.md convention 5). The
|
||||
transparently, per alknet ADR-093/ADR-071 and AGENTS.md convention 5). The
|
||||
tunnel protocol owns whatever framing it puts inside the `BiStream`.
|
||||
A tunnel has one data stream per direction, so there is no
|
||||
sub-demux key — a 5-byte header like alktty's (with `stream_type`)
|
||||
sub-demux key (a 5-byte header like alktty's, with `stream_type`,
|
||||
would be pure overhead here.
|
||||
|
||||
The Phase 0 direction (`phase-0-findings.md` checklist, 2026-09-06):
|
||||
@@ -29,14 +29,14 @@ Two validation passes sharpened this:
|
||||
|
||||
1. **Forward POC (2026-09-06, 17 tests):** the codec round-trips
|
||||
datagrams split across chunks, batched in one chunk, partial
|
||||
headers, and empty datagrams (`len=0` is a legal payload, NOT EOF
|
||||
— EOF is the channels-level `length=0` chunk sentinel; the two
|
||||
headers, and empty datagrams (`len=0` is a legal payload, NOT EOF;
|
||||
EOF is the channels-level `length=0` chunk sentinel; the two
|
||||
coexist at different layers). The 1400-byte (max ethernet MTU
|
||||
payload) datagram rides the bounded-buffer path; >65535 is
|
||||
rejected at frame time.
|
||||
2. **Reverse POC finding F-2 (2026-09-07, executable-pinned):** in
|
||||
the RAW pass-through pump shape, an empty UDP datagram is a
|
||||
zero-byte read from the substrate adapter — `tokio::io::copy`
|
||||
zero-byte read from the substrate adapter, and `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 test pins the non-round-trip so the
|
||||
@@ -49,7 +49,7 @@ The data plane inside a `alk/tunnel` channel is:
|
||||
- **Stream substrates (`tcp`, `unix`): raw pass-through.** The halves
|
||||
are the tunnel; zero tunnel-level framing. The only wire overhead
|
||||
is the channels 8-byte chunk header. A zero-length read on a stream
|
||||
substrate is genuinely EOF (stream semantics) — no collision. Both
|
||||
substrate is genuinely EOF (stream semantics), so there is no collision. Both
|
||||
pumps run `alkcall::channels::pump_bidi` (ADR-050) over the raw
|
||||
halves.
|
||||
- **Datagram substrate (`udp`): mandatory `[len: u16 BE][datagram]`
|
||||
@@ -57,7 +57,7 @@ The data plane inside a `alk/tunnel` channel is:
|
||||
length-prefixed frame, on BOTH directions:
|
||||
- The **establisher** wraps the dialed UDP socket in the framed
|
||||
adapter BEFORE returning the plan (the plan payload is the
|
||||
adapter, not a raw socket) — the pump handler stays
|
||||
adapter, not a raw socket), so the pump handler stays
|
||||
substrate-agnostic (ADR-004's invariant; producer.md's placement).
|
||||
- Consumer side: the `TunnelSession`'s datagram variant does the
|
||||
same over the adopted halves.
|
||||
@@ -66,29 +66,29 @@ The data plane inside a `alk/tunnel` channel is:
|
||||
POC). EOF is exclusively the channels-level sentinel; the codec
|
||||
never emits zero-length reads, so the layers never collide. This
|
||||
is the load-bearing property F-2 validated from a second angle:
|
||||
the length prefix makes an empty datagram two bytes — unambiguous
|
||||
the length prefix makes an empty datagram two bytes, which is unambiguous
|
||||
with EOF.
|
||||
- **MTU discipline:** the codec rejects framing a >65535-byte
|
||||
datagram (`Oversize` error at frame time, never a wire overflow —
|
||||
datagram (`Oversize` error at frame time, never a wire overflow;
|
||||
the u16 length field would wrap). A truncated receive (caller
|
||||
buffer smaller than the datagram) fails loudly at the adapter
|
||||
level — resolved 2026-09-07 (OQ-TN-13, fail-loud): silent
|
||||
level, resolved 2026-09-07 (OQ-TN-13, fail-loud): silent
|
||||
truncation would corrupt the framing invariants (the same
|
||||
invariant class F-2 protects).
|
||||
- **Chunking is transparent.** Datagrams split across channel chunks,
|
||||
batch into single chunks, and survive partial headers — the
|
||||
batch into single chunks, and survive partial headers; the
|
||||
incremental decoder is the only consumer-visible decode path
|
||||
(forward POC: maximally awkward 7-byte chunk splits verified).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Amendment (2026-09-08 — the boundary mechanics, from review 001 U-1's remediation)
|
||||
### Amendment (2026-09-08, the boundary mechanics from review 001 U-1's remediation)
|
||||
|
||||
The placement sentence ("the establisher wraps the dialed UDP socket
|
||||
in the framed adapter before returning the plan") leaves the adapter's
|
||||
*mechanics* open; review 001 found `UdpHalf` shipping as a raw
|
||||
boundary-preserving adapter (the codec on one end only — real targets
|
||||
saw the tunnel's length prefixes; empty datagrams collapsed to EOF at
|
||||
boundary-preserving adapter (the codec on one end only, so real targets
|
||||
saw the tunnel's length prefixes; empty datagrams collapsed to EOF at
|
||||
the pump's zero-read check; >8 KiB datagrams split at the copy
|
||||
buffer). The remediation pins the mechanics:
|
||||
|
||||
@@ -98,29 +98,29 @@ buffer). The remediation pins the mechanics:
|
||||
sends each decoded payload as one raw datagram; `poll_read` **frames**
|
||||
(`frame_datagram`) each raw target datagram and hands frame bytes to
|
||||
the pump. "Frame on write, de-frame on read" describes the TARGET's
|
||||
face and is the inverted (wrong) reading — the review's original
|
||||
face and is the inverted (wrong) reading; the review's original
|
||||
fix sketch had it inverted and was corrected in re-validation.
|
||||
- **The write half accepts all offered bytes and reports them
|
||||
consumed.** The pump's copy engine re-offers a *full* window
|
||||
unsatisfied until it drains (tokio `copy`'s `read_done` path never
|
||||
tops the window up), so a partial-frame tail could sit forever in a
|
||||
full window if the adapter accepted only complete frames — the
|
||||
full window if the adapter accepted only complete frames; the
|
||||
>8 KiB frame would deadlock the pump. Completed datagrams queue for
|
||||
the socket; a trailing partial frame waits in the `DatagramReader`
|
||||
(its state is bounded by construction — the u16 cap, review 001
|
||||
(its state is bounded by construction, by the u16 cap, review 001
|
||||
N-2). Backpressure is an accepted-byte high-water mark on
|
||||
`poll_write`, and the socket drain pumps opportunistically on
|
||||
write/flush/shutdown (tokio's copy polls flush after every window
|
||||
drain).
|
||||
- **The recv scratch is full-size (65535) and lazy.** `try_recv`
|
||||
truncates SILENTLY at the buffer size — a smaller scratch corrupts
|
||||
truncates SILENTLY at the buffer size, and a smaller scratch corrupts
|
||||
the framing before any size check could reject it (the review's
|
||||
"size to the MTU" suggestion is unsafe as written). The 64 KiB
|
||||
eager allocation is instead lazy (first recv), the U-3 mitigation.
|
||||
- **A >caller-buffer frame is staged, not an error.** The frame's
|
||||
length prefix reaches the pump on the first read; the remainder
|
||||
hands out across reads. The pump's far-side de-framer reassembles
|
||||
the true length — the raw adapter's read-level `InvalidData` is
|
||||
the true length; the raw adapter's read-level `InvalidData` is
|
||||
replaced by staging (the fail-loud posture now protects the *frame
|
||||
stream*, and a wire-level over-bound datagram remains a loud
|
||||
`Oversize`-class error at the frame path).
|
||||
@@ -131,9 +131,9 @@ recording-target suite).
|
||||
|
||||
## Consequences (original)
|
||||
|
||||
- **TCP/unix tunnels have 0 B tunnel overhead** — the SSH
|
||||
- **TCP/unix tunnels have 0 B tunnel overhead**, matching the SSH
|
||||
`direct-tcpip` experience (the payload is the bytes).
|
||||
- **UDP correctness requires the codec** — raw pass-through over UDP
|
||||
- **UDP correctness requires the codec**, since raw pass-through over UDP
|
||||
is structurally broken for empty datagrams (F-2). The substrate
|
||||
discriminator (ADR-001) is what tells both sides to engage the
|
||||
codec; a consumer bypassing it for UDP is a spec violation, not a
|
||||
@@ -141,8 +141,8 @@ recording-target suite).
|
||||
- **`tokio::io::copy`-shaped pumps fit UDP only through the framed
|
||||
adapter** (the `UdpHalf` + codec wrapper presents
|
||||
`AsyncRead`/`AsyncWrite` whose zero-length read never occurs for a
|
||||
well-formed stream — `len=0` yields a 2-byte wire frame).
|
||||
- **The BAST document exists** (AGENTS.md convention 12 — the
|
||||
well-formed stream; `len=0` yields a 2-byte wire frame).
|
||||
- **The BAST document exists** (AGENTS.md convention 12; the
|
||||
mandatory framing IS binary framing beyond pass-through, so the
|
||||
trigger fired): `docs/architecture/bast.md` describes the
|
||||
`[len: u16 BE]` datagram frame per the BAST meta-schema; the
|
||||
@@ -160,6 +160,6 @@ recording-target suite).
|
||||
sentinel layering + MTU); reverse POC:
|
||||
`docs/research/reverse-poc-summary.md` §F-2 (the mandate)
|
||||
- `docs/research/ssh-socks5-survey.md` §UDP (tun2proxy udpgw framing
|
||||
precedent — no CONN_ID, no FLAGS; boundary framing only)
|
||||
precedent (no CONN_ID, no FLAGS; boundary framing only)
|
||||
- alknet ADR-071/093 (channels wire format; the zero-length sentinel)
|
||||
- alkcall `channels::pump_bidi` (ADR-050)
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR-004: No TunnelBackend Trait — Halves Functions at the Assembly Layer
|
||||
# ADR-004: No TunnelBackend Trait (Halves Functions at the Assembly Layer)
|
||||
|
||||
## Status
|
||||
|
||||
@@ -8,8 +8,8 @@ re-produce thread)
|
||||
## Context
|
||||
|
||||
alktty has a `TtyBackend` trait because backends (local PTY, docker,
|
||||
SSH) produce complex handles — `TtyHandle` carries
|
||||
stdin/stdout/stderr/exit-code quadruple + control — and the adapter
|
||||
SSH) produce complex handles: `TtyHandle` carries
|
||||
stdin/stdout/stderr/exit-code quadruple + control, and the adapter
|
||||
pumps them. The Phase 0 question (OQ-TN-05): does alktunnels need a
|
||||
`TunnelBackend` trait, or is the two-pump handler + feature-gated
|
||||
substrate modules the whole story?
|
||||
@@ -24,7 +24,7 @@ backend's:
|
||||
connection is the same boxed-halves shape.
|
||||
|
||||
The UDP POC gave the executable answer for dial flows (2026-09-06):
|
||||
`pump_halves` is generic over boxed `(AsyncRead, AsyncWrite)` halves —
|
||||
`pump_halves` is generic over boxed `(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.
|
||||
@@ -33,7 +33,7 @@ The one remaining thread was the hub re-produce composition (does a
|
||||
hub proxy that re-produces a consumed resource need a trait or a
|
||||
composition helper?). The reverse POC (2026-09-07) resolved the
|
||||
mechanics: a hub proxy is a consumer + producer composed at the
|
||||
assembly layer — the reverse POC's hub is exactly this shape (it
|
||||
assembly layer; the reverse POC's hub is exactly this shape (it
|
||||
consumes a channels connection and produces reverse-tunnel opens
|
||||
toward the worker); re-exporting (registering an openable for a
|
||||
resource the hub itself consumes) would add a
|
||||
@@ -51,7 +51,7 @@ assembly layer:
|
||||
|
||||
- **The produce function.** The producer's establisher resolves
|
||||
`(resource, substrate)` → boxed halves
|
||||
(`Box<dyn AsyncRead + Send + Sync + Unpin>` ×2 — the `+ Sync` is
|
||||
(`Box<dyn AsyncRead + Send + Sync + Unpin>` ×2; the `+ Sync` is
|
||||
the `ChannelPlan` bound, reverse POC F-1) or a framed UDP adapter.
|
||||
The dial itself is substrate code living in feature-gated backend
|
||||
modules (`local` feature: real sockets), injected at assembly:
|
||||
@@ -64,12 +64,12 @@ assembly layer:
|
||||
open op; its establisher pops the next accepted connection from an
|
||||
assembly-owned listener queue and returns it as the plan payload.
|
||||
The listener lifecycle (bind, accept loop, cancel) is assembly
|
||||
code — the protocol never binds (OQ-TN-04). The reverse POC's
|
||||
code; the protocol never binds (OQ-TN-04). The reverse POC's
|
||||
worker (dial) and the forward POC's producer (dial) both rode this
|
||||
shape; the listen establisher is the same plan-flow with a
|
||||
different halves source.
|
||||
- **The pump handler is substrate-agnostic by construction:**
|
||||
`pump_bidi(bidi, t_read, t_write)` over the downcast plan payload —
|
||||
`pump_bidi(bidi, t_read, t_write)` over the downcast plan payload;
|
||||
it cannot know whether the halves came from TCP, UDP, a unix
|
||||
socket, or an in-process pipe, and must not.
|
||||
- **Datagram substrates** contribute a framed adapter (ADR-003)
|
||||
@@ -78,9 +78,9 @@ assembly layer:
|
||||
every assembly layer re-implements the same glue (registry + dial +
|
||||
establisher construction + pump wiring) three or more times, extract
|
||||
a `TunnelBackend`-shaped trait THEN (the alknet ADR-078
|
||||
convergence-test threshold — extract when the shapes have converged
|
||||
convergence-test threshold: extract when the shapes have converged
|
||||
across consumers, not before)
|
||||
— as an additive layer, not a v1 wire/ABI commitment. Until then
|
||||
and do it as an additive layer, not a v1 wire/ABI commitment. Until then
|
||||
the function-plus-closure shape keeps the crate dependency-light
|
||||
and wasm-clean.
|
||||
|
||||
@@ -89,14 +89,14 @@ assembly layer:
|
||||
- **The default crate stays wasm-clean** with no socket/platform
|
||||
deps; `local` (sockets) and future backends (docker, process) are
|
||||
feature-gated modules implementing dial/listen functions.
|
||||
- **Assembly layers write more glue than a trait would save** — the
|
||||
accepted cost, validated twice (both POCs' producers are ~200 lines
|
||||
- **Assembly layers write more glue than a trait would save**, which is
|
||||
the accepted cost, validated twice (both POCs' producers are ~200 lines
|
||||
including the UDP adapter).
|
||||
- **Plan payloads are `Send + Sync`** (reverse POC F-1, documented on
|
||||
alkcall's `ChannelPlan` type in 0.7.0): socket-backed handles carry
|
||||
`+ Sync` naturally; non-Sync handles (process pipes as boxed trait
|
||||
objects) need a wrapper. The spec documents the constraint.
|
||||
- **The listen variant needs no new wire surface** — same open op,
|
||||
- **The listen variant needs no new wire surface**: same open op,
|
||||
same params, same typed errors (an empty listener queue during
|
||||
establishment is `resource_shortage`; a closed listener is
|
||||
`dial_failed`-class).
|
||||
@@ -108,8 +108,8 @@ assembly layer:
|
||||
- Forward POC: `docs/research/poc-summary.md` §5 (halves-not-trait);
|
||||
reverse POC: `docs/research/reverse-poc-summary.md` (the hub shape,
|
||||
F-1)
|
||||
- alktty ADR-002 (`TtyBackend` — the contrast case: when a trait IS
|
||||
warranted), alktty ADR-003 (backend placement — the feature-gate
|
||||
- alktty ADR-002 (`TtyBackend`, the contrast case: when a trait IS
|
||||
warranted), alktty ADR-003 (backend placement, the feature-gate
|
||||
pattern)
|
||||
- AGENTS.md conventions 4/7/14 (wasm-clean, substrate-agnostic,
|
||||
feature flags)
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Accepted (2026-09-07; resolves the reverse POC W3 finding).
|
||||
Amended (2026-09-08, review 001 N-3 decision): the datagram
|
||||
`take_halves` posture is pinned — raw framed halves stay the only
|
||||
`take_halves` posture is pinned: raw framed halves stay the only
|
||||
owned-halves shape (`take_halves` keeps its semantics for every
|
||||
substrate), the datagram accessors remain the codec path, and a
|
||||
framed-halves convenience is deferred to the first consumer that
|
||||
@@ -17,23 +17,23 @@ A tunnel channel's two ends have asymmetric lifecycle machinery:
|
||||
- **The serving side (the side the open op ran on)** is
|
||||
wrapper-managed: the open wrapper awaits the pump handler's
|
||||
`JoinHandle`, and its completion triggers channel teardown (drop of
|
||||
the demux sender = EOF to the handler's read half — alkcall ADR-049
|
||||
the demux sender = EOF to the handler's read half, per alkcall ADR-049
|
||||
+ review 007 R-02). Out-of-band `channel/close` also tears it down.
|
||||
Nothing leaks.
|
||||
- **The adopting side (the side that called the open op and adopted
|
||||
the channel ID — the consumer)** has NO such machinery:
|
||||
the channel ID, i.e. the consumer)** has NO such machinery:
|
||||
`ChannelManager::adopt_channel` installs routing state nothing
|
||||
awaits. The consumer-side pump (`pump_bidi` spawned locally) is
|
||||
somebody's `JoinHandle`; the adopted channel state is nobody's to
|
||||
reap. If the assembly layer drops both, the channel entry leaks in
|
||||
the manager until transport EOF (`clear_all`) — and worse, a
|
||||
the manager until transport EOF (`clear_all`); and worse, a
|
||||
consumer that drops the pump handle mid-flight aborts the pump
|
||||
without reaping, leaving half-open state on the peer.
|
||||
|
||||
The reverse POC found this by construction (W3, 2026-09-07): the hub
|
||||
must hold its pump handle and reap (`teardown_channel`) itself —
|
||||
must hold its pump handle and reap (`teardown_channel`) itself.
|
||||
`ReverseTunnel::join_and_reap`/`close` in the POC. The lifecycle is
|
||||
assembly-layer by design (OQ-TN-04 — no forced binding means no
|
||||
assembly-layer by design (OQ-TN-04: no forced binding means no
|
||||
protocol-owned listener lifecycle either), but the POC demonstrated
|
||||
that leaving teardown discipline to each assembly layer's memory is
|
||||
exactly the kind of gap the spec must close with a type, not a doc
|
||||
@@ -49,31 +49,31 @@ plane (raw halves vs datagram codec, ADR-003).
|
||||
The consumer half is a typed session, **`TunnelSession`**, and it owns
|
||||
its teardown:
|
||||
|
||||
- **Construction:** `TunnelSession::open(client, params)` — calls the
|
||||
- **Construction:** `TunnelSession::open(client, params)`: calls the
|
||||
open op (`ChannelClient::open_channel` on the forward path; the
|
||||
reverse path's `open_reverse_channel` + `adopt_channel` equivalent),
|
||||
adopts the returned channel ID, splits the channel `BiStream`, and
|
||||
presents the substrate-shaped data plane:
|
||||
- **Stream variant:** raw halves (`AsyncRead`/`AsyncWrite`) — the
|
||||
- **Stream variant:** raw halves (`AsyncRead`/`AsyncWrite`); the
|
||||
halves ARE the tunnel.
|
||||
- **Datagram variant:** `send_datagram`/`recv_datagram` over the
|
||||
mandatory codec (ADR-003) — boundary-preserving, `len=0` is an
|
||||
mandatory codec (ADR-003), which is boundary-preserving; `len=0` is an
|
||||
empty datagram, `recv_datagram` returns `None` only on stream EOF.
|
||||
- **Pump ownership:** the session's `pump_against(accepted_halves)`
|
||||
(the reverse-flow use) spawns `pump_bidi` and holds the returned
|
||||
`JoinHandle`. For the pure consumer (no local pump — the session
|
||||
`JoinHandle`. For the pure consumer (no local pump; the session
|
||||
halves ARE handed to the caller), the session does not spawn; the
|
||||
caller drives the halves and the session still owns the channel
|
||||
entry.
|
||||
- **Teardown API (the point of the ADR):**
|
||||
- `close(self)` — abort the pump (if session-owned), tear down the
|
||||
- `close(self)`: abort the pump (if session-owned), tear down the
|
||||
adopted channel (`teardown_channel`), consume the session. The
|
||||
abort path (ungraceful).
|
||||
- `join(self)` — await pump completion (both directions finished),
|
||||
- `join(self)`: await pump completion (both directions finished),
|
||||
THEN reap the adopted channel, return the `(u64, u64)` copy
|
||||
counts from `pump_bidi` for observability. The graceful path.
|
||||
- Dropping the session without either: the `Drop` impl tears the
|
||||
channel down (the panic-free fallback — never leak the entry).
|
||||
channel down (the panic-free fallback, never leaking the entry).
|
||||
`Drop` cannot await, so it aborts the pump and calls the
|
||||
sync `teardown_channel`; this is semantically `close`.
|
||||
- **Half-open semantics fall out of `pump_bidi`** (ADR-050): one
|
||||
@@ -81,7 +81,7 @@ its teardown:
|
||||
crosses the mux) → the other pump keeps running until its own EOF.
|
||||
The session's `join` completes when BOTH pumps finish. Half-close
|
||||
semantics validated end-to-end (reverse POC W4).
|
||||
- **Error surface:** a failed open resolves a typed error —
|
||||
- **Error surface:** a failed open resolves a typed error:
|
||||
`ChannelOpenError::CallFailed` carrying the wire `CallError`;
|
||||
branch on `establishment_reason()` for `channel:open_failed`'s
|
||||
reason (ADR-049 §4). A failed open never yields a session (no
|
||||
@@ -92,19 +92,19 @@ its teardown:
|
||||
|
||||
## Consequences
|
||||
|
||||
### Amendment (2026-09-08 — the N-3 datagram `take_halves` posture, decided before the first external consumer)
|
||||
### Amendment (2026-09-08, the N-3 datagram `take_halves` posture, decided before the first external consumer)
|
||||
|
||||
Review 001 N-3: a datagram session exposes codec-applied accessors
|
||||
(`send_datagram`/`recv_datagram`) until `take_halves` — then the
|
||||
(`send_datagram`/`recv_datagram`) until `take_halves`, at which point the
|
||||
caller owns RAW halves whose wire bytes carry the `[len: u16 BE]`
|
||||
framing. Documented, but a footgun asymmetry the API window
|
||||
(this ADR's surface becomes ABI-stable at the first external
|
||||
consumer) forced a decision on. Decided: **option 1 — keep as-is,
|
||||
carried by docs**, with the rationale:
|
||||
consumer) forced a decision on. Decided: **option 1 (keep as-is,
|
||||
carried by docs)**, with the rationale:
|
||||
|
||||
- **No external consumer exists** (verified 2026-09-08 — no crate in
|
||||
- **No external consumer exists** (verified 2026-09-08; no crate in
|
||||
the workspace depends on alktunnels); the window is open, so a
|
||||
breaking reshape was *available* — but nothing yet demonstrates a
|
||||
breaking reshape was *available*, but nothing yet demonstrates a
|
||||
need for a different shape.
|
||||
- **The framing asymmetry is ADR-003's spec, not drift.** The
|
||||
datagram data plane is mandatory-framing by substrate
|
||||
@@ -113,23 +113,23 @@ carried by docs**, with the rationale:
|
||||
boundary." `take_halves` yielding raw halves keeps ONE semantic
|
||||
across substrates (the tunnel's wire bytes, uninterpreted); a
|
||||
per-substrate `take_halves` (option 3) diverges semantics by
|
||||
substrate — the worse confusion at the type level even as it
|
||||
substrate (the worse confusion at the type level even as it
|
||||
removes the byte-level footgun.
|
||||
- **`pump_against` needs the framed adapter, not framed halves.** The
|
||||
adjacent seam (N-11, resolved in-review with the doc note): a UDP
|
||||
session pumped against an accepted local half requires the FRAMED
|
||||
adapter on the accepted side — the `local` feature's `UdpHalf` (or
|
||||
adapter on the accepted side, namely the `local` feature's `UdpHalf` (or
|
||||
an assembly equivalent), not a raw socket. What composes there is
|
||||
the adapter AT the boundary, not a session-side wrapper pair; a
|
||||
`take_framed_halves` convenience would not feed this use.
|
||||
- **The convenience is additive and can land later.** Option 2's
|
||||
`take_framed_halves` breaks nothing when added after the first
|
||||
consumer (unlike a `take_halves` reshape), so its timing is
|
||||
decoupled from this window — deferring it costs nothing now, and
|
||||
decoupled from this window, so deferring it costs nothing now, and
|
||||
the first real consumer's reach defines its shape (a
|
||||
`DatagramReader`-wrapped pair has an open design question: an
|
||||
`AsyncRead` face re-collides with the F-2 empty-datagram/EOF
|
||||
ambiguity the codec exists to prevent — the codec-side framing
|
||||
ambiguity the codec exists to prevent: the codec-side framing
|
||||
wants accessor-shaped surface, which the session already has).
|
||||
- **The docs carry the contract.** `take_halves`'s doc states the
|
||||
framing (landed with the implementation); consumer.md's datagram
|
||||
@@ -138,14 +138,14 @@ carried by docs**, with the rationale:
|
||||
**Consequence:** datagram callers wanting codec-applied I/O after
|
||||
taking ownership should keep the session (drive `send_datagram`/
|
||||
`recv_datagram`; the session IS the owner) or compose `UdpHalf` at
|
||||
their own boundary — `take_halves` is the raw-wire escape hatch and
|
||||
their own boundary; `take_halves` is the raw-wire escape hatch and
|
||||
stays documented as such. Revisit only on a demonstrated consumer
|
||||
need (file an OQ at that point).
|
||||
|
||||
### Consequences (original)
|
||||
|
||||
- **Assembly layers cannot leak adopted channels** by forgetting to
|
||||
reap — the type is the discipline (the W3 gap closes structurally).
|
||||
reap; the type is the discipline (the W3 gap closes structurally).
|
||||
- **The reverse-flow initiator gets the same session shape** as the
|
||||
forward consumer: the reverse POC's hub-side `ReverseTunnel` is the
|
||||
seed; the spec generalizes it so `-L` and `-R` consumers share one
|
||||
@@ -156,7 +156,7 @@ need (file an OQ at that point).
|
||||
already wrapper-managed (R-02); the session only fixes the adopter
|
||||
asymmetry.
|
||||
- **`Drop`-based teardown is best-effort** (abort + sync reap, no
|
||||
await) — the documented contract; graceful flows call `join` or
|
||||
await): the documented contract; graceful flows call `join` or
|
||||
`close` explicitly.
|
||||
|
||||
## References
|
||||
@@ -165,7 +165,7 @@ need (file an OQ at that point).
|
||||
finding), §W4 (half-close validation)
|
||||
- OQ-TN-03, OQ-TN-04, OQ-TN-09 (promoted)
|
||||
- alkcall ADR-049 + review 007 R-02 (wrapper-managed serving side),
|
||||
ADR-050 (`pump_bidi` + copy counts), ADR-047 §5 (allocation — the
|
||||
ADR-050 (`pump_bidi` + copy counts), ADR-047 §5 (allocation; the
|
||||
initiator adopts)
|
||||
- AGENTS.md convention 10 (limits inherited — the session adds no
|
||||
- AGENTS.md convention 10 (limits inherited; the session adds no
|
||||
second bookkeeping layer; it owns exactly one channel's lifecycle)
|
||||
@@ -1,8 +1,8 @@
|
||||
# ADR-006: Access-Control Posture — the Open Gate Is the Security Boundary
|
||||
# ADR-006: Access-Control Posture: the Open Gate Is the Security Boundary
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-07; records the OQ-TN-08 resolution as a decision —
|
||||
Accepted (2026-09-07; records the OQ-TN-08 resolution as a decision;
|
||||
the posture was resolved in Phase 0 but the consumer-visible constants
|
||||
had no ADR backing); amended 2026-09-16 (scope separation for the
|
||||
direct/forwarded ops + the hub re-produce policy sentence, see
|
||||
@@ -11,7 +11,7 @@ Amendment 1)
|
||||
## Context
|
||||
|
||||
Tunnels reach local networks: opening a tunnel channel instructs the
|
||||
producer to dial (or accept for) a target — potentially anything the
|
||||
producer to dial (or accept for) a target, potentially anything the
|
||||
producer's host can reach. The open gate is therefore the security
|
||||
boundary of this crate, in the same sense that SSH's
|
||||
`AllowTcpForwarding`/`PermitOpen` govern `direct-tcpip` opens. The
|
||||
@@ -20,13 +20,13 @@ this crate vs alkcall's op-level ACL vs the assembly layer.
|
||||
|
||||
Phase 0 resolved the conceptual tangle (2026-09-05, the OQ-TN-08
|
||||
resolution): at protocol-crate level, a produced resource belongs to
|
||||
the far side of the connection — the protocol works under that
|
||||
the far side of the connection, and the protocol works under that
|
||||
assumption, so the ACL story is exactly alkcall's existing op-level
|
||||
ACL. No new policy layer. The hub/overlay mechanism (workers
|
||||
connecting in, the hub re-exposing resources per ITS policy) is a
|
||||
downstream, assembly-layer concern (OQ-TN-12's scope).
|
||||
|
||||
What was left implicit — and needs pinning, because it is
|
||||
What was left implicit, and needs pinning because it is
|
||||
consumer-visible and stable-once-published:
|
||||
|
||||
- The scope constant governing tunnel opens (`tunnel:open`) is
|
||||
@@ -39,10 +39,10 @@ consumer-visible and stable-once-published:
|
||||
## Decision
|
||||
|
||||
1. **The open gate is the boundary, and it is alkcall's ACL.** The
|
||||
open op's `AccessControl` rides `ChannelCore::register_openable` —
|
||||
open op's `AccessControl` rides `ChannelCore::register_openable`;
|
||||
the registry runs the ACL before the establisher. The base gate is
|
||||
the scope constant **`TUNNEL_OPEN_SCOPE = "tunnel:open"`**
|
||||
(required scope, exact string — stable once published; consumers
|
||||
(required scope, exact string; stable once published, and consumers
|
||||
will request grants by it). An identity without the scope is
|
||||
`FORBIDDEN` before any establisher code runs.
|
||||
2. **The scope string is wire/ACL-stable** from the first consumer:
|
||||
@@ -51,7 +51,7 @@ consumer-visible and stable-once-published:
|
||||
3. **No target allowlists and no tunnel-specific policy machinery in
|
||||
v1.** The resource is owned by the far side (the Phase 0
|
||||
resolution); a producer that wants finer granularity wires
|
||||
`OwnershipProvider` checks into ITS establisher — the protocol
|
||||
`OwnershipProvider` checks into ITS establisher; the protocol
|
||||
provides the seam (the establisher sees the per-call opener
|
||||
identity, alkcall 0.7.0 CF-006), not the policy. This mirrors
|
||||
alktty's posture (`TTY_OPEN_SCOPE` + op-level ACL, no
|
||||
@@ -80,17 +80,17 @@ consumer-visible and stable-once-published:
|
||||
## Consequences
|
||||
|
||||
- **One security knob per producer deployment** (the op-level ACL +
|
||||
scope grants) instead of a tunnel-specific policy language — the
|
||||
accepted simplicity; per-resource granularity is an establisher
|
||||
scope grants) instead of a tunnel-specific policy language, which is
|
||||
the accepted simplicity; per-resource granularity is an establisher
|
||||
customization (the seam), not a v1 surface.
|
||||
- **The scope constant is a compatibility commitment:** treat
|
||||
`tunnel:open` like the ALPN string. Changing it after consumers
|
||||
request grants is a breaking ACL change.
|
||||
- **No allowlist concept** — a wildcard-egress producer
|
||||
- **No allowlist concept**: a wildcard-egress producer
|
||||
(`0.0.0.0/0`-style egress) is expressed by registering a resource
|
||||
whose backing is that egress; gating it is the assembly layer's
|
||||
ownership check at the seam. The protocol stays out of the business.
|
||||
- **Tunnels reach local networks** — producers MUST treat the gate as
|
||||
- **Tunnels reach local networks**, so producers MUST treat the gate as
|
||||
the security boundary; the spec docs state this posture (producer.md).
|
||||
|
||||
## References
|
||||
@@ -99,7 +99,7 @@ consumer-visible and stable-once-published:
|
||||
- alkcall ADR-047 (open ops + ACL wiring), ADR-017 §7 (the
|
||||
`auth_token` path), ledger CF-005/CF-006 (identity precedence,
|
||||
per-call opener); alknet ADR-024/ADR-050 (registry layering,
|
||||
ownership — the alkcall ports ADR-019/ADR-011)
|
||||
ownership; the alkcall ports ADR-019/ADR-011)
|
||||
- alktty's `TTY_OPEN_SCOPE` posture (the sibling precedent)
|
||||
- AGENTS.md convention 13 (access control)
|
||||
|
||||
@@ -109,19 +109,19 @@ The posture stands; the scope space grows with the ops (ADR-007,
|
||||
ADR-008):
|
||||
|
||||
- **`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`** gates
|
||||
`channels/tunnel/direct` — dynamic-target egress. **Never implied by
|
||||
`channels/tunnel/direct` for dynamic-target egress. **Never implied by
|
||||
`tunnel:open`**: "dial `postgres-primary`" and "dial anything from
|
||||
my network" are different capabilities, and the separation is
|
||||
structural (scopes attach to op specs, not params). A deployment
|
||||
granting both grants both explicitly.
|
||||
- **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`** gates
|
||||
`channels/tunnel/forwarded` on the opener side — the grant for
|
||||
`channels/tunnel/forwarded` on the opener side; it is the grant for
|
||||
RECEIVING producer-initiated forwarded connections (ingress).
|
||||
Ingress and egress are different grants (the alksocks POC #5
|
||||
`socks5:bind` gate proved the distinction matters in practice).
|
||||
- **Hub re-produce policy sentence (the security model):** 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
|
||||
re-produce hop; the hub's ACL, not the original opener's, governs
|
||||
the final dial. The hub is the network boundary; the original
|
||||
opener's grant gets it to the hub, the hub's own grant decides what
|
||||
it will dial on the far side. Correct-by-default; pinned here
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR-007: The Direct Open Op — Dynamic-Target Egress
|
||||
# ADR-007: The Direct Open Op (Dynamic-Target Egress)
|
||||
|
||||
## Status
|
||||
|
||||
@@ -11,7 +11,7 @@ the backing; rich addressing rides the `-D`/dynamic composition path,
|
||||
never the wire. That decision stands. The use case that justifies an
|
||||
*additive* revisit is the one the alksocks graduation research
|
||||
(`docs/research/tunnels-graduation.md`) named: a consumer asking the
|
||||
producer to dial an arbitrary target chosen at session time — ssh's
|
||||
producer to dial an arbitrary target chosen at session time, i.e. ssh's
|
||||
`direct-tcpip` shape, SOCKS5 CONNECT's egress, and the vpn-like
|
||||
dial-callback translation all need it. A dynamic target is a different
|
||||
capability from opening a named resource, and ssh carries it as its own
|
||||
@@ -19,7 +19,7 @@ channel type precisely because the two capabilities must not share one
|
||||
grant.
|
||||
|
||||
The capability is dangerous in exactly the way ADR-001's "resource, not
|
||||
address" framing protects against — arbitrary egress from the
|
||||
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" must not be the same grant.
|
||||
@@ -27,7 +27,7 @@ The alksocks POC #5 scope gate (`socks5:bind`) proved the pattern
|
||||
matters in practice.
|
||||
|
||||
Timing: alksocks is the first real consumer and nothing is published
|
||||
against the direct shape yet — this is the correct moment to pin the
|
||||
against the direct shape yet, so this is the correct moment to pin the
|
||||
wire-stable surface.
|
||||
|
||||
## Decision
|
||||
@@ -35,19 +35,19 @@ wire-stable surface.
|
||||
### 1. One ALPN, a second open op
|
||||
|
||||
The direct op rides the same `alk/tunnel` ALPN (ADR-002 stands: one
|
||||
ALPN, one protocol identity — SSH's one-transport-many-channel-types
|
||||
shape). The op id is **`channels/tunnel/direct`** — the flavor-form
|
||||
ALPN, one protocol identity, i.e. SSH's one-transport-many-channel-types
|
||||
shape). The op id is **`channels/tunnel/direct`**, the flavor-form
|
||||
op-id convention (ADR-002 Amendment 1): `channels/<alpn>/<flavor>`,
|
||||
`Sub`-typed, same establishment phase and wrapper as `…/sub`. An older
|
||||
producer rejects the unknown op loudly (registry lookup fails — the
|
||||
producer rejects the unknown op loudly (registry lookup fails, which is the
|
||||
SSH "unknown channel type" posture, `NOT_FOUND`).
|
||||
|
||||
The op-id convention itself needed an upstream alkcall amendment
|
||||
(ADR-047 amendment: discovery's ALPN derivation and the
|
||||
`rebuild_spec_for` marker path recognize flavor-form op ids; hub relay
|
||||
reconstructs them). Runtime was never blocked — registration sets the
|
||||
reconstructs them). Runtime was never blocked, since registration sets the
|
||||
ALPN explicitly and `open_channel(op_id, params, alpn)` takes the ALPN
|
||||
as an argument — but discovery + hub relay are the re-produce path
|
||||
as an argument; discovery + hub relay are the re-produce path
|
||||
(alknodes, OQ-TN-12), so the convention is pinned upstream before the
|
||||
first consumer. **Landed in alkcall 0.8.0** (review 008 U-1, ADR-047
|
||||
amendment 3).
|
||||
@@ -61,27 +61,27 @@ amendment 3).
|
||||
}
|
||||
```
|
||||
|
||||
- **`substrate`** (string, required) — same discriminator semantics as
|
||||
- **`substrate`** (string, required): same discriminator semantics as
|
||||
ADR-001: `"tcp"` | `"udp"` | `"unix"` in v1; selects the data-plane
|
||||
framing (ADR-003). Enum-validated; unknown values fail schema
|
||||
validation loudly.
|
||||
- **`target`** (object, required) — a self-describing JSON object keyed
|
||||
- **`target`** (object, required): a self-describing JSON object keyed
|
||||
by the sibling `substrate` field:
|
||||
- `tcp` → `{ "host": <string>, "port": <u16> }` — `host` is a
|
||||
- `tcp` → `{ "host": <string>, "port": <u16> }`, where `host` is a
|
||||
hostname, IPv4/IPv6 literal, or the JSON form of
|
||||
`SocketAddr`-shaped values; **domain-form targets stay unresolved
|
||||
on the wire** (producer-side resolution — the ssh `-D` semantic;
|
||||
on the wire** (producer-side resolution, the ssh `-D` semantic;
|
||||
both POCs validated egress-side resolution). No DNS dependency on
|
||||
the consumer.
|
||||
- `udp` → `{ "host": <string>, "port": <u16> }` — the connected-UDP
|
||||
- `udp` → `{ "host": <string>, "port": <u16> }`: the connected-UDP
|
||||
destination (endpoint-at-open; ADR-003 framing applies as for base
|
||||
udp).
|
||||
- `unix` → `{ "path": <string> }` — the socket path on the
|
||||
- `unix` → `{ "path": <string> }`: the socket path on the
|
||||
producer's filesystem.
|
||||
- The schema pins both fields required; the target sub-schema is
|
||||
per-substrate (an `allOf` of two `if`/`then` branches keyed on the
|
||||
sibling `substrate` value, validated **registry-side** — the schema
|
||||
gate runs before the establisher; the establisher's serde parse
|
||||
sibling `substrate` value, validated **registry-side**: the schema
|
||||
gate runs before the establisher; the establisher's serde parse
|
||||
re-checks as the backstop; see the Erratum below and Consequences).
|
||||
Malformed targets are the typed `invalid_input`-class failure;
|
||||
**`unknown_resource` can never fire on the direct op** (there is no
|
||||
@@ -97,16 +97,16 @@ amendment 3).
|
||||
*wire* encodes.
|
||||
- **Dial-only: no listen/direct-BIND form.** The reverse shape
|
||||
(`-R`-style ingress) is the listen-tunnel + the forwarded op
|
||||
(ADR-008) — a direct-BIND form would duplicate its capability with
|
||||
(ADR-008); a direct-BIND form would duplicate its capability with
|
||||
none of its metadata fidelity.
|
||||
|
||||
### 3. Scope: `TUNNEL_DIRECT_SCOPE = "tunnel:direct"`
|
||||
|
||||
The op's `AccessControl.required_scopes` is `["tunnel:direct"]` — a
|
||||
The op's `AccessControl.required_scopes` is `["tunnel:direct"]`, a
|
||||
separate grant from `tunnel:open`, never implied by it. "Dial
|
||||
`postgres-primary`" and "dial anything from my network" are different
|
||||
capabilities; the ACL separation is structural (scopes attach to op
|
||||
specs, not params — ADR-006 §2's additivity rule). Scope strings are
|
||||
specs, not params, per ADR-006 §2's additivity rule). Scope strings are
|
||||
ACL-stable once published (ADR-006 §2): renames are breaking; new
|
||||
gates are new scopes on new ops.
|
||||
|
||||
@@ -115,13 +115,13 @@ gates are new scopes on new ops.
|
||||
Same establisher contract as the dial shape (producer.md), minus the
|
||||
lookup:
|
||||
|
||||
1. Parse and validate `target` against `substrate` (a mismatch —
|
||||
`unix` without `path`, `tcp` with a bad port — is `invalid_input`
|
||||
1. Parse and validate `target` against `substrate` (a mismatch, such as
|
||||
`unix` without `path` or `tcp` with a bad port, is `invalid_input`
|
||||
class).
|
||||
2. Inject the parsed `TunnelTarget` into the assembly-provided
|
||||
`DialFn` (the same halves function the base op's registry backings
|
||||
resolve to; ADR-004 — the protocol never sees a socket type).
|
||||
3. Return the dialed handle via `Establishment::new(plan)` — unchanged
|
||||
resolve to; ADR-004, which is why the protocol never sees a socket type).
|
||||
3. Return the dialed handle via `Establishment::new(plan)`: unchanged
|
||||
plan-flow.
|
||||
|
||||
Error mapping (the dial table, minus the registry row):
|
||||
@@ -163,12 +163,12 @@ sentence (also ADR-006 amendment).
|
||||
a target object must be optional-with-default.
|
||||
- **`SubstrateAddr`'s untagged leniency is schema-gated** (review 002
|
||||
N-4): untagged serde variants cannot `deny_unknown_fields`, so a
|
||||
cross-shape target (e.g. `{path, host}`) parses at the serde layer —
|
||||
the SCHEMA's `additionalProperties: false` per branch is the real
|
||||
cross-shape target (e.g. `{path, host}`) parses at the serde layer;
|
||||
the SCHEMA's `additionalProperties: false` per branch is the real
|
||||
shape-vs-substrate gate. One caution for future registry variants: a
|
||||
schema-less registry would leave the establisher's serde backstop as
|
||||
the only gate, and it keys `render_target` off the sibling
|
||||
`substrate` — a cross-shape target that sneaked past would render
|
||||
`substrate`; a cross-shape target that sneaked past would render
|
||||
*something* (`/p` for the unix case) rather than fail; the serde
|
||||
backstop renders the sibling-keyed target, so cross-shape garbage is
|
||||
the schema's absence, not a serde failure. If this crate is ever
|
||||
@@ -177,15 +177,15 @@ sentence (also ADR-006 amendment).
|
||||
- **`SubstrateAddr` must not grow overlapping untagged variants.** If
|
||||
a future variant's shapes overlap (e.g. a `{host, port, flowlabel}`
|
||||
form), untagged disambiguation starts ordering (first-match wins
|
||||
becomes load-bearing and silent) — switch to an internally-tagged
|
||||
or untagged-with-content form at that point, deliberately (an ADR
|
||||
line), never by accident. Pre-consumer and additive until then.
|
||||
- **The BAST question is moot** — params is JSON inside the channels
|
||||
becomes load-bearing and silent), which means switching to an internally-tagged
|
||||
or untagged-with-content form at that point, deliberately (an ADR
|
||||
line), never by accident. Pre-consumer and additive until then.
|
||||
- **The BAST question is moot**: params is JSON inside the channels
|
||||
open op; the data plane is unchanged (raw pass-through for tcp/unix,
|
||||
the `[len: u16 BE]` codec for udp — ADR-003).
|
||||
the `[len: u16 BE]` codec for udp per ADR-003).
|
||||
- **Discovery exposure**: the direct op's `input_schema` carries
|
||||
`{substrate, target}`, so a discovery listing reveals that dynamic
|
||||
egress exists and of which substrates — acceptable; the target
|
||||
egress exists and of which substrates; that is acceptable, since the target
|
||||
values (what was dialed) never enter discovery (they ride per-call
|
||||
params). A producer wanting to hide the capability entirely simply
|
||||
does not register the op (and old producers reject it loudly anyway).
|
||||
@@ -196,7 +196,7 @@ sentence (also ADR-006 amendment).
|
||||
direct op itself is protocol-layer (default crate, wasm-clean).
|
||||
- **SOCKS5 CONNECT composes directly:** the alksocks adapter's
|
||||
`DialFn` seam maps the SOCKS5 CONNECT target (ATYP-decoded) onto
|
||||
`open_direct` — the graduation's §4 table, now with the stand-in
|
||||
`open_direct`, which is the graduation's §4 table with the stand-in
|
||||
dissolved.
|
||||
|
||||
## Erratum (2026-09-20, review 002 D-2)
|
||||
@@ -204,7 +204,7 @@ sentence (also ADR-006 amendment).
|
||||
§2's original wording said the target sub-schema is "`oneOf` on the
|
||||
sibling `substrate` value, validated at establisher time". Both
|
||||
halves of that sentence were stale against the shipped schema
|
||||
(the decision itself was not — the code is the wire-stable side and
|
||||
(the decision itself was not; the code is the wire-stable side and
|
||||
is correct):
|
||||
|
||||
1. **The shipped structure is an `allOf` of two `if`/`then`
|
||||
@@ -225,14 +225,14 @@ is correct):
|
||||
## References
|
||||
|
||||
- `docs/research/tunnels-graduation.md` §2 (the ask, options, and the
|
||||
lean) — the design discussion this ADR records
|
||||
lean): the design discussion this ADR records
|
||||
- alksocks `poc-bind-findings.md` (the scope-gate precedent) and
|
||||
`poc-connect-wasm-findings.md` (the dial-at-command-read layering)
|
||||
- alkcall ADR-047 (open ops + `input_schema` + discovery marker),
|
||||
ADR-049 (establishment/typed errors), Gap F (op-name → ALPN
|
||||
derivation — the flavor-form amendment target)
|
||||
derivation, the flavor-form amendment target)
|
||||
- alktunnels ADR-001 (the resource-params invariant the direct op
|
||||
extends), ADR-002 Amendment 1 (flavor-form op ids), ADR-006
|
||||
(scope posture + the hub-policy amendment)
|
||||
- AGENTS.md conventions 9 (no forced binding — dial-only, no listen
|
||||
- AGENTS.md conventions 9 (no forced binding; dial-only, no listen
|
||||
form), 13 (access control), 17 (ALPN naming)
|
||||
@@ -1,21 +1,23 @@
|
||||
# ADR-008: Listen Metadata — Bind-First Establisher and the Forwarded Op
|
||||
# ADR-008: Listen Metadata: Bind-First Establisher and the Forwarded Op
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-16); amended 2026-09-18 (the correlation token —
|
||||
`listen` reply field + forwarded-params key, see Amendment 1)
|
||||
Accepted (2026-09-16); amended 2026-09-18 (the correlation token,
|
||||
the `listen` reply field + forwarded-params key, see Amendment 1); amended
|
||||
2026-11-03 (the forwarded op's serving-side establishment phase + the
|
||||
correlation-miss vocabulary, see Amendment 2)
|
||||
|
||||
## Context
|
||||
|
||||
alksocks' BIND POC (`poc-bind-findings.md` in the alksocks repo) pinned
|
||||
two upstream asks for composed (`-R`-style) BIND fidelity:
|
||||
|
||||
1. **Bound-addr discovery without consuming an accept** — reply#1's
|
||||
1. **Bound-addr discovery without consuming an accept**: reply#1's
|
||||
`BND.ADDR` must precede the app server's dial, but the listen
|
||||
establisher is accept-first: its establishment phase pops an
|
||||
already-accepted connection, so the open cannot complete (and
|
||||
cannot report a bound address) until the app server dials.
|
||||
2. **Peer metadata on accepted handles** — reply#2's peer address is
|
||||
2. **Peer metadata on accepted handles**: reply#2's peer address is
|
||||
lost through the composed path; `TargetHandle` carries boxed halves
|
||||
only, so the composed variant reports the sentinel.
|
||||
|
||||
@@ -23,8 +25,8 @@ The graduation research (`docs/research/tunnels-graduation.md` §3)
|
||||
framed the option space: per-ask carriers (a reply-metadata projection
|
||||
plus one of three addr-discovery shapes) versus ssh's
|
||||
`forwarded-tcpip` shape as the combined alternative. Decision: the
|
||||
`forwarded-tcpip` shape — **bind-first establisher + accept-as-open**
|
||||
— because it resolves both asks *and* two structural problems the POC
|
||||
`forwarded-tcpip` shape (**bind-first establisher + accept-as-open**),
|
||||
because it resolves both asks *and* two structural problems the POC
|
||||
recorded, with zero special carriers:
|
||||
|
||||
- The address-first-vs-accept-first **deadlock dissolves** instead of
|
||||
@@ -33,9 +35,9 @@ recorded, with zero special carriers:
|
||||
OS-chosen bound address (ask #1 solved natively).
|
||||
- The **10s establishment-bound problem dissolves**: the accept wait
|
||||
moves out of the establishment phase into the pump-handler phase
|
||||
(whose lifetime is teardown-governed, not establishment-bounded) —
|
||||
FTP-style long BIND waits stop resolving as spurious refusals.
|
||||
- Ask #2 (peer metadata) rides the reverse channel's open-op params —
|
||||
(whose lifetime is teardown-governed, not establishment-bounded), so
|
||||
FTP-style long BIND waits stop resolving as spurious refusals.
|
||||
- Ask #2 (peer metadata) rides the reverse channel's open-op params:
|
||||
the wire's native "this connection is for X" moment.
|
||||
|
||||
All the machinery is POC-validated: `open_reverse_channel` +
|
||||
@@ -48,18 +50,18 @@ over a serving connection (the reverse POC's hub shape).
|
||||
### 1. Third establisher shape: bind-first
|
||||
|
||||
The listen establisher gains a bind-first variant alongside the
|
||||
accept-first shape (which stays — see §5):
|
||||
accept-first shape (which stays; see §5):
|
||||
|
||||
- The establisher **binds the listener** (via the assembly-provided
|
||||
listen function) and returns `Establishment::new(plan)` carrying the
|
||||
listener handle. Establishment is fast — bounded by the bind, not by
|
||||
listener handle. Establishment is fast, bounded by the bind, not by
|
||||
any app-server dial.
|
||||
- **The establisher mints a correlation token** (Amendment 1): a fresh
|
||||
u32 unique per open on this producer, carried twice — inside the
|
||||
u32 unique per open on this producer, carried twice: inside the
|
||||
plan (the pump handler needs it to correlate per-accept forwarded
|
||||
opens) and as the additive `listen` reply field (§2). The plan also
|
||||
carries the listen tunnel's substrate, stamped at establisher time
|
||||
from the one successful params parse — the accept dispatcher reads
|
||||
from the one successful params parse; the accept dispatcher reads
|
||||
it from the plan and stamps it into each forwarded open's
|
||||
`substrate` param; it never re-parses wire input (review 002 M-1:
|
||||
the removed silent `Tcp` fallback). The original
|
||||
@@ -69,24 +71,25 @@ accept-first shape (which stays — see §5):
|
||||
a generic hub relay forwards params untouched (it strips/rewrites
|
||||
only the reply's `channel_id`), so a spoke's forwarded open would
|
||||
carry an id the end opener never allocated. A producer-minted token
|
||||
rides both hops untouched — reply fields pass through relays
|
||||
rides both hops untouched, since reply fields pass through relays
|
||||
per-hop-truthful (alkcall ADR-051), and the same minted token
|
||||
appears in every hop's reply.
|
||||
- The establisher MAY contribute the observed bound address to the
|
||||
open-op reply (§2): `"bound": { ... }` — the real OS-chosen address
|
||||
open-op reply (§2): `"bound": { ... }`, which is the real OS-chosen address
|
||||
(port `0` resolved), substrate-shaped like the direct op's target
|
||||
(`{host, port}` for tcp/udp, `{path}` for unix).
|
||||
- The pump handler for this shape is an **accept dispatcher**, not
|
||||
`pump_bidi`: it accepts the channel's `BiStream` (the R-02 contract)
|
||||
and runs the assembly-provided accept loop concurrently — each
|
||||
accepted connection produces a forwarded channel (§3); the channel's
|
||||
own `BiStream` stays idle and its EOF (the opener dropping the
|
||||
listener session) cancels the loop and tears the listener down. The
|
||||
idle-read check is deliberately WIDER than a bare EOF comparison
|
||||
(review 002 N-2): the watch treats ANY read as teardown — `Ok(0)`
|
||||
(the EOF sentinel), an error, **and** any non-empty read (a peer
|
||||
that writes to the idle listen channel is misbehaving; the channel
|
||||
is not a data plane — fail closed). An implementer reading only
|
||||
and runs the assembly-provided accept loop concurrently; each
|
||||
accepted connection produces a forwarded channel (§3); the channel's
|
||||
own `BiStream` stays idle and its EOF (the opener dropping the
|
||||
listener session) cancels the loop and tears the listener down. The
|
||||
idle-read check is deliberately WIDER than a bare EOF comparison
|
||||
(review 002 N-2): the watch treats ANY read as teardown, whether
|
||||
`Ok(0)`
|
||||
(the EOF sentinel), an error, **or** any non-empty read (a peer
|
||||
that writes to the idle listen channel is misbehaving; the channel
|
||||
is not a data plane, so fail closed). An implementer reading only
|
||||
"its EOF cancels the loop" and checking `Ok(0)` alone would hang on
|
||||
a write-happy peer; the shipped posture is the any-byte one,
|
||||
pinned by the write-happy-peer test.
|
||||
@@ -100,42 +103,42 @@ The open-op reply gains an **optional, additive** field:
|
||||
```
|
||||
|
||||
- Present only when the establisher supplies it (bind-first listeners);
|
||||
absent for dial establishers — old consumers ignore unknown reply
|
||||
absent for dial establishers; old consumers ignore unknown reply
|
||||
fields (plain JSON, alkcall `ResponseEnvelope`), so the change is
|
||||
wire-additive.
|
||||
- The carrier is an upstream alkcall ask (ADR-049 amendment 3:
|
||||
establisher → reply projection — `Establishment` gains optional
|
||||
establisher → reply projection, with `Establishment` gaining optional
|
||||
reply fields the wrapper merges into the success output; the
|
||||
`channel_id` key is wrapper-reserved). Filed in
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`.
|
||||
**Landed in alkcall 0.8.0** (review 008 U-2, ADR-049 amendment 3:
|
||||
`Establishment::with_reply_field` + the wrapper merge with the
|
||||
reserved-key guard).
|
||||
- **`listen` — the correlation token (Amendment 1).** A bind-first
|
||||
- **`listen`: the correlation token (Amendment 1).** A bind-first
|
||||
establisher also contributes `"listen": <u32>`: the token the
|
||||
forwarded op's params key on (§3, as amended). Same projection
|
||||
carrier, same additive posture — a second reply field on the same
|
||||
opens that carry `bound`. `bound` and `listen` are independent
|
||||
fields (an accept-first establisher could supply `listen`-shaped
|
||||
correlation too — the accept queue's establisher mints its own —
|
||||
but v1 pins the pair on bind-first only).
|
||||
carrier, same additive posture: a second reply field on the
|
||||
same opens that carry `bound`. `bound` and `listen` are independent
|
||||
fields (an accept-first establisher could supply `listen`-shaped
|
||||
correlation too, since the accept queue's establisher mints its
|
||||
own, but v1 pins the pair on bind-first only).
|
||||
- `bound` is informational surface, not addressing: a consumer MUST
|
||||
NOT treat it as a dial target (the producer owns the backing —
|
||||
ADR-001's invariant). It exists for RFC-1928-reply-class fidelity
|
||||
NOT treat it as a dial target (the producer owns the backing,
|
||||
per ADR-001's invariant). It exists for RFC-1928-reply-class fidelity
|
||||
(BND.ADDR) and observability.
|
||||
|
||||
### 3. The forwarded op: `channels/tunnel/forwarded`
|
||||
|
||||
When the listener accepts, the **producer opens a channel back toward
|
||||
the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
|
||||
the listen-opener**: accept-as-open, the `forwarded-tcpip` shape:
|
||||
|
||||
- Op id **`channels/tunnel/forwarded`** (flavor-form, ADR-002
|
||||
Amendment 1), `Sub`-typed, ALPN marker `alk/tunnel`. It is served by
|
||||
the **listen-opener side** (the initiator of the listen-tunnel
|
||||
open): the producer *calls* it per accept
|
||||
(`open_reverse_channel`-shaped — the call plane on channel 0), the
|
||||
(`open_reverse_channel`-shaped; the call plane on channel 0), the
|
||||
opener's registry allocates the `channel_id`, the producer adopts it
|
||||
(the ADR-047 §5 connection-owner rule — the serving side of the
|
||||
(the ADR-047 §5 connection-owner rule, i.e. the serving side of the
|
||||
forwarded call allocates; the producer-side initiator adopts) and
|
||||
pumps the accepted connection's halves against the adopted channel.
|
||||
- Params:
|
||||
@@ -148,19 +151,19 @@ the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
|
||||
}
|
||||
```
|
||||
|
||||
- **`listen`** (u32, required) — the correlation token the
|
||||
- **`listen`** (u32, required): the correlation token the
|
||||
bind-first establisher minted and returned in the listen open's
|
||||
reply (§2, as amended). The opener matches it against the
|
||||
`listen` field of its open listener sessions — a `HashMap<u32, ..>`
|
||||
on the opener side; no registry op, no extra wire field. The
|
||||
token space is the producer's: fresh per open, unique on that
|
||||
producer (the opener-side map is keyed by the producer's token,
|
||||
and one opener's map may hold tokens from several producers — a
|
||||
hash collision across producers is the opener's bookkeeping
|
||||
`listen` field of its open listener sessions, a `HashMap<u32, ..>`
|
||||
on the opener side; no registry op, no extra wire field. The
|
||||
token space is the producer's: fresh per open, unique on that
|
||||
producer (the opener-side map is keyed by the producer's token,
|
||||
and one opener's map may hold tokens from several producers; a
|
||||
hash collision across producers is the opener's bookkeeping
|
||||
choice, e.g. keying `(token)` uniquely or `(producer, token)`;
|
||||
v1 leaves it assembly-side). **Amends the original `channel`
|
||||
key** (the listen channel's ID): that semantic is unimplementable
|
||||
as pinned — the serving-side wrapper never exposes the allocated
|
||||
key** (the listen channel's ID): that semantic is unimplementable
|
||||
as pinned, since the serving-side wrapper never exposes the allocated
|
||||
channel ID to the establisher or handler (alkcall 0.8.0's
|
||||
`OpenHandler` surface), and generic hub relays forward params
|
||||
untouched, so a spoke's forwarded open would carry an id the end
|
||||
@@ -169,31 +172,35 @@ the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
|
||||
the reply field passes through per-hop truthful (alkcall ADR-051),
|
||||
and the forwarded open carries the same minted value at every
|
||||
hop.
|
||||
- **`substrate`** (string, required) — the listen tunnel's
|
||||
- **`substrate`** (string, required): the listen tunnel's
|
||||
substrate; shapes the forwarded channel's data-plane framing
|
||||
(ADR-003), same discriminator semantics as everywhere else.
|
||||
- **`peer`** (object, required) — the accepted connection's remote
|
||||
- **`peer`** (object, required): the accepted connection's remote
|
||||
address, substrate-shaped (`{host, port}` / `{path}`). This is ask
|
||||
#2's carrier: reply#2-class fidelity rides the open op, the wire's
|
||||
native metadata moment. Optional in kind, required in v1 — a
|
||||
native metadata moment. Optional in kind, required in v1: a
|
||||
producer that cannot know the peer (rare; all POC'd listeners
|
||||
can) omits... no: **required**; a producer that cannot determine
|
||||
the peer does not serve a bind-first listener over that substrate.
|
||||
- Scope: **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`** — the
|
||||
- Scope: **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`**, the
|
||||
opener-side grant for RECEIVING forwarded connections (ingress).
|
||||
Separate from `tunnel:open` and `tunnel:direct` (ADR-006 amendment):
|
||||
exposing a listener and dialing egress are different capabilities.
|
||||
- The opener-side serving shape: `register_openable_with_establisher`
|
||||
as-is (no establisher — the forwarded channel needs no
|
||||
establishment; its data plane starts immediately), ACL-gated by the
|
||||
scope, handler = correlate the open (via `listen`, the correlation
|
||||
token) to the waiting
|
||||
as-is (no establisher, because the forwarded channel needs no
|
||||
establishment; its data plane starts immediately; this clause was
|
||||
**amended by Amendment 2**: the served shape gains an establisher
|
||||
variant, the injected correlation gate; this sentence records the
|
||||
pre-amendment posture, which stays a valid shape per Amendment 2),
|
||||
ACL-gated by
|
||||
the scope, handler = correlate the open (via `listen`, the
|
||||
correlation token) to the waiting
|
||||
session, accept the `BiStream`, pump against the local BIND client's
|
||||
halves. The correlation registry is assembly-side bookkeeping —
|
||||
halves. The correlation registry is assembly-side bookkeeping;
|
||||
the protocol provides the op and the params, not the
|
||||
wait/quiet-state machine.
|
||||
- An older opener that does not serve the forwarded op rejects the
|
||||
producer's per-accept call loudly (`NOT_FOUND`) — the SSH posture.
|
||||
producer's per-accept call loudly (`NOT_FOUND`), the SSH posture.
|
||||
A producer serving bind-first SHOULD prefer openers that advertise
|
||||
the forwarded op (discovery, alkcall ADR-047).
|
||||
|
||||
@@ -205,7 +212,7 @@ let bound = listener.bound(); // the resolved ad
|
||||
// ... per forwarded accept (opener side, served op):
|
||||
// correlate by `listen`, accept the BiStream, pump against the
|
||||
// local session's halves
|
||||
listener.close(self); // or Drop — tears the listen channel + far listener down
|
||||
listener.close(self); // or Drop; tears the listen channel + far listener down
|
||||
```
|
||||
|
||||
`TunnelListener` is the listen-flow sibling of `TunnelSession`: it owns
|
||||
@@ -213,7 +220,7 @@ the listen channel's teardown (ADR-005's ownership posture extended to
|
||||
the listen flow); it presents `bound()` and the teardown API, and the
|
||||
forwarded channels themselves arrive as separate adopted-side
|
||||
`TunnelSession`s on the opener's manager. `TunnelSession::adopt` gains
|
||||
nothing — forwarded channels are ordinary adopted channels. The
|
||||
nothing; forwarded channels are ordinary adopted channels. The
|
||||
opener-side correlation registry (token → waiting listener session)
|
||||
is assembly-side bookkeeping (consumer.md's posture); the crate's
|
||||
`TunnelListener` type provides the `listen` token it needs to key it.
|
||||
@@ -225,7 +232,7 @@ remains a valid establisher shape for producers whose resource backing
|
||||
IS an accept queue (an externally-fed or pre-bound listener shared
|
||||
across opens). Bind-first is the shape for producers that own the
|
||||
bind decision (the common hub case; alksocks' composed BIND). The two
|
||||
shapes are per-registration assembly choices over the same op — no
|
||||
shapes are per-registration assembly choices over the same op; there is no
|
||||
wire distinction beyond `bound`'s presence.
|
||||
|
||||
## Consequences
|
||||
@@ -238,7 +245,7 @@ wire distinction beyond `bound`'s presence.
|
||||
(ADR-049 amendment 3) and the flavor-form discovery derivation
|
||||
(ADR-047 amendment) **landed in alkcall 0.8.0** (review 008, Units
|
||||
1–2); the in-tree `ChannelRelay` + `HubLegTemplate` (ADR-051) came
|
||||
with them — the hub re-produce machinery §6 assumed assembly-layer
|
||||
with them, so the hub re-produce machinery §6 assumed assembly-layer
|
||||
is now upstream, and the relay passes reply fields through per-hop
|
||||
truthful (which is what makes the Amendment 1 token compose through
|
||||
hubs with zero tunnel-specific relay behavior). The bind-first
|
||||
@@ -248,21 +255,21 @@ wire distinction beyond `bound`'s presence.
|
||||
carrier from the graduation doc is superseded: with accept-as-open,
|
||||
the peer rides the forwarded op's params; no process-local metadata
|
||||
field, no sentinel. (If a future non-forwarded accept shape needs
|
||||
metadata, revisit then — additive.)
|
||||
metadata, revisit then, and the change is additive.)
|
||||
- **The BIND reply#2 fidelity is end-to-end:** the opener-side
|
||||
correlation writes reply#2 with the REAL peer from the forwarded
|
||||
op's params. reply#1's `BND.ADDR` is the `bound` reply field. Both
|
||||
POC stand-ins (carried `bind_addr`; sentinel `0.0.0.0:0`) retire.
|
||||
- **Hub re-produce:** a hub re-producing a listen resource binds its
|
||||
own listener (bind-first, its own `bound` + minted `listen` token)
|
||||
and re-opens forwarded channels with the same token — the spoke's
|
||||
and re-opens forwarded channels with the same token, so the spoke's
|
||||
forwarded opens carry the hub's token untouched (params are not
|
||||
rewritten by the generic relay), and the metadata is per-hop
|
||||
truthful, matching alkcall ADR-042's terminate-and-re-produce
|
||||
posture. The end opener correlates on the hub's minted token — the
|
||||
posture. The end opener correlates on the hub's minted token, the
|
||||
value it read from its own open's reply.
|
||||
- **Local-variant BIND needs none of this** (the listener and the
|
||||
accept are in-process — POC #5 variant 1, unchanged); the asks and
|
||||
accept are in-process; POC #5 variant 1, unchanged); the asks and
|
||||
this ADR are scoped to the composed variant.
|
||||
|
||||
## References
|
||||
@@ -273,18 +280,18 @@ wire distinction beyond `bound`'s presence.
|
||||
deadlock and establishment-bound findings this shape dissolves)
|
||||
- alkcall ADR-047 (open ops; connection-owner allocation; the
|
||||
flavor-form amendment), ADR-049 (establishment; the reply-projection
|
||||
amendment), ADR-042 (hub relay — re-produce id semantics)
|
||||
amendment), ADR-042 (hub relay; re-produce id semantics)
|
||||
- alktunnels ADR-001 (params per-op amendment), ADR-002 Amendment 1
|
||||
(flavor-form op ids), ADR-005 (teardown ownership — extended to
|
||||
(flavor-form op ids), ADR-005 (teardown ownership, extended to
|
||||
`TunnelListener`), ADR-006 (scope posture amendment)
|
||||
- AGENTS.md conventions 9 (no forced binding — the bind decision is
|
||||
the producer's), 13 (access control), 8 (two-pump contract — the
|
||||
- AGENTS.md conventions 9 (no forced binding, so the bind decision is
|
||||
the producer's), 13 (access control), 8 (two-pump contract, where the
|
||||
accept dispatcher is a per-accept `pump_bidi`, not a hand-rolled
|
||||
loop over one stream)
|
||||
|
||||
## Amendment 1 (the correlation token, 2026-09-18)
|
||||
|
||||
The decision stands — bind-first establisher, accept-as-open, the
|
||||
The decision stands: bind-first establisher, accept-as-open, the
|
||||
`bound` reply field, the forwarded op. What changes: the forwarded
|
||||
op's correlation key (§3's `channel` param) and the bind-first
|
||||
establisher's reply surface (§2 gains `listen`).
|
||||
@@ -296,18 +303,18 @@ establisher's reply surface (§2 gains `listen`).
|
||||
1. **The serving side never learns its own channel ID.** The
|
||||
open-op wrapper allocates the channel and replies with its ID,
|
||||
but the `OpenHandler`/establisher surface is
|
||||
`Fn(Value, Option<ChannelPlan>, Connection, AuthContext)` — no
|
||||
ID anywhere. The §3 semantic ("the opener knows … without a
|
||||
`Fn(Value, Option<ChannelPlan>, Connection, AuthContext)`, with no
|
||||
ID anywhere. The §3 semantic ("the opener knows … without a
|
||||
registry op") required the producer's accept dispatcher to know
|
||||
its own listen channel's ID to stamp into forwarded opens;
|
||||
unimplementable without a new upstream surface. (Filing one —
|
||||
exposing the allocated ID to handlers — was considered and
|
||||
unimplementable without a new upstream surface. (Filing one,
|
||||
which would expose the allocated ID to handlers, was considered and
|
||||
rejected: it is an alkcall-surface change for a correlation
|
||||
need this crate can solve in-protocol, and it would still not
|
||||
fix (2).)
|
||||
2. **Generic hub relays do not rewrite params.** The in-tree
|
||||
`ChannelRelay` (ADR-051) forwards the open-op input untouched —
|
||||
it strips/rewrites only the reply's `channel_id`. A spoke's
|
||||
`ChannelRelay` (ADR-051) forwards the open-op input untouched;
|
||||
it strips/rewrites only the reply's `channel_id`. A spoke's
|
||||
forwarded opens would carry the spoke-side listen channel's ID,
|
||||
which the end opener never allocated and cannot correlate ("hubs
|
||||
re-map the id naturally" assumed tunnel-specific relay behavior
|
||||
@@ -318,24 +325,135 @@ establisher's reply surface (§2 gains `listen`).
|
||||
reply field via the same `Establishment::with_reply_field` carrier
|
||||
`bound` rides. Both fields pass through relays untouched
|
||||
(reply fields ride per-hop-truthful, ADR-051), and the forwarded
|
||||
open's `listen` param carries the minted token at every hop — the
|
||||
open's `listen` param carries the minted token at every hop; the
|
||||
end opener correlates on the value from its own open's reply.
|
||||
- **Why not a consumer-minted token:** the opener choosing the token
|
||||
and passing it in the listen open's params would work on the direct
|
||||
path, but fails hub re-produce the same way (the hub's own
|
||||
bind-first open is a NEW open — it cannot adopt the end opener's
|
||||
bind-first open is a NEW open, and it cannot adopt the end opener's
|
||||
token without a pass-through mechanism, which is exactly the
|
||||
params-rewriting relays don't do). Producer-minted is the direction
|
||||
information already flows (producer → opener via reply fields);
|
||||
no new flow is needed.
|
||||
- **Token lifecycle:** minted at establisher time (one per open —
|
||||
- **Token lifecycle:** minted at establisher time (one per open;
|
||||
two opens never share a token, so the opener's correlation map
|
||||
needs no session disambiguation beyond the token); retired with
|
||||
the listener session (the token's only consumer is the per-accept
|
||||
forwarded opens of that listener). Uniqueness is per-producer;
|
||||
the opener-side map keys on the token as read from its own reply.
|
||||
- **Scope names, op ids, `bound`, `peer` — unchanged.** The wire
|
||||
- **Scope names, op ids, `bound`, and `peer` are unchanged.** The wire
|
||||
delta vs the pre-amendment ADR is exactly: the reply gains the
|
||||
optional `listen` field, the forwarded params rename `channel` →
|
||||
`listen` with token semantics. Nothing else shipped against either
|
||||
(no consumer exists), so the change is pre-consumer and legal.
|
||||
(no consumer exists), so the change is pre-consumer and legal.
|
||||
|
||||
## Amendment 2 (the serving-side establishment phase + the correlation-miss vocabulary, 2026-11-03)
|
||||
|
||||
Resolves review 003 (`docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md`)
|
||||
via `docs/plans/003-correlation-miss-resolve.md` §Decisions 1–4. The
|
||||
decision stands: bind-first establisher, accept-as-open, the
|
||||
`bound`/`listen` reply fields, the forwarded op, `tunnel:forwarded`.
|
||||
What changes: **the serving side of the forwarded op (§3) gains an
|
||||
establishment phase**: §3's sentence, "no establisher; the forwarded
|
||||
channel needs no establishment; its data plane starts immediately"
|
||||
(marked inline above) is the text this amendment amends.
|
||||
|
||||
### The miss class this closes (the tri-vocabulary history)
|
||||
|
||||
A forwarded open whose `listen` token no longer routes, i.e. the
|
||||
stale/missing-token arrival class, is currently served
|
||||
establisher-less everywhere (alkssh's door, alksocks' composed BIND,
|
||||
and this crate's built-in shape). That is the one posture exception
|
||||
to ADR-049's "a refused open is a typed call error, never a phantom
|
||||
channel": the open succeeds at the call plane, declines post-open in
|
||||
the assembly, and the producer-side pump EOFs with no signal. The
|
||||
family has named the class three ways:
|
||||
|
||||
- **alkssh** uses `correlation-miss` at `debug`:
|
||||
`observe::decline_arrival(row, "correlation-miss", listen)` +
|
||||
teardown-at-birth (`src/tunnel_map.rs`; tokens removed at session
|
||||
teardown by the shared per-fabric-leg `ListenRouter`).
|
||||
- **alksocks** uses prose at `warn`: "forwarded open with no waiting
|
||||
session" (`src/composed/mod.rs:497`).
|
||||
- **alktunnels** has the `handler_error`-class generic arm at `warn`
|
||||
(the bind-first accept loop's failed-open branch,
|
||||
`src/producer.rs:838-854`), which fires only for the
|
||||
connection-gone/`NOT_FOUND`/ACL-refused subclass; an in-session
|
||||
miss (route removed while the fabric connection lives) succeeds at
|
||||
the call plane and logs nothing at all.
|
||||
|
||||
Amendment 2 retires the prose variant and the fabric-side silence.
|
||||
|
||||
### The decisions
|
||||
|
||||
1. **The serving side gains an establisher variant, and the gate is
|
||||
injected.** The protocol crate cannot hold the assembly's
|
||||
correlation map, so the establisher's check is an
|
||||
assembly-provided gate closure over the forwarded params
|
||||
(`TunnelForwardedParams`); the crate wraps that closure into the
|
||||
alkcall `OpenEstablisher` dance and registers via
|
||||
`register_openable_with_establisher` (alkcall 0.10.x, verified
|
||||
present). Assemblies must NOT hand-roll the alkcall establisher
|
||||
plumbing; the crate-owned registration surface is the only
|
||||
supported path. The gate is a lookup, not a dial: establishment
|
||||
stays fast, bounded by the usual establisher rules only.
|
||||
2. **A stale/missing token fails the open as a typed
|
||||
`channel:open_failed`.** The crate maps the gate's failure to
|
||||
alkcall's `EstablishmentError::UnknownResource`, so the reply is
|
||||
`channel:open_failed` with `details.reason = "unknown_resource"`,
|
||||
and no phantom channel is ever born (ADR-049's posture, extended here
|
||||
to the forwarded op's serving side). The waiting session IS a
|
||||
resource that is gone; **`unknown_resource` is reused as-is and
|
||||
the wire vocabulary is NOT extended**: `wire.md`'s open-op sections
|
||||
(the pinned `details.reason ∈ dial_failed / unknown_resource /
|
||||
resource_shortage / handler_error / timeout` list and its
|
||||
§The Forwarded Op) are untouched by this amendment; there is no
|
||||
reason-class ADR, no new wire value. (The one serving-shape prose
|
||||
line in `wire.md`, "no establisher" under §The Forwarded Op,
|
||||
syncs with the API task's in-code doc updates, plan 003 W1; this
|
||||
ADR does not touch `wire.md`.)
|
||||
3. **Gate order is pinned: the scope gate precedes the establisher.**
|
||||
The registry runs `tunnel:forwarded` ACL before the open op's
|
||||
establishment phase (alkcall's open-op wrapper order: registry
|
||||
gates (schema, then ACL), then the wrapper's allocate →
|
||||
establisher → handler). A scope-less producer is `FORBIDDEN`
|
||||
before any gate lookup; the gate never sees an unauthorized open.
|
||||
4. **The residual post-open race keeps teardown-at-birth, named
|
||||
`correlation-miss` at `debug`.** A token that vanishes between
|
||||
establisher pass and handler run, the narrow race the gate cannot
|
||||
close, keeps §3's opener-half posture, unchanged: the assembly
|
||||
handler declines, its early return reaps the channel
|
||||
teardown-at-birth (R-02). Its event name is **`correlation-miss`**
|
||||
(alkssh's shipped vocabulary wins; it is published, and alksocks'
|
||||
prose variant retires), at `debug` **assembly-side**:
|
||||
the
|
||||
protocol crate's built-in handler holds no map, so this is the
|
||||
assemblies' convention this ADR records, not code this crate runs.
|
||||
The producer-side generic arm (the accept loop's failed-open
|
||||
branch) stays at `warn`, now carrying the typed reason from the
|
||||
open failure instead of the prose one.
|
||||
|
||||
### Additive-only surface
|
||||
|
||||
The base `register_tunnel_forwarded_openable` (establisher-less, the
|
||||
post-open shape) stays for compatibility; the establisher variant
|
||||
(`register_tunnel_forwarded_openable_with_establisher`) is the
|
||||
recommended posture, and new assemblies take it by default. Both ride
|
||||
the same op id, params, scope, and reply surface; the wire delta of
|
||||
this amendment is empty.
|
||||
|
||||
### References
|
||||
|
||||
- `docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md`
|
||||
(the ask; situation + framing this amendment resolves)
|
||||
- `docs/plans/003-correlation-miss-resolve.md` §Decisions, §Verified
|
||||
findings (the tri-vocabulary survey; W1 is this amendment + the API
|
||||
task), §Ordering (W2 alkssh / W3 alksocks adoption waves)
|
||||
- alkcall ADR-049 (the establishment phase and typed-error posture
|
||||
extended here); alkcall 0.10.x
|
||||
`register_openable_with_establisher` and
|
||||
`EstablishmentError::UnknownResource → unknown_resource` (verified)
|
||||
- alkssh `src/tunnel_map.rs` (`ListenRouter`, `decline_arrival`);
|
||||
alksocks `src/composed/mod.rs:497`; alktunnels
|
||||
`src/producer.rs:838-854`: the three miss sites this amendment
|
||||
converges (source for §The miss class above)
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (OQ-TN-16 updated — ADR-008 Amendment 1: the correlation token)
|
||||
last_updated: 2026-09-18 (OQ-TN-16 updated per ADR-008 Amendment 1: the correlation token)
|
||||
---
|
||||
|
||||
# alktunnels — Open Questions
|
||||
# alktunnels: Open Questions
|
||||
|
||||
Centralized tracker. The Phase 0 OQ ledger (`docs/research/
|
||||
phase-0-findings.md`, OQ-TN-01..10) is promoted here with statuses
|
||||
@@ -15,7 +15,7 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
### OQ-TN-01: Target addressing format (open-op `params` shape)
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-01)
|
||||
- **Status**: resolved — [ADR-001](decisions/001-open-params-layout.md)
|
||||
- **Status**: resolved (see [ADR-001](decisions/001-open-params-layout.md))
|
||||
- **Priority**: high
|
||||
- **Resolution**: 2026-09-07 (Phase 1). `params` = self-contained
|
||||
JSON object: `{ "resource": <string>, "substrate": <string> }`.
|
||||
@@ -26,17 +26,17 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
+ survey input (ssh-socks5-survey §OQ-TN-01 residue) + both POCs.
|
||||
- **Cross-references**: OQ-TN-02, OQ-TN-03, ADR-001, ADR-002
|
||||
|
||||
### OQ-TN-02: Datagram substrates (UDP) — boundary preservation
|
||||
### OQ-TN-02: Datagram substrates (UDP), boundary preservation
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-02)
|
||||
- **Status**: resolved — [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
- **Status**: resolved; see [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
- **Priority**: high
|
||||
- **Resolution**: split by path (2026-09-06 survey) + F-2 mandate
|
||||
(2026-09-07 reverse POC). Base open-op UDP resources:
|
||||
endpoint-at-open (one channel = one flow). Dynamic/`-D` UDP:
|
||||
per-datagram addressing inside the tunnel payload, assembly-layer.
|
||||
Boundary preservation: mandatory `[len: u16 BE]` per-datagram
|
||||
framing — mandatory for correctness, not cosmetics (F-2; the full
|
||||
framing; this is mandatory for correctness, not cosmetics (F-2; the full
|
||||
rationale and the executable pin live in ADR-003). Under the codec,
|
||||
`len=0` is a legal empty datagram (forward POC validated); EOF is
|
||||
the channels-level sentinel; the layers never collide.
|
||||
@@ -46,7 +46,7 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-03)
|
||||
- **Priority**: high
|
||||
- **Status**: resolved — hub-owns-the-connection model (2026-09-05) +
|
||||
- **Status**: resolved: the hub-owns-the-connection model (2026-09-05) +
|
||||
the reverse-flow POC (2026-09-07, `reverse-poc-summary.md`).
|
||||
No protocol-level direction: role follows the resource. `-L`/`-R`
|
||||
are the same open op with the entry point on different machines;
|
||||
@@ -60,37 +60,37 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-04)
|
||||
- **Priority**: medium
|
||||
- **Status**: resolved — binding is always assembly-layer and
|
||||
- **Status**: resolved: binding is always assembly-layer and
|
||||
optional, on either side; the protocol never binds. API surface:
|
||||
dial flows with no local bind (both POCs), listen flows where the
|
||||
binding is the producer's establisher (listen variant, ADR-004),
|
||||
unbound/abstract flows (in-process pipes — the pump is generic over
|
||||
unbound/abstract flows (in-process pipes; the pump is generic over
|
||||
boxed halves). The concrete produce/consume surface is spec'd in
|
||||
producer.md / consumer.md (ADR-005).
|
||||
- **Cross-references**: OQ-TN-03, ADR-004, ADR-005
|
||||
|
||||
## Theme 2: Crate architecture
|
||||
|
||||
### OQ-TN-05: Backend inversion point — `TunnelBackend` trait?
|
||||
### OQ-TN-05: Backend inversion point (`TunnelBackend` trait?)
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-05)
|
||||
- **Status**: resolved — [ADR-004](decisions/004-no-backend-trait.md)
|
||||
- **Status**: resolved: [ADR-004](decisions/004-no-backend-trait.md)
|
||||
- **Priority**: medium
|
||||
- **Resolution**: 2026-09-07 (Phase 1). No trait for substrate access:
|
||||
"produce boxed halves for a resource" is a function (UDP POC,
|
||||
OQ-TN-05 executable input). The only remaining thread — the hub
|
||||
re-produce composition — resolves the same way: a hub proxy is a
|
||||
OQ-TN-05 executable input). The only remaining thread, the hub
|
||||
re-produce composition, resolves the same way: a hub proxy is a
|
||||
consumer + producer composed at the assembly layer (the reverse
|
||||
POC's hub is exactly this shape minus re-export); a composition
|
||||
helper is deferred(scope) until a concrete hub-proxy consumer
|
||||
exists (OQ-TN-12).
|
||||
- **Cross-references**: OQ-TN-12, ADR-004
|
||||
|
||||
### OQ-TN-06: The two-pump helper — extract now?
|
||||
### OQ-TN-06: The two-pump helper (extract now?)
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-06)
|
||||
- **Priority**: medium
|
||||
- **Status**: resolved (upstream) — `alkcall::channels::pump_bidi`
|
||||
- **Status**: resolved (upstream): `alkcall::channels::pump_bidi`
|
||||
landed in alkcall 0.6.0 (ADR-050, review 007 R-03), shape as this
|
||||
OQ sketched it; this crate is cited as convergence input. alktunnels
|
||||
consumes the helper; hand-rolling is out.
|
||||
@@ -101,7 +101,7 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
### OQ-TN-07: ALPN strategy
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-07)
|
||||
- **Status**: resolved — [ADR-002](decisions/002-alpn-strategy.md)
|
||||
- **Status**: resolved: [ADR-002](decisions/002-alpn-strategy.md)
|
||||
- **Priority**: high
|
||||
- **Resolution**: 2026-09-07 (Phase 1). Option A: single `alk/tunnel`
|
||||
ALPN; the substrate discriminator in `params` selects the framing.
|
||||
@@ -113,8 +113,8 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
### OQ-TN-08: Access control and ownership scope
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-08)
|
||||
- **Status**: resolved — [ADR-006](decisions/006-access-control-posture.md)
|
||||
(2026-09-07 — the posture promoted to a decision). The resource is
|
||||
- **Status**: resolved: [ADR-006](decisions/006-access-control-posture.md)
|
||||
(2026-09-07; the posture promoted to a decision). The resource is
|
||||
owned by the far side (alkcall op-level ACL applies as-is; no
|
||||
tunnel-specific policy layer, no allowlists in v1; the ownership
|
||||
seam is the establisher seeing the per-call opener identity).
|
||||
@@ -134,7 +134,7 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-09)
|
||||
- **Priority**: high
|
||||
- **Status**: resolved (superseded by alkcall ADR-049, 0.5.0; narrowed
|
||||
through 0.6.0/0.7.0) — establishment is the open op's awaited phase;
|
||||
through 0.6.0/0.7.0): establishment is the open op's awaited phase;
|
||||
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
|
||||
@@ -148,35 +148,35 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-10)
|
||||
- **Priority**: low
|
||||
- **Status**: resolved — UDP POC DONE (2026-09-06, 17 tests),
|
||||
- **Status**: resolved: the UDP POC is DONE (2026-09-06, 17 tests),
|
||||
reverse-flow POC DONE (2026-09-07, 14 tests; re-validated over
|
||||
alkcall 0.7.0, 16 tests). #3 split (OQ-TN-14): unix ships with the
|
||||
`local` feature; stdio is OUT — alktty's pipe mode owns process
|
||||
`local` feature; stdio is OUT, since alktty's pipe mode owns process
|
||||
stdio (exit codes + signals are tty semantics). #4 (helper spike)
|
||||
resolved by alkcall ADR-050. Findings F-1 (plan payloads
|
||||
`Send + Sync` — documented upstream) and F-2 (UDP codec mandatory)
|
||||
`Send + Sync`, documented upstream) and F-2 (UDP codec mandatory)
|
||||
recorded; W1 filed upstream and RESOLVED (CF-005/CF-006).
|
||||
- **Cross-references**: ADR-003, OQ-TN-05, OQ-TN-06
|
||||
|
||||
## Theme 4: Phase 1 residues (new)
|
||||
|
||||
### OQ-TN-11: Resource naming — collision domain and lifecycle of a resource id
|
||||
### OQ-TN-11: Resource naming (collision domain and lifecycle of a resource id)
|
||||
|
||||
- **Origin**: OQ-TN-01's residue (stable resource name vs target
|
||||
address), carried into Phase 1
|
||||
- **Status**: partially resolved — the collision-domain half is
|
||||
- **Status**: partially resolved: the collision-domain half is
|
||||
**resolved by [ADR-001](decisions/001-open-params-layout.md)**: the
|
||||
domain is the producer's registry (per-producer; two producers on
|
||||
the same connection may expose the same resource name independently
|
||||
— the open op resolves against the SERVING side's registry). The
|
||||
the same connection may expose the same resource name independently;
|
||||
the open op resolves against the SERVING side's registry). The
|
||||
lifecycle half stays open: is a resource registry a producer-side
|
||||
assembly construct (the POCs' `HashMap` — the leading candidate),
|
||||
assembly construct (the POCs' `HashMap`, the leading candidate),
|
||||
or does it need protocol-level register/rename/retire ops?
|
||||
- **Priority**: medium
|
||||
- **Impacts**: producer.md's `ResourceRegistry` shape and the
|
||||
discovery story (OQ-TN-08); a protocol-level registry op would be a
|
||||
new wire surface (one-way door).
|
||||
- **Resolution**: lifecycle half pending — no prior-art pressure
|
||||
- **Resolution**: lifecycle half pending; no prior-art pressure
|
||||
(SSH lists no forwards in-band; discovery is the ops listing);
|
||||
decide when a consumer needs shared/lifecycle-managed resources.
|
||||
- **Cross-references**: ADR-001, OQ-TN-08
|
||||
@@ -184,7 +184,7 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
### OQ-TN-12: Hub re-produce composition helper
|
||||
|
||||
- **Origin**: OQ-TN-05's remaining thread (2026-09-05 hub model)
|
||||
- **Status**: resolved (upstream, 2026-09-18) — alkcall 0.8.0 ships
|
||||
- **Status**: resolved (upstream, 2026-09-18): alkcall 0.8.0 ships
|
||||
the in-tree `ChannelRelay` + `HubLegTemplate`/`HubLegImports`
|
||||
(alkcall ADR-051, review 008 Unit 3): the relay openable wraps
|
||||
from_call-imported marked specs on the consumer-leg registry via
|
||||
@@ -192,10 +192,10 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
teardown), the translate hop calls the producer leg with
|
||||
`forwarded_for` from the consumer's per-call identity, the
|
||||
byte-forward hop is `pump_bidi` awaited inline, the spoke reply's
|
||||
`channel_id` is stripped/replaced (other fields ride through —
|
||||
`channel_id` is stripped/replaced (other fields ride through, so
|
||||
`bound` flows end-to-end per-hop truthful), and the adopted
|
||||
producer-leg entry reclaims via a `Drop` guard on every plan path.
|
||||
The composition helper a hub proxy needed is upstream — this crate
|
||||
The composition helper a hub proxy needed is upstream, and this crate
|
||||
composes it (OQ-TN-05's resolution stands: no tunnel-side trait).
|
||||
Tracker task: `architecture/oq-tn-12-tracker` (tasks/architecture/).
|
||||
- **Priority**: low
|
||||
@@ -205,9 +205,9 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
### OQ-TN-13: UDP truncation semantics at the codec boundary
|
||||
|
||||
- **Origin**: forward POC Issues Surfaced #3
|
||||
- **Status**: resolved — [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
- **Status**: resolved: [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
(2026-09-07): **fail-loud at the adapter level.** A receive buffer
|
||||
smaller than the datagram is an error, never a silent truncation —
|
||||
smaller than the datagram is an error, never a silent truncation;
|
||||
a truncated datagram would corrupt the length-framed stream's
|
||||
framing invariants (the reverse POC's F-2 rationale: the codec's
|
||||
wire discipline is what keeps empty datagrams unambiguous; silent
|
||||
@@ -226,20 +226,20 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
- **Origin**: OQ-TN-10 #3 (the cheap un-POC'd item)
|
||||
- **Priority**: low
|
||||
- **Status**: **resolved 2026-09-07 (split by substrate):**
|
||||
- **Unix: in scope for the `local` feature v1** — same halves shape
|
||||
- **Unix: in scope for the `local` feature v1**: same halves shape
|
||||
as TCP (`dial_unix`), cheap, wire enum already carries `unix`
|
||||
(ADR-001). Implemented by `tunnels/local-socket-halves`.
|
||||
- **Stdio: OUT of scope — alktty owns it.** A spawned process's
|
||||
- **Stdio: OUT of scope; alktty owns it.** A spawned process's
|
||||
stdin/stdout/stderr IS a terminal session in alktty's model:
|
||||
`LocalTtyBackend`'s pipe mode (`tokio::process::Command` +
|
||||
`Stdio::piped()`, alktty `tty-local.md`) is exactly the stdio
|
||||
case — three multiplexed logical streams + the exit-code
|
||||
case, with three multiplexed logical streams + the exit-code
|
||||
control chunk (alktty ADR-004) + signal forwarding (REQ-TTY-02).
|
||||
A "stdio bridge" here would be alktty's runner mode with the
|
||||
terminal stripped out: a strictly worse duplicate that also
|
||||
drops the semantics that matter (exit code, signals — a byte
|
||||
drops the semantics that matter (exit code, signals; a byte
|
||||
tunnel has neither). Consumers needing to run commands remotely
|
||||
compose: alkcall dial → alktty session (pipe mode) — the two
|
||||
compose: alkcall dial → alktty session (pipe mode); the two
|
||||
crates are siblings on the same channels substrate, not
|
||||
competitors. (The OQ-TN-10 #3 "cheap item" was scoped before the
|
||||
sibling relationship was this clear.)
|
||||
@@ -256,16 +256,16 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
- **Origin**: `docs/research/tunnels-graduation.md` §2 (the alksocks
|
||||
graduation discussion, 2026-09-15)
|
||||
- **Priority**: high
|
||||
- **Status**: resolved — [ADR-007](decisions/007-direct-open-op.md)
|
||||
- **Status**: resolved: [ADR-007](decisions/007-direct-open-op.md)
|
||||
(2026-09-16). Option (a): a second `Sub`-typed open op
|
||||
`channels/tunnel/direct` on the same `alk/tunnel` ALPN, params
|
||||
`{substrate, target}` (self-describing JSON target keyed by the
|
||||
sibling substrate — ATYP bytes rejected: not schema-describable, no
|
||||
sibling substrate; ATYP bytes rejected: not schema-describable, no
|
||||
unix path form), scope `tunnel:direct` (never implied by
|
||||
`tunnel:open` — structural ACL separation), dial-only (no
|
||||
listen/direct-BIND form — ingress is the listen + forwarded
|
||||
`tunnel:open`, which is the structural ACL separation), dial-only (no
|
||||
listen/direct-BIND form; ingress is the listen + forwarded
|
||||
surface). The op-id convention needed an upstream alkcall amendment
|
||||
(ADR-047's discovery derivation was `/sub`-shaped) — filed in
|
||||
(ADR-047's discovery derivation was `/sub`-shaped); it was filed in
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
|
||||
(ask #1) and **landed in alkcall 0.8.0** (ADR-047 amendment 3:
|
||||
flavor-form op ids + the explicit `channel_open_alpn` discovery
|
||||
@@ -279,23 +279,23 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
BIND POC's two asks: bound-addr without consuming an accept; peer
|
||||
metadata on accepted handles)
|
||||
- **Priority**: high
|
||||
- **Status**: resolved — [ADR-008](decisions/008-listen-metadata-forwarded-op.md)
|
||||
(2026-09-16; **amended 2026-09-18** — Amendment 1). The
|
||||
- **Status**: resolved: [ADR-008](decisions/008-listen-metadata-forwarded-op.md)
|
||||
(2026-09-16; **amended 2026-09-18**, Amendment 1). The
|
||||
`forwarded-tcpip` shape adopted over per-ask carriers: (1) a
|
||||
bind-first listen establisher — establishment ends at bind time, so
|
||||
bind-first listen establisher: establishment ends at bind time, so
|
||||
the open-op reply carries the real OS-chosen address via an
|
||||
additive `bound` field (ask #1; the carrier is the establisher →
|
||||
reply projection — filed as the review's ask #2 and **landed in
|
||||
reply projection, filed as the review's ask #2 and **landed in
|
||||
alkcall 0.8.0** as ADR-049 amendment 3,
|
||||
`Establishment::with_reply_field` + `open_channel_with_reply`),
|
||||
and the address-first-vs-accept-first BIND deadlock + the 10s
|
||||
establishment-bound problem dissolve with the shape; (2) the
|
||||
forwarded op `channels/tunnel/forwarded` (accept-as-open) — the
|
||||
forwarded op `channels/tunnel/forwarded` (accept-as-open): the
|
||||
producer opens a channel per accept with `{listen, substrate,
|
||||
peer}` in params (ask #2's carrier; the `TargetHandle.peer` option
|
||||
is superseded). Amendment 1: the correlation key is a
|
||||
**producer-minted token** (`listen` reply field + forwarded
|
||||
param), not the listen channel's ID — the serving-side handler
|
||||
param), not the listen channel's ID; the serving-side handler
|
||||
never sees its own channel ID (alkcall 0.8.0's `OpenHandler`
|
||||
surface) and generic relays don't rewrite params, so a
|
||||
producer-minted token (which rides reply fields and params through
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (the graduation: the direct op, listen metadata, the forwarded op — ADR-007/008 as amended: the correlation token)
|
||||
last_updated: 2026-09-18 (the graduation: the direct op, listen metadata, the forwarded op; ADR-007/008 as amended: the correlation token)
|
||||
---
|
||||
|
||||
# alktunnels — Overview
|
||||
# alktunnels: Overview
|
||||
|
||||
Arbitrary bidirectional tunnels over alkcall channels: TCP, UDP, unix
|
||||
sockets, and other stream or datagram substrates — in the `ssh -L` /
|
||||
sockets, and other stream or datagram substrates, all in the `ssh -L` /
|
||||
`ssh -D` / `ssh -R` sense. A producer/consumer protocol crate riding
|
||||
alkcall channels the same way alktty does (`alk/tty` is the sibling
|
||||
precedent). This document covers the crate's purpose, the resource
|
||||
@@ -28,8 +28,8 @@ channels and provides the typed consumer sessions:
|
||||
halves (ADR-050, R-02's lifetime contract). Three ops on the one
|
||||
ALPN (ADR-002 Amendment 1): `channels/tunnel/sub` (the resource
|
||||
op), `channels/tunnel/direct` (dynamic-target egress, ADR-007), and
|
||||
— served by the listen-opener side — `channels/tunnel/forwarded`
|
||||
(accept-as-open, ADR-008). Three establisher shapes: dial, listen
|
||||
`channels/tunnel/forwarded` (served by the listen-opener side;
|
||||
accept-as-open, ADR-008). Three establisher shapes: dial, listen
|
||||
(accept-first), and bind-first listen.
|
||||
- **The consumer half** (`TunnelSession` / `TunnelListener`,
|
||||
ADR-005/008): opens tunnel channels (`ChannelClient::open_channel`
|
||||
@@ -44,11 +44,11 @@ The guiding insight (the OQ-TN-01 reframe):
|
||||
> A tunnel is a resource, not an address. Base params identify a
|
||||
> produced resource + substrate; the producer owns the backing. The
|
||||
> one additive exception is the direct op (ADR-007): a consumer may
|
||||
> ask the producer to dial an arbitrary target — a different
|
||||
> capability, its own op and its own scope (`tunnel:direct`), never
|
||||
> ask the producer to dial an arbitrary target, and that is a
|
||||
> different capability, its own op and its own scope (`tunnel:direct`), never
|
||||
> implied by `tunnel:open`. Rich per-datagram addressing (SOCKS5 ATYP,
|
||||
> udpgw-style remotes) still enters only through the `-D`/dynamic
|
||||
> composition path — inside the tunnel payload, never in the wire.
|
||||
> composition path, i.e. inside the tunnel payload, never in the wire.
|
||||
|
||||
## Why
|
||||
|
||||
@@ -57,15 +57,15 @@ consumers, the same role alktty plays for terminal sessions:
|
||||
|
||||
- A hub that runs agent workspaces exposes workers' services
|
||||
(postgres, redis, gitea HTTP) as named resources; a coordinator
|
||||
opens tunnels to them over alkcall channels — the VPN/SSH-tunnel
|
||||
service pattern without SSH.
|
||||
opens tunnels to them over alkcall channels (the VPN/SSH-tunnel
|
||||
service pattern without SSH).
|
||||
- A worker behind NAT dials the hub and serves its own open ops
|
||||
(the reverse flow, `ssh -R`); the hub opens tunnel channels toward
|
||||
it per local accept. Both POCs validated this end-to-end.
|
||||
- A `-D`-style consumer tunnels a SOCKS5 connection; the socks5
|
||||
server (assembly layer) does dynamic target selection inside the
|
||||
tunnel payload.
|
||||
- The protocol layer is substrate-agnostic by construction — the
|
||||
- The protocol layer is substrate-agnostic by construction: the
|
||||
bookkeeping (target addressing, direction, lifecycle) never
|
||||
hardcodes a substrate (AGENTS.md convention 7). TCP, UDP, unix
|
||||
sockets, and in-process pipes ride the same open op, pump shape,
|
||||
@@ -77,10 +77,10 @@ A tunnel channel's life (full detail in [wire.md](wire.md)):
|
||||
|
||||
1. **Open (the establishment phase).** The consumer calls
|
||||
`channels/tunnel/sub` with `params = {resource, substrate}`
|
||||
(ADR-001) — or `channels/tunnel/direct` with
|
||||
(ADR-001), or `channels/tunnel/direct` with
|
||||
`params = {substrate, target}` (ADR-007, dynamic egress). The
|
||||
producer's establisher — the open op's awaited phase (alkcall
|
||||
ADR-049) — resolves the resource (or parses the target), dials the
|
||||
producer's establisher (the open op's awaited phase, alkcall
|
||||
ADR-049) resolves the resource (or parses the target), dials the
|
||||
substrate, and returns the handle in the plan. Failure is a typed
|
||||
`channel:open_failed` (`dial_failed` / `unknown_resource` /
|
||||
`resource_shortage` / `handler_error` / `timeout`); the channel
|
||||
@@ -108,52 +108,52 @@ establisher + the forwarded op (ADR-008).
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
alktunnels (default — wasm-clean)
|
||||
├── alkcall 0.8.0 (core types, channels: ChannelCore/ChannelClient/manager,
|
||||
│ pump_bidi, ServingConfig identity seam — CF-005/006,
|
||||
alktunnels (default: wasm-clean)
|
||||
├── alkcall 0.10.1 (core types, channels: ChannelCore/ChannelClient/manager,
|
||||
│ 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
|
||||
wasm-clean (AGENTS.md convention 4); substrate I/O is feature-gated
|
||||
(ADR-004). The wire format carries no alkcall-internal types
|
||||
(alktty ADR-006's self-containment rule applies to the tunnel codec —
|
||||
trivial here, since the codec is a 2-byte length prefix).
|
||||
(alktty ADR-006's self-containment rule applies to the tunnel codec,
|
||||
though it is trivial here, since the codec is a 2-byte length prefix).
|
||||
|
||||
## Feature Gates
|
||||
|
||||
| Feature | Contents | Wasm |
|
||||
|---------|----------|------|
|
||||
| *(default)* | params, wire codec, open-op spec, establisher shapes, `TunnelSession` — protocol only | yes |
|
||||
| `local` | TCP/UDP/unix dial + listen helpers — halves-producing dial/listen functions (ADR-004). Unix ships (OQ-TN-14); stdio does NOT — a spawned process's stdio is alktty's pipe mode (exit codes, signals), not a tunnel | no |
|
||||
| *(default)* | params, wire codec, open-op spec, establisher shapes, `TunnelSession` (protocol only) | yes |
|
||||
| `local` | TCP/UDP/unix dial + listen helpers, i.e. halves-producing dial/listen functions (ADR-004). Unix ships (OQ-TN-14); stdio does NOT: a spawned process's stdio is alktty's pipe mode (exit codes, signals), not a tunnel | no |
|
||||
|
||||
## Module Map
|
||||
|
||||
```
|
||||
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).
|
||||
@@ -164,14 +164,14 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [001](decisions/001-open-params-layout.md) | Open-op params: resource + substrate | `{resource, substrate}` — the producer's stable name, not an address; wire-stable from the first consumer |
|
||||
| [001](decisions/001-open-params-layout.md) | Open-op params: resource + substrate | `{resource, substrate}`, the producer's stable name (not an address); wire-stable from the first consumer |
|
||||
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN | Substrate in params selects the framing; no ALPN split, no API bifurcation |
|
||||
| [003](decisions/003-codec-and-udp-framing.md) | Data-plane codec | Raw pass-through (stream) / mandatory `[len: u16 BE]` (UDP — F-2 mandate); `len=0` is a legal empty datagram |
|
||||
| [003](decisions/003-codec-and-udp-framing.md) | Data-plane codec | Raw pass-through (stream) / mandatory `[len: u16 BE]` (UDP; F-2 mandate); `len=0` is a legal empty datagram |
|
||||
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend trait | Halves functions at the assembly layer; listen is an establisher shape; trait re-evaluated at the 3-consumer threshold |
|
||||
| [005](decisions/005-consumer-session-owns-teardown.md) | Consumer session owns teardown | `TunnelSession` with close/join/Drop — the W3 gap closes structurally |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1; amended — `tunnel:direct`/`tunnel:forwarded` scopes + the hub re-produce policy sentence |
|
||||
| [007](decisions/007-direct-open-op.md) | The direct open op | `channels/tunnel/direct`: dynamic-target egress, `{substrate, target}` params, `tunnel:direct` scope, dial-only — the ssh `direct-tcpip` capability with structural ACL separation |
|
||||
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen metadata + the forwarded op | Bind-first establisher (reply `bound` field = BND.ADDR fidelity; `listen` = the correlation token), `channels/tunnel/forwarded` accept-as-open (params carry `{listen, substrate, peer}` = reply#2 fidelity) — the `forwarded-tcpip` shape; amended — producer-minted correlation token supersedes the channel-ID key |
|
||||
| [005](decisions/005-consumer-session-owns-teardown.md) | Consumer session owns teardown | `TunnelSession` with close/join/Drop; the W3 gap closes structurally |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1; amended with the `tunnel:direct`/`tunnel:forwarded` scopes + the hub re-produce policy sentence |
|
||||
| [007](decisions/007-direct-open-op.md) | The direct open op | `channels/tunnel/direct`: dynamic-target egress, `{substrate, target}` params, `tunnel:direct` scope, dial-only (the ssh `direct-tcpip` capability with structural ACL separation) |
|
||||
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen metadata + the forwarded op | Bind-first establisher (reply `bound` field = BND.ADDR fidelity; `listen` = the correlation token), `channels/tunnel/forwarded` accept-as-open (params carry `{listen, substrate, peer}` = reply#2 fidelity); this is the `forwarded-tcpip` shape; amended so that a producer-minted correlation token supersedes the channel-ID key |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -179,13 +179,13 @@ Open questions are tracked in [open-questions.md](open-questions.md).
|
||||
Key questions affecting this document:
|
||||
|
||||
- **OQ-TN-11**: resource naming collision domain + lifecycle (partially resolved)
|
||||
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope) —
|
||||
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope);
|
||||
the policy sentence is pinned: the hub's ACL governs the final dial)
|
||||
- **OQ-TN-13**: UDP truncation semantics (resolved — fail-loud, ADR-003)
|
||||
- **OQ-TN-14**: unix/stdio placement (resolved — unix in via
|
||||
- **OQ-TN-13**: UDP truncation semantics (resolved, fail-loud, ADR-003)
|
||||
- **OQ-TN-14**: unix/stdio placement (resolved; unix in via
|
||||
`local`, stdio out: alktty's pipe mode owns process stdio)
|
||||
- **OQ-TN-15**: the direct op (resolved — ADR-007)
|
||||
- **OQ-TN-16**: listen metadata (resolved — ADR-008)
|
||||
- **OQ-TN-15**: the direct op (resolved; ADR-007)
|
||||
- **OQ-TN-16**: listen metadata (resolved; ADR-008)
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (the direct op, the bind-first establisher, the forwarded op — ADR-007/008 as amended: the correlation token)
|
||||
last_updated: 2026-11-03 (the forwarded op's serving-side establishment
|
||||
phase + the correlation-miss vocabulary, ADR-008 Amendment 2)
|
||||
---
|
||||
|
||||
# alktunnels — Producer Half
|
||||
# alktunnels: Producer Half
|
||||
|
||||
The producer half: the `channels/tunnel/sub` open op — its spec, its
|
||||
The producer half: the `channels/tunnel/sub` open op, its spec, its
|
||||
establishers (dial + listen shapes), its pump handler, and its
|
||||
registration — plus the sibling ops on the same ALPN: the direct op
|
||||
registration, plus the sibling ops on the same ALPN: the direct op
|
||||
(`channels/tunnel/direct`, dynamic-target egress) and the forwarded op
|
||||
(`channels/tunnel/forwarded`, accept-as-open). The producer is whoever
|
||||
can reach the tunnel target; connection direction is irrelevant
|
||||
@@ -43,16 +44,16 @@ ACL, allocates the channel (connection-owner rule, ADR-047 §5), runs
|
||||
the establisher as the awaited bounded establishment phase, then
|
||||
spawns the pump handler. The generic channel ops
|
||||
(`channel/close`, `channel/control`, `channel/resources/subscribe`)
|
||||
register separately via `ChannelOperations::register_on` — the
|
||||
register separately via `ChannelOperations::register_on`; the
|
||||
producer (or its assembly layer) MUST register them on the serving
|
||||
registry so peers can manage channels out-of-band (the recipe alkcall
|
||||
review 007 §Part B verified — `alkcall/docs/reviews/
|
||||
review 007 §Part B verified; see `alkcall/docs/reviews/
|
||||
007-establishment-follow-ups-review.md`; R-01 = the `Establishment`
|
||||
plan payload, R-02 = the `OpenHandler` lifetime contract).
|
||||
|
||||
**Registration timing:** the openable may register AFTER
|
||||
`from_connection_with_serving` returns (the connect-side serving
|
||||
posture — the reverse POC's W2): the dispatcher reads through the
|
||||
posture; the reverse POC's W2): the dispatcher reads through the
|
||||
shared `Arc<OperationRegistry>` per dispatch. Late registration is a
|
||||
supported assembly shape, not a race.
|
||||
|
||||
@@ -60,7 +61,7 @@ supported assembly shape, not a race.
|
||||
|
||||
Dynamic-target egress (ADR-007): the same ALPN, a second `Sub`-typed
|
||||
open op whose establisher dials a per-call target instead of resolving
|
||||
a resource. One ALPN, two ops, two scopes — dial-what-the-producer-
|
||||
a resource. One ALPN, two ops, two scopes: dial-what-the-producer-
|
||||
registered (`tunnel:open`) and dial-anything (`tunnel:direct`) are
|
||||
different capabilities.
|
||||
|
||||
@@ -76,11 +77,11 @@ access control: required_scopes: ["tunnel:direct"]
|
||||
description: SHOULD carry a human-readable hint (dynamic-target egress)
|
||||
```
|
||||
|
||||
- **No registry lookup** — the establisher parses `target` against
|
||||
- **No registry lookup**: the establisher parses `target` against
|
||||
`substrate` (malformed → `invalid_input`-class; `unknown_resource`
|
||||
can never fire) and injects the parsed `TunnelTarget` into the same
|
||||
assembly-provided `DialFn` the base op's backings resolve to.
|
||||
- **Dial-only** — no listen/direct-BIND form; the ingress capability
|
||||
- **Dial-only**: no listen/direct-BIND form; the ingress capability
|
||||
is the listen + forwarded surface (ADR-008).
|
||||
- Registration: `register_tunnel_direct_openable` (same wrapper, same
|
||||
plan-flow; the spec builder is public for assembly variants).
|
||||
@@ -90,9 +91,9 @@ description: SHOULD carry a human-readable hint (dynamic-target egress)
|
||||
The establisher is the open op's awaited, bounded establishment phase
|
||||
(alkcall ADR-049 §1/§2): validate params semantically, consult
|
||||
ownership if wired, dial/accept the substrate, and return
|
||||
`Establishment::new(plan)` — the dialed handle rides the typed-opaque
|
||||
`Establishment::new(plan)`; the dialed handle rides the typed-opaque
|
||||
plan (`ChannelPlan = Arc<dyn Any + Send + Sync>`; the `+ Sync` bound
|
||||
constrains plan payloads — socket-backed handles carry it naturally,
|
||||
constrains plan payloads; socket-backed handles carry it naturally,
|
||||
reverse POC F-1, documented upstream). On failure:
|
||||
`EstablishmentError` (the typed reason codes) → the wrapper tears the
|
||||
channel down and replies `channel:open_failed`.
|
||||
@@ -125,15 +126,15 @@ listener; a hub exposing its own port to a third party): the same
|
||||
open op, the same params; the establisher pops the next accepted
|
||||
connection from an assembly-owned listener queue and returns it as
|
||||
the plan payload. The listener lifecycle (bind, accept loop, cancel)
|
||||
is assembly code — the protocol never binds (OQ-TN-04). Error
|
||||
is assembly code; the protocol never binds (OQ-TN-04). Error
|
||||
mapping: an empty queue during establishment is `resource_shortage`;
|
||||
a closed listener is `dial_failed`-class. No new wire surface — the
|
||||
a closed listener is `dial_failed`-class. No new wire surface: the
|
||||
listen variant is plan-flow with a different halves source.
|
||||
|
||||
**The accept-first shape stays** (ADR-008 §5): for producers whose
|
||||
resource backing IS an accept queue (an externally-fed or pre-bound
|
||||
listener shared across opens), this remains the correct shape — the
|
||||
POC #5 composed-BIND shape. Its limits (no `bound` in the reply — the
|
||||
listener shared across opens), this remains the correct shape: the
|
||||
POC #5 composed-BIND shape. Its limits (no `bound` in the reply: the
|
||||
accept hasn't happened at establishment; the POC's
|
||||
establishment-bound-vs-wait problem) are inherent to accepting-first.
|
||||
|
||||
@@ -141,32 +142,32 @@ establishment-bound-vs-wait problem) are inherent to accepting-first.
|
||||
|
||||
For a producer that OWNS the bind decision (the common hub case;
|
||||
alksocks' composed BIND): the establisher **binds the listener** (via
|
||||
the assembly-provided listen function — the protocol never binds,
|
||||
the assembly-provided listen function; the protocol never binds,
|
||||
OQ-TN-04) and returns the listener handle as the plan payload.
|
||||
Establishment is fast — bounded by the bind, not by any app-server
|
||||
dial:
|
||||
Establishment is fast (bounded by the bind, not by any app-server
|
||||
dial):
|
||||
|
||||
- **The establishment phase ends at bind time**, so the open-op reply
|
||||
can carry the REAL OS-chosen address: the establisher contributes
|
||||
`"bound": {host, port}` (or `{path}`) to the reply — the
|
||||
`"bound": {host, port}` (or `{path}`) to the reply: the
|
||||
establisher → reply projection (ADR-049 amendment 3,
|
||||
`Establishment::with_reply_field`; the `channel_id` key is
|
||||
wrapper-reserved). **Landed in alkcall 0.8.0** (review 008 U-2);
|
||||
consumers read it via `ChannelClient::open_channel_with_reply`.
|
||||
- **The establisher mints the correlation token** (ADR-008
|
||||
Amendment 1): a fresh u32 per open, contributed as the `"listen"`
|
||||
reply field (the same projection) and carried in the plan — the
|
||||
accept dispatcher reads it from the plan and stamps it into each
|
||||
forwarded open's `listen` param. The plan carries
|
||||
`{listener, token, substrate}` (a bind-first plan type; the
|
||||
substrate is stamped at establisher time — the dispatcher reads it
|
||||
from the plan, never re-parses wire input); the token is
|
||||
relay-transparent by construction (reply fields and params both
|
||||
pass hubs untouched — alkcall ADR-051; ADR-008 Amendment 1 has the
|
||||
reply field (the same projection) and carried in the plan; the
|
||||
accept dispatcher reads it from the plan and stamps it into each
|
||||
forwarded open's `listen` param. The plan carries
|
||||
`{listener, token, substrate}` (a bind-first plan type; the
|
||||
substrate is stamped at establisher time, and the dispatcher reads it
|
||||
from the plan, never re-parses wire input); the token is
|
||||
relay-transparent by construction (reply fields and params both
|
||||
pass hubs untouched per alkcall ADR-051; ADR-008 Amendment 1 has the
|
||||
why-superseded of the channel-ID key).
|
||||
- **The pump handler for this shape is an accept dispatcher**, not
|
||||
`pump_bidi`: it accepts the channel's `BiStream` (the R-02
|
||||
contract), then runs the assembly-provided accept loop — each
|
||||
contract), then runs the assembly-provided accept loop; each
|
||||
accepted connection produces a forwarded-channel open toward the
|
||||
listen opener (§The Forwarded Op below); the accepted halves pump
|
||||
against the adopted channel. The listen channel's own `BiStream`
|
||||
@@ -178,22 +179,22 @@ dial:
|
||||
- The address-first-vs-accept-first BIND deadlock (the alksocks POC's
|
||||
one real discovery) DISSOLVES in this shape: the open completes at
|
||||
bind time, reply#1-class metadata (the `bound` field) precedes any
|
||||
app-server dial, and the accept wait lives in the pump phase —
|
||||
teardown-governed, not establishment-bounded (the 10s bound problem
|
||||
app-server dial, and the accept wait lives in the pump phase:
|
||||
teardown-governed, not establishment-bounded (the 10s bound problem
|
||||
dissolves with it).
|
||||
|
||||
**Identity:** the establisher receives the **per-call opener
|
||||
identity** (the dispatch-resolved identity overlaid onto the
|
||||
install-time context — alkcall 0.7.0 CF-006). On hub-forwarded opens
|
||||
install-time context, alkcall 0.7.0 CF-006). On hub-forwarded opens
|
||||
it sees the end client, not the hub. `auth` carries the opener's
|
||||
identity plus the transport-truthful fields (`alpn`, `remote_addr`,
|
||||
`tls_client_fingerprint`) — never rewritten. Consult it for
|
||||
`tls_client_fingerprint`); these are never rewritten. Consult it for
|
||||
ownership-scoped resources if the assembly layer wants
|
||||
`OwnershipProvider` checks here (OQ-TN-08's optional half).
|
||||
|
||||
**Concurrency:** each open's establisher result flows to ITS pump
|
||||
handler — concurrent opens of the same resource are independent
|
||||
(the R-01 plan flow — alkcall review 007 R-01, the
|
||||
handler; concurrent opens of the same resource are independent
|
||||
(the R-01 plan flow per alkcall review 007 R-01, the
|
||||
`Establishment::new(plan)` payload; the forward POC's resource-keyed
|
||||
handoff race is structurally gone, confirmed under concurrency by the
|
||||
reverse POC).
|
||||
@@ -205,45 +206,49 @@ The `OpenHandler` (alkcall ADR-049/050 shape:
|
||||
JoinHandle<()>`):
|
||||
|
||||
1. Downcast the plan to the concrete handle type
|
||||
(`Arc::downcast::<TargetHandle>` — establisher and handler agree
|
||||
(`Arc::downcast::<TargetHandle>`, so establisher and handler agree
|
||||
on the type; alkcall never inspects it).
|
||||
2. `conn.accept_bi()` — the channel's yield-once `BiStream`.
|
||||
3. `pump_bidi(bidi, handle.read, handle.write)` — **awaited inline**
|
||||
2. `conn.accept_bi()`: the channel's yield-once `BiStream`.
|
||||
3. `pump_bidi(bidi, handle.read, handle.write)`: **awaited inline**
|
||||
inside the handler's task. The returned `JoinHandle` MUST track
|
||||
the data-plane lifetime (R-02): the wrapper awaits it and its
|
||||
completion triggers channel teardown. **Early return =
|
||||
teardown-at-birth** — both pumps see instant EOF with zero bytes;
|
||||
teardown-at-birth**: both pumps see instant EOF with zero bytes;
|
||||
the "spawn-and-forget" shape is the one bug class the R-02
|
||||
telemetry (yield-once flag + birth-teardown debug hint) diagnoses
|
||||
at runtime. Hand-rolling the two-pump shape is out (ADR-050).
|
||||
4. UDP: the plan payload is the FRAMED adapter (ADR-003) — the pump
|
||||
4. UDP: the plan payload is the FRAMED adapter (ADR-003), so the pump
|
||||
is unchanged; the codec lives at the substrate/pump boundary.
|
||||
5. **Bind-first listen handler (the accept dispatcher, ADR-008 §1):**
|
||||
the plan payload is the listener; after `accept_bi()`, the handler
|
||||
runs the accept loop (assembly-provided) concurrently with holding
|
||||
the channel's `BiStream` — each accepted connection produces a
|
||||
the channel's `BiStream`; each accepted connection produces a
|
||||
forwarded-channel open toward the listen opener. The listen
|
||||
channel's own stream stays idle (no pumps on it); its EOF cancels
|
||||
the loop (the listener tears down with the session). Per-accept
|
||||
data planes are still `pump_bidi` (convention 8 — per-channel,
|
||||
data planes are still `pump_bidi` (convention 8, per-channel,
|
||||
never a hand-rolled loop over one stream).
|
||||
|
||||
**The handler is substrate-agnostic by construction:** it cannot know
|
||||
whether the halves came from TCP, UDP, a unix socket, or an
|
||||
in-process pipe, and must not (AGENTS.md convention 7).
|
||||
|
||||
## The Forwarded Op (opener side, ADR-008 §3)
|
||||
## The Forwarded Op (opener side, ADR-008 §3 as amended by Amendment 2)
|
||||
|
||||
Accept-as-open: when the bind-first listener accepts, the PRODUCER
|
||||
opens a channel back toward the listen opener. The opener SERVES the
|
||||
op (`channels/tunnel/forwarded`, scope `tunnel:forwarded` — ingress;
|
||||
no establisher, the data plane starts immediately):
|
||||
op (`channels/tunnel/forwarded`, scope `tunnel:forwarded`, ingress;
|
||||
Amendment 2's two serving shapes: the recommended correlation-gate
|
||||
establisher variant, where a stale/missing token fails the open as a
|
||||
typed `channel:open_failed`/`unknown_resource` before the channel
|
||||
is born, or the base establisher-less shape, where the data plane starts
|
||||
immediately and the assembly's post-open decline owns the miss):
|
||||
|
||||
1. The producer's per-accept open carries `{listen, substrate, peer}`
|
||||
— the correlation token from the plan (the value the establisher
|
||||
1. The producer's per-accept open carries `{listen, substrate, peer}`:
|
||||
the correlation token from the plan (the value the establisher
|
||||
minted; the opener matches it against the `listen` field of its
|
||||
open listener sessions — ADR-008 Amendment 1), the substrate
|
||||
(also from the plan — stamped at establisher time from the one
|
||||
open listener sessions per ADR-008 Amendment 1), the substrate
|
||||
(also from the plan, stamped at establisher time from the one
|
||||
successful params parse; the dispatcher never re-parses wire
|
||||
input), the accepted connection's remote address (reply#2-class
|
||||
fidelity's carrier).
|
||||
@@ -251,14 +256,19 @@ no establisher, the data plane starts immediately):
|
||||
adopts it (`TunnelSession::adopt`) and pumps the accepted halves
|
||||
against it (`pump_against`).
|
||||
3. The opener-side handler correlates the open (via `listen`) to the
|
||||
waiting session (assembly-side bookkeeping — the protocol provides
|
||||
waiting session (assembly-side bookkeeping; the protocol provides
|
||||
the op and params, not the wait state machine), accepts the
|
||||
`BiStream`, and pumps against the local session's halves.
|
||||
`BiStream`, and pumps against the local session's halves. Under
|
||||
the establisher variant (Amendment 2) that correlation lookup
|
||||
rides the gate at establisher time instead; the handler-side
|
||||
correlation remains only for the narrow residual race (a token
|
||||
vanishing between gate pass and handler run; the miss class is
|
||||
named `correlation-miss` at `debug`, assembly-side).
|
||||
4. Identity: the forwarded op's opener-side gate sees the PRODUCER's
|
||||
per-call identity (CF-005 precedence — the producer may carry an
|
||||
per-call identity (CF-005 precedence; the producer may carry an
|
||||
`auth_token` when it is itself forwarding); the peer address in
|
||||
params is producer-asserted data, not transport truth — the ACL
|
||||
trusts the producer's grant, the application treats `peer` as
|
||||
params is producer-asserted data, not transport truth; the ACL
|
||||
trusts the producer's grant, and the application treats `peer` as
|
||||
metadata.
|
||||
|
||||
## Registration API (the spec surface)
|
||||
@@ -269,7 +279,7 @@ pub fn register_tunnel_openable(
|
||||
registry: &ResourceRegistry, // assembly-owned: (resource, substrate) -> backing
|
||||
on_registry: &Arc<OperationRegistry>, // the session's dispatch registry
|
||||
// (the per-session fork per alkcall ADR-047 §4
|
||||
// amendment, 2026-09-03 — NOT the connection
|
||||
// amendment, 2026-09-03, NOT the connection
|
||||
// overlay; Layer-2-registered open ops resolve
|
||||
// NOT_FOUND, review 004 F-01)
|
||||
auth: AuthContext, // the install-time context (per-call overlay is upstream's)
|
||||
@@ -300,7 +310,7 @@ pub fn register_tunnel_bindfirst_openable( // bind-first (shape 3, ADR-008)
|
||||
- `ResourceRegistry` (assembly construct, OQ-TN-11): maps
|
||||
`(resource, substrate)` → backing. The POCs' `HashMap` shape is the
|
||||
candidate; the collision domain is the producer's registry
|
||||
(ADR-001 — two producers on one connection may expose the same
|
||||
(per ADR-001, two producers on one connection may expose the same
|
||||
resource name independently).
|
||||
- The scope constants `TUNNEL_OPEN_SCOPE = "tunnel:open"`,
|
||||
`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`, and
|
||||
@@ -311,7 +321,7 @@ pub fn register_tunnel_bindfirst_openable( // bind-first (shape 3, ADR-008)
|
||||
|
||||
## Access control posture
|
||||
|
||||
Tunnels reach local networks — the open gate is the security boundary
|
||||
Tunnels reach local networks; the open gate is the security boundary
|
||||
(OQ-TN-08, ADR-006 + Amendment 1):
|
||||
|
||||
- The registry runs `AccessControl` before the establisher (the ACL
|
||||
@@ -321,26 +331,26 @@ Tunnels reach local networks — the open gate is the security boundary
|
||||
`TUNNEL_FORWARDED_SCOPE` gates ingress (receiving forwarded
|
||||
connections) on the opener side. Three scopes, three capabilities.
|
||||
- The assembly layer MAY add resource-scoped checks via the
|
||||
establisher (ownership on the resource name) — the protocol
|
||||
establisher (ownership on the resource name); the protocol
|
||||
provides the seam, not the policy. For the direct op the seam is
|
||||
the parsed `TunnelTarget` (a deployment MAY enforce target
|
||||
allowlists in its establisher — policy, not protocol).
|
||||
allowlists in its establisher; policy, not protocol).
|
||||
- No target allowlists in the PROTOCOL, no tunnel-specific policy
|
||||
machinery in v1 — the far side owns the resource (OQ-TN-08
|
||||
machinery in v1, as the far side owns the resource (OQ-TN-08
|
||||
resolution); discovery is the ACL-filtered ops listing.
|
||||
- **Hub re-produce policy (the security model sentence, ADR-006
|
||||
Amendment 1):** a hub re-producing the direct op applies ITS OWN
|
||||
target policy at the re-produce hop — the hub's ACL governs the
|
||||
target policy at the re-produce hop; the hub's ACL governs the
|
||||
final dial. The hub is the network boundary.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **OQ-TN-11**: resource naming collision domain + lifecycle
|
||||
(partially resolved — the per-producer registry is the leading
|
||||
(partially resolved; the per-producer registry is the leading
|
||||
candidate)
|
||||
- **OQ-TN-14**: resolved (split: unix in, stdio out — alktty's pipe
|
||||
mode owns process stdio; see open-questions.md)
|
||||
- **OQ-TN-15/16**: resolved (ADR-007/008 — the direct op, listen
|
||||
- **OQ-TN-14**: resolved (split: unix in, stdio out, with alktty's pipe
|
||||
mode owning process stdio; see open-questions.md)
|
||||
- **OQ-TN-15/16**: resolved (ADR-007/008: the direct op, listen
|
||||
metadata, the forwarded op)
|
||||
|
||||
## References
|
||||
@@ -354,7 +364,7 @@ Tunnels reach local networks — the open gate is the security boundary
|
||||
- alkcall ADR-047/049 (open ops + establishment), ADR-050 (`pump_bidi`),
|
||||
ledger CF-005/CF-006 (identity precedence + per-call opener)
|
||||
- Upstream asks (review 008): `alkcall/docs/reviews/
|
||||
008-tunnels-graduation-upstream-review.md` — both landed in
|
||||
008-tunnels-graduation-upstream-review.md`; both landed in
|
||||
alkcall 0.8.0 (flavor-form discovery derivation, ADR-047
|
||||
amendment 3; the establisher → reply `bound` projection, ADR-049
|
||||
amendment 3), plus the in-tree `ChannelRelay`/`HubLegTemplate`
|
||||
|
||||
+67
-56
@@ -3,12 +3,12 @@ status: draft
|
||||
last_updated: 2026-09-20 (review 002 D-1/D-2 sync: the params schemas' port bounds, `additionalProperties: false` gates, `listen` `minimum: 1`, and the registry-side gate ordering recorded; ADR-007's oneOf wording corrected to the shipped allOf/if-then)
|
||||
---
|
||||
|
||||
# alktunnels — Wire Format
|
||||
# alktunnels: Wire Format
|
||||
|
||||
The `alk/tunnel` wire surface. Two layers: the **open op** (JSON
|
||||
params on the channels call plane — structured, schema-validated,
|
||||
params on the channels call plane: structured, schema-validated,
|
||||
typed errors) and the **data plane** (bytes/datagrams inside the
|
||||
channel `BiStream` — the codec, ADR-003). The channels layer strips
|
||||
channel `BiStream`, the codec, ADR-003). The channels layer strips
|
||||
its own 8-byte header transparently (alknet ADR-093); the tunnel
|
||||
protocol owns everything inside the `BiStream`.
|
||||
|
||||
@@ -21,7 +21,7 @@ this document is the normative WHAT.
|
||||
|
||||
`Sub`-typed operation (alkcall ADR-047): the reply carries
|
||||
`{ "channel_id": <u32> }` once; the data plane flows on the channel's
|
||||
`BiStream` — never in the call response stream.
|
||||
`BiStream`, never in the call response stream.
|
||||
|
||||
### Input (params)
|
||||
|
||||
@@ -32,33 +32,33 @@ this document is the normative WHAT.
|
||||
}
|
||||
```
|
||||
|
||||
- `resource`: string, required — the producer's stable resource name
|
||||
- `resource`: string, required: the producer's stable resource name
|
||||
(NOT an address; the producer's registry maps it to its backing).
|
||||
- `substrate`: string, required, enum `["tcp", "udp", "unix"]` — the
|
||||
- `substrate`: string, required, enum `["tcp", "udp", "unix"]`: the
|
||||
extensible discriminator (ADR-001); selects the data-plane framing.
|
||||
The schema ships with all three values in v1 (unix has an
|
||||
implementation path — OQ-TN-14). An older producer rejecting a
|
||||
newer substrate value is the SSH "unknown channel type" posture —
|
||||
implementation path, OQ-TN-14). An older producer rejecting a
|
||||
newer substrate value is the SSH "unknown channel type" posture:
|
||||
loud, not silent.
|
||||
- `input_schema` pins both as required strings; unknown substrate
|
||||
values fail schema validation (typed error — loud, not silent).
|
||||
values fail schema validation (typed error, loud, not silent).
|
||||
- Extensibility: a new substrate is a new enum value, not a format
|
||||
change. An older producer rejecting a newer substrate is the SSH
|
||||
"unknown channel type" posture.
|
||||
|
||||
### Reply / errors
|
||||
|
||||
- Success: `{ "channel_id": <u32> }` — the channel is open, the
|
||||
- Success: `{ "channel_id": <u32> }`: the channel is open, the
|
||||
establishment phase (dial) succeeded, the pump handler is running.
|
||||
Bind-first listen opens MAY add `"bound"` (§The Listen Open Op's
|
||||
Reply below).
|
||||
- Failure: a typed `channel:open_failed` call error with
|
||||
`details: { reason, message }` — `reason ∈ dial_failed` /
|
||||
`details: { reason, message }` with `reason ∈ dial_failed` /
|
||||
`unknown_resource` / `resource_shortage` / `handler_error` /
|
||||
`timeout` (alkcall ADR-049 §3). The registry's ACL gate runs before
|
||||
the wrapper (`FORBIDDEN` for a scope-less identity); the 256-channel
|
||||
cap is `channel:too_many_channels`. **A failed open never returns a
|
||||
`channel_id`** — no phantom channel (the SSH contract, POC-verified
|
||||
`channel_id`**; no phantom channel (the SSH contract, POC-verified
|
||||
from the consumer side).
|
||||
- The scope gate: the open op's `AccessControl.required_scopes` is
|
||||
`["tunnel:open"]` (the `TUNNEL_OPEN_SCOPE` constant). Caller
|
||||
@@ -69,10 +69,10 @@ this document is the normative WHAT.
|
||||
|
||||
## The Direct Op (`channels/tunnel/direct`)
|
||||
|
||||
Dynamic-target egress (ADR-007) — "dial what I name," not "dial what
|
||||
Dynamic-target egress (ADR-007): "dial what I name," not "dial what
|
||||
the producer registered." `Sub`-typed, ALPN marker `alk/tunnel`; the
|
||||
same establishment phase and wrapper as `…/sub`. Scope gate:
|
||||
`["tunnel:direct"]` (`TUNNEL_DIRECT_SCOPE`) — **never implied by
|
||||
`["tunnel:direct"]` (`TUNNEL_DIRECT_SCOPE`), **never implied by
|
||||
`tunnel:open`** (ADR-006 Amendment 1).
|
||||
|
||||
### Input (params)
|
||||
@@ -84,42 +84,42 @@ same establishment phase and wrapper as `…/sub`. Scope gate:
|
||||
}
|
||||
```
|
||||
|
||||
- `substrate`: string, required, enum `["tcp", "udp", "unix"]` — the
|
||||
- `substrate`: string, required, enum `["tcp", "udp", "unix"]`: the
|
||||
same discriminator semantics as `…/sub` (ADR-001); selects the
|
||||
data-plane framing.
|
||||
- `target`: object, required, keyed by the sibling `substrate` —
|
||||
- `target`: object, required, keyed by the sibling `substrate`:
|
||||
- `tcp`/`udp` → `{ "host": <string>, "port": <u16> }` (domain-form
|
||||
hosts stay unresolved on the wire — producer-side resolution, the
|
||||
hosts stay unresolved on the wire; producer-side resolution, the
|
||||
ssh `-D` semantic);
|
||||
- `unix` → `{ "path": <string> }`.
|
||||
- `input_schema` pins both required; the target sub-schema is
|
||||
per-substrate — an `allOf` of two `if`/`then` branches keyed on the
|
||||
per-substrate: an `allOf` of two `if`/`then` branches keyed on the
|
||||
sibling `substrate` value, validated **registry-side** (the schema
|
||||
gate runs before the establisher — the behavior
|
||||
gate runs before the establisher, as the behavior
|
||||
`malformed_target_is_schema_class_and_leaves_no_channel` pins; the
|
||||
establisher's serde parse re-checks as the backstop). Malformed
|
||||
targets are the `invalid_input`-class failure; **`unknown_resource`
|
||||
can never fire on the direct op** (no registry lookup).
|
||||
- Schema bounds (wire-stable — the target sub-schema is part of the
|
||||
- Schema bounds (wire-stable; the target sub-schema is part of the
|
||||
one-way door, ADR-007 §"Wire-stable"): each branch's target object
|
||||
carries `"type": "object"` and `"additionalProperties": false` —
|
||||
carries `"type": "object"` and `"additionalProperties": false`:
|
||||
- `tcp`/`udp` → `{ "host": {"type": "string"}, "port": {"type":
|
||||
"integer", "minimum": 0, "maximum": 65535} }`, both required;
|
||||
- `unix` → `{ "path": {"type": "string"} }`, required.
|
||||
|
||||
The `additionalProperties: false` gate is what makes a cross-shape
|
||||
target (a `unix` open carrying `host`) fail the registry's
|
||||
`INVALID_INPUT` class *before* the establisher — not a serde-level
|
||||
`INVALID_INPUT` class *before* the establisher, not a serde-level
|
||||
quirk.
|
||||
|
||||
### Reply / errors
|
||||
|
||||
- Success: `{ "channel_id": <u32> }` — identical semantics to
|
||||
- Success: `{ "channel_id": <u32> }`: identical semantics to
|
||||
`…/sub`; no `bound` field (dial establishers do not bind).
|
||||
- Failure: the same typed surface as `…/sub` minus `unknown_resource`,
|
||||
plus the schema/`invalid_input` class for malformed targets.
|
||||
|
||||
## The Listen Open Op's Reply — the `bound` and `listen` Fields
|
||||
## The Listen Open Op's Reply: the `bound` and `listen` Fields
|
||||
|
||||
A bind-first listen open (ADR-008 §1, served by `…/sub` with the
|
||||
bind-first establisher) MAY add optional, additive reply fields:
|
||||
@@ -133,16 +133,16 @@ bind-first establisher) MAY add optional, additive reply fields:
|
||||
(`{host, port}` / `{path}`); the REAL OS-chosen address (port `0`
|
||||
resolved).
|
||||
- `listen` is the correlation token (ADR-008 Amendment 1): a u32 the
|
||||
establisher minted, unique per open on that producer — the key the
|
||||
establisher minted, unique per open on that producer; the key the
|
||||
forwarded op's params carry (§The Forwarded Op below). Riding the
|
||||
reply is what makes it relay-transparent: reply fields pass through
|
||||
hub re-produce untouched (alkcall ADR-051), and the same minted
|
||||
token reappears in every hop's reply.
|
||||
- Present only when the establisher supplies them; absent for dial
|
||||
establishers and accept-first listen opens. Old consumers ignore
|
||||
unknown reply fields (plain JSON) — wire-additive.
|
||||
unknown reply fields (plain JSON), which is wire-additive.
|
||||
- `bound` is informational surface, not addressing: a consumer MUST
|
||||
NOT treat `bound` as a dial target (the producer owns the backing —
|
||||
NOT treat `bound` as a dial target (the producer owns the backing,
|
||||
ADR-001). It exists for RFC-1928-reply-class fidelity (BND.ADDR)
|
||||
and observability.
|
||||
|
||||
@@ -150,12 +150,17 @@ bind-first establisher) MAY add optional, additive reply fields:
|
||||
|
||||
Accept-as-open (ADR-008 §3): when a bind-first listener accepts, the
|
||||
PRODUCER opens a channel back toward the listen-opener side. The
|
||||
producer *calls* the op (the opener SERVES it — the ADR-047 §5
|
||||
producer *calls* the op (the opener SERVES it, per the ADR-047 §5
|
||||
connection-owner rule: the serving side allocates the `channel_id`,
|
||||
the producer-side initiator adopts). `Sub`-typed, ALPN marker
|
||||
`alk/tunnel`; no establisher (the data plane starts immediately).
|
||||
`alk/tunnel`; the serving side registers establisher-less by base
|
||||
shape (the data plane starts immediately) or (the recommended shape,
|
||||
ADR-008 Amendment 2) with the correlation-gate establisher variant
|
||||
(a stale/missing token then fails the op as a typed
|
||||
`channel:open_failed` with `details.reason = "unknown_resource"`; the
|
||||
wire vocabulary is unchanged by the amendment).
|
||||
Scope gate (opener side): `["tunnel:forwarded"]`
|
||||
(`TUNNEL_FORWARDED_SCOPE`) — ingress, a separate grant from
|
||||
(`TUNNEL_FORWARDED_SCOPE`): ingress, a separate grant from
|
||||
`tunnel:open`/`tunnel:direct` (ADR-006 Amendment 1).
|
||||
|
||||
### Input (params)
|
||||
@@ -168,23 +173,23 @@ Scope gate (opener side): `["tunnel:forwarded"]`
|
||||
}
|
||||
```
|
||||
|
||||
- `listen`: integer > 0, required — the correlation token from the
|
||||
- `listen`: integer > 0, required: the correlation token from the
|
||||
listen open's `listen` reply field (ADR-008 Amendment 1; the
|
||||
producer-minted token, relay-transparent). The opener correlates
|
||||
the accept to its waiting listener session by matching it. The
|
||||
schema pins `"minimum": 1` and the serde type pins it as the
|
||||
backstop (a `listen: 0` on the wire is a malformed open, rejected
|
||||
at parse time — never silently accepted).
|
||||
- `substrate`: string, required, enum — the listen tunnel's
|
||||
at parse time, never silently accepted).
|
||||
- `substrate`: string, required, enum: the listen tunnel's
|
||||
substrate; shapes the forwarded channel's data-plane framing.
|
||||
- `peer`: object, required — the accepted connection's remote address,
|
||||
- `peer`: object, required: the accepted connection's remote address,
|
||||
substrate-shaped (`{host, port}` / `{path}`). Reply#2-class
|
||||
fidelity's carrier (ask #2).
|
||||
- Schema bounds (wire-stable, mirroring the direct op's target
|
||||
sub-schema): the per-substrate `peer` sub-schema is an `allOf` of
|
||||
two `if`/`then` branches keyed on the sibling `substrate`, each
|
||||
branch's object carrying `"type": "object"` and
|
||||
`"additionalProperties": false` —
|
||||
branch's object carrying `"type": "object"` and
|
||||
`"additionalProperties": false`:
|
||||
- `tcp`/`udp` → `{ "host": {"type": "string"}, "port": {"type":
|
||||
"integer", "minimum": 0, "maximum": 65535} }`, both required;
|
||||
- `unix` → `{ "path": {"type": "string"} }`, required.
|
||||
@@ -194,16 +199,22 @@ Scope gate (opener side): `["tunnel:forwarded"]`
|
||||
|
||||
### Reply / errors
|
||||
|
||||
- Success: `{ "channel_id": <u32> }` — the opener-allocated ID the
|
||||
- Success: `{ "channel_id": <u32> }`: the opener-allocated ID the
|
||||
producer adopts, then pumps the accepted halves against.
|
||||
- Failure: a producer-side accept correlation miss is the typed
|
||||
`channel:open_failed`/`handler_error`-class error; an opener without
|
||||
- Failure: a stale/missing correlation token is the typed
|
||||
`channel:open_failed` with `details.reason = "unknown_resource"`
|
||||
(the establisher variant, ADR-008 Amendment 2, which is the
|
||||
recommended serving shape; the miss never births a channel). An opener without
|
||||
the scope is `FORBIDDEN`; an old opener that does not serve the op
|
||||
rejects loudly (`NOT_FOUND` — the SSH posture).
|
||||
rejects loudly (`NOT_FOUND`: the SSH posture). Other open failures
|
||||
ride the existing typed vocabulary. Under the base (establisher-
|
||||
less) serving shape an in-session miss produces no call error; it
|
||||
is the assembly-side post-open decline (the channel reaps at
|
||||
birth, `correlation-miss` at `debug`).
|
||||
|
||||
## The Data Plane
|
||||
|
||||
One data stream per direction on one channel — the channel ID is the
|
||||
One data stream per direction on one channel: the channel ID is the
|
||||
flow key (no conn-id, no sub-demux). Two codecs by substrate
|
||||
(ADR-003):
|
||||
|
||||
@@ -214,7 +225,7 @@ overhead is the channels 8-byte chunk header. A zero-length read is
|
||||
genuinely EOF (stream semantics).
|
||||
|
||||
Both pumps run `alkcall::channels::pump_bidi(channel, peer_read,
|
||||
peer_write)` — the two-pump contract (alknet ADR-078, pinned upstream
|
||||
peer_write)`, the two-pump contract (alknet ADR-078, pinned upstream
|
||||
ADR-050): when one direction's source EOFs, the pump shuts the
|
||||
OPPOSITE sink down (the EOF sentinel crosses the mux; the peer sees a
|
||||
clean half-close), and the helper completes when both pumps finish,
|
||||
@@ -223,15 +234,15 @@ returning `(u64, u64)` copy counts. Copy errors are EOF-shaped
|
||||
|
||||
### Datagram substrate (`udp`): mandatory length framing
|
||||
|
||||
Every datagram rides `[len: u16 BE][datagram]` on BOTH directions —
|
||||
mandatory for correctness (the F-2 rationale lives in ADR-003; the
|
||||
Every datagram rides `[len: u16 BE][datagram]` on BOTH directions;
|
||||
this is mandatory for correctness (the F-2 rationale lives in ADR-003; the
|
||||
short form: raw pass-through cannot carry an empty datagram). Under
|
||||
the codec:
|
||||
|
||||
- **Framing (`frame_datagram`):** `len: u16 BE` + payload. Datagrams
|
||||
> 65535 bytes are rejected at frame time (`Oversize`) — never a
|
||||
> 65535 bytes are rejected at frame time (`Oversize`), never a
|
||||
wire overflow (the u16 length field would wrap).
|
||||
- **Decoding (`DatagramReader`):** incremental — datagrams split
|
||||
- **Decoding (`DatagramReader`):** incremental; datagrams split
|
||||
across channel chunks, batch into single chunks, and survive
|
||||
partial headers. The decoder buffers state across reads; it is the
|
||||
only consumer-visible decode path.
|
||||
@@ -242,7 +253,7 @@ the codec:
|
||||
returns `Some(bytes)` per datagram (possibly empty), `None` only on
|
||||
stream EOF.
|
||||
- **Chunk-size independence:** datagram boundaries survive chunk
|
||||
splitting, batching, and any channels-layer re-chunking — verified
|
||||
splitting, batching, and any channels-layer re-chunking, verified
|
||||
end-to-end at 7-byte chunk splits (forward POC).
|
||||
- **MTU discipline:** 1400-byte datagrams (a conservative MTU-safe
|
||||
payload, not the theoretical 1472 max) ride the bounded-buffer path;
|
||||
@@ -251,10 +262,10 @@ the codec:
|
||||
- **Truncation:** silent truncation never happens (OQ-TN-13, resolved
|
||||
fail-loud per ADR-003): the `local` adapter recvs on a full
|
||||
65535-byte scratch (UDP `try_recv` truncates silently at the buffer
|
||||
size — a smaller scratch would corrupt the framing) and a
|
||||
size; a smaller scratch would corrupt the framing) and a
|
||||
>caller-buffer frame is staged across reads, the length prefix
|
||||
intact, so the far-side de-framer reassembles the true length. A
|
||||
wire-level over-bound datagram (>65535 — impossible for a
|
||||
wire-level over-bound datagram (>65535, impossible for a
|
||||
well-formed peer) is a loud error at the frame path.
|
||||
|
||||
### Sentinels and lifecycle (both codecs)
|
||||
@@ -271,10 +282,10 @@ the codec:
|
||||
idle-expiry mapping is a producer-registry concern, not wire).
|
||||
- **No establishment frame.** The open op's reply IS the
|
||||
establishment result (ADR-049); the pre-ADR-049
|
||||
"JSON ack before data" hunch is superseded — the wire carries zero
|
||||
"JSON ack before data" hunch is superseded; the wire carries zero
|
||||
tunnel-level control framing in v1.
|
||||
- **BAST:** the binary framing (the UDP codec) carries its BAST
|
||||
document — [bast.md](bast.md) (AGENTS.md convention 12; the
|
||||
document in [bast.md](bast.md) (AGENTS.md convention 12; the
|
||||
stream pass-through has no binary framing to describe).
|
||||
|
||||
## Normative byte diagrams
|
||||
@@ -296,11 +307,11 @@ UDP substrate, one direction's wire view:
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **OQ-TN-13**: resolved (fail-loud, ADR-003) — see the Truncation
|
||||
- **OQ-TN-13**: resolved (fail-loud, ADR-003); see the Truncation
|
||||
bullet
|
||||
- **OQ-TN-02**: resolved — see the ADR-003 mandate
|
||||
- **OQ-TN-15**: resolved — see ADR-007 (the direct op)
|
||||
- **OQ-TN-16**: resolved — see ADR-008 (the `bound`/`listen` fields +
|
||||
- **OQ-TN-02**: resolved; see the ADR-003 mandate
|
||||
- **OQ-TN-15**: resolved; see ADR-007 (the direct op)
|
||||
- **OQ-TN-16**: resolved; see ADR-008 (the `bound`/`listen` fields +
|
||||
the forwarded op)
|
||||
|
||||
## References
|
||||
@@ -322,5 +333,5 @@ UDP substrate, one direction's wire view:
|
||||
ADR-050 (`pump_bidi`); alknet ADR-071/093 (channels wire)
|
||||
- `docs/research/tunnels-graduation.md` (the graduation research the
|
||||
new ops implement); the upstream asks (review 008) landed in
|
||||
alkcall 0.8.0 — see
|
||||
alkcall 0.8.0; see
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
|
||||
@@ -0,0 +1,142 @@
|
||||
# Plan: resolve review 003 (the correlation-miss arrival class)
|
||||
|
||||
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, which extend the review and do not contradict it).
|
||||
|
||||
## Verified findings (the ground the plan stands on)
|
||||
|
||||
The review's claims held under code verification. Two additions sharpen
|
||||
the resolution scope:
|
||||
|
||||
- **A third vocabulary variant.** alksocks (`src/composed/mod.rs:497`)
|
||||
logs the same miss class as prose at `warn` ("forwarded open with no
|
||||
waiting session"). So the family names the stale-token miss three
|
||||
ways: alkssh `correlation-miss` (debug), alksocks prose (warn),
|
||||
alktunnels `handler_error`-class generic arm (warn, call-plane
|
||||
failures only).
|
||||
- **The in-session miss is fully silent on the fabric side.** The
|
||||
forwarded op is served establisher-less everywhere (alkssh,
|
||||
alksocks, and this crate's own built-in shape), so an in-session
|
||||
correlation miss (route removed while the fabric connection lives)
|
||||
succeeds at the call plane, declines post-open in the assembly, and
|
||||
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, 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
|
||||
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
|
||||
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
|
||||
vocabulary (`wire.md` §The Open Ops, `details.reason ∈`)
|
||||
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,
|
||||
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
|
||||
registration surface for the forwarded op. The base
|
||||
`register_tunnel_forwarded_openable` shape stays (compatibility +
|
||||
assemblies that prefer the post-open shape); the establisher
|
||||
variant is the recommended shape and new assemblies take it by
|
||||
default posture.
|
||||
|
||||
## Work items
|
||||
|
||||
### 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
|
||||
(decision 1-4 above), the residual post-open decline keeps the
|
||||
`correlation-miss` name. Also record the tri-vocabulary history the
|
||||
decision retires (review 003 + this plan).
|
||||
- API: an establisher variant for the forwarded-op serving half. The
|
||||
protocol cannot hold the assembly's correlation map, so the
|
||||
establisher's check is injected. Candidate shape (a W1 design
|
||||
decision to finalize): a small assembly gate closure
|
||||
(`(TunnelForwardedParams) -> Result<(), TunnelEstablishError>`) that
|
||||
the crate wraps into the alkcall `OpenEstablisher` dance, plus
|
||||
`register_tunnel_forwarded_openable_with_establisher(core,
|
||||
on_registry, auth, gate, handler)`. Assemblies must not hand-roll
|
||||
the alkcall establisher plumbing.
|
||||
- Tests: miss at the gate → `channel:open_failed` with reason
|
||||
`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).
|
||||
- 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)
|
||||
|
||||
- Move `pump_forwarded_arrival`'s route lookup into the new gate
|
||||
(the `ListenRouter` lookup becomes the establisher's check); the
|
||||
residual handler keeps `decline_arrival("correlation-miss", …)` 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; producer-side generic arm 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
|
||||
("cleanup rides the code-update release"; the user-owned task, out
|
||||
of this plan).
|
||||
|
||||
### 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
|
||||
logging aligns to `correlation-miss` at `debug`).
|
||||
- Fold alksocks' em-dash doc cleanup into its release for this change
|
||||
(user-owned, out of this plan).
|
||||
|
||||
## Ordering
|
||||
|
||||
W1 → {W2, W3} (the dependent waves can run in parallel; each is its
|
||||
own session). Em-dash cleanup rides each repo's release for its wave
|
||||
and is otherwise out of scope here.
|
||||
|
||||
## Acceptance criteria (plan level)
|
||||
|
||||
- [x] W1: ADR landed; `…_with_establisher` surface landed; tests green
|
||||
(`cargo test`, clippy `--all-targets -D warnings`, fmt, wasm
|
||||
check; `--all-features` as far as features exist today); review
|
||||
003 status updated to resolved (W1 absorbed; W2/W3 pointed).
|
||||
- [ ] W2: alkssh serves the gate shape; stale-token arrival produces a
|
||||
typed call-plane failure; residual class named
|
||||
`correlation-miss` at `debug`; alkssh release includes its doc
|
||||
cleanup.
|
||||
- [ ] W3: alksocks serves the gate shape; prose variant retired;
|
||||
residual logging aligned; release includes its doc cleanup.
|
||||
- [ ] The family names the class one way in its event surface;
|
||||
composed `-R` traces read coherently end to end.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md`
|
||||
(the ask; this plan resolves it)
|
||||
- `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)
|
||||
- `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)
|
||||
@@ -0,0 +1,596 @@
|
||||
# Fuzzing alktunnels: Proposal and Plan
|
||||
|
||||
**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
|
||||
child of an agent session** (§5).
|
||||
**Date:** 2026-09-28
|
||||
**Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
|
||||
(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
|
||||
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
|
||||
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);
|
||||
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.
|
||||
|
||||
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
|
||||
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),
|
||||
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
|
||||
code-review candidate, the analog of alkcall §6.2-1 (the
|
||||
early-arrival key-space): `DatagramHalves.pending`
|
||||
(`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
|
||||
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
|
||||
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,
|
||||
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
|
||||
targets become regression protection for a wire-stable format
|
||||
(ADR-003).
|
||||
|
||||
## 2. Toolchain (inherited, not re-decided)
|
||||
|
||||
Identical to alkcall §4: **cargo-fuzz 0.13.2 + `arbitrary` 1.4.x**,
|
||||
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).
|
||||
|
||||
**Layout (the alkcall pattern, load-bearing):**
|
||||
|
||||
```
|
||||
fuzz/
|
||||
├── Cargo.toml alktunnels-fuzz (nightly-only bins; own [workspace])
|
||||
├── rust-toolchain.toml pins nightly for this subtree only
|
||||
├── fuzz_targets/ thin fuzz_target! wrappers (3-line each)
|
||||
│ ├── datagram_codec.rs
|
||||
│ ├── params_parse.rs
|
||||
│ └── session_pumps.rs
|
||||
├── shared/ alktunnels-fuzz-shared, the STABLE-toolchain library
|
||||
│ └── src/… invariant logic + corpus replay (§3)
|
||||
├── corpus/<target>/ committed hand-made seeds (§3)
|
||||
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
|
||||
├── run-detached.sh §6 detached runner (CWD-independent)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
- **Invariant logic lives in `fuzz/shared/`**, not inline in the target
|
||||
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
|
||||
`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/`
|
||||
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
|
||||
(quinn/h2 model). Seeds per target: valid frames, truncation at every
|
||||
short prefix length, `len = 0` / `len = MAX` / `len = MAX+1`, empty
|
||||
datagrams, batching (two frames per chunk), splitting (one frame per
|
||||
many chunks), and for params: every substrate value, unknown
|
||||
substrate, unknown/missing fields, `listen: 0`, malformed reply
|
||||
shapes, untagged cross-shape targets, deep/invalid JSON.
|
||||
|
||||
## 3. Invariants by target
|
||||
|
||||
### 3.1 `datagram_codec` (wave 1)
|
||||
|
||||
Input: raw `&[u8]` (and in the shared logic, chunk-slice parameter
|
||||
arbitrary). Drives `DatagramReader::feed` + `frame_datagram`
|
||||
(`src/wire.rs`). Invariants:
|
||||
|
||||
- **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
|
||||
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
|
||||
target pins it against future refactors).
|
||||
- **no partial yields**: a datagram completes only when exactly
|
||||
`declared` payload bytes are buffered (`is_mid_datagram` observable
|
||||
consistency); `feed(&[])` never yields anything and never clears
|
||||
mid-datagram state.
|
||||
- **round-trip**: `feed(frame_datagram(x)) == [x]` for any x; the
|
||||
reverse direction `write(feed-parse(x))` re-frames to the same
|
||||
frame for any well-framed input.
|
||||
- **F-2 sentinel layering**: `len == 0` is a legal empty datagram,
|
||||
never EOF; the codec never emits zero-length datagrams for non-empty
|
||||
frames and never treats an empty datagram as stream end.
|
||||
- **consumption accounting**: re-framing all consumed bytes and
|
||||
feeding the byte-level remainder yields the same datagram stream
|
||||
(splitting a feed at any boundary is transparent).
|
||||
- **oversize frame rejection**: `frame_datagram` errors iff payload >
|
||||
`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, 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
|
||||
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
|
||||
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
|
||||
by construction + assertion rather than remediated; the
|
||||
`datagram_backlog()` diagnostic exposes the live count.)
|
||||
|
||||
### 3.2 `params_parse` (wave 1)
|
||||
|
||||
Input: raw bytes → `serde_json` (with alkcall's bounded
|
||||
`arbitrary_value` approach, depth/width-capped `Value` generation) →
|
||||
`TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` /
|
||||
`SubstrateAddr` deserialization + `parse_listen_reply` /
|
||||
`listen_reply_fields` (`src/params.rs`). Invariants:
|
||||
|
||||
- **no-panic on any JSON-shaped input** (untagged-enum serde dispatch,
|
||||
`deny_unknown_fields`, the `positive_u32` pin).
|
||||
- **clean failure only**: parse failures are `Err`, never panics;
|
||||
`parse_listen_reply` returns `TunnelError::MalformedReply` on
|
||||
malformed shapes.
|
||||
- **round-trip for valid params**: `from_json(to_json(p)) == p` for
|
||||
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").
|
||||
- **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
|
||||
construction (the lenient view projects malformed to both-`None`).
|
||||
- **establishment-reason surface**: `establishment_reason` never
|
||||
panics on arbitrary `ChannelOpenError` shapes.
|
||||
|
||||
### 3.3 `session_pumps` (wave 2)
|
||||
|
||||
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:
|
||||
`Route` (producer→consumer framed datagrams / raw stream bytes via
|
||||
`route_payload`), `AdoptDatagram` / `AdoptStream` (the
|
||||
`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`,
|
||||
`ClearAll`. Invariants:
|
||||
|
||||
- **no-panic / no-wedge on any op sequence** (the baseline; the
|
||||
harness's 5 s reconcile bound turns a wedged pump into a finding,
|
||||
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,
|
||||
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, 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).
|
||||
- **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
|
||||
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
|
||||
wave-1 decoder-state bound re-asserted per op alongside.)
|
||||
- **monotonicity**: the manager's early-arrival / dropped counters
|
||||
survive `clear_all` (the alkcall §7.7 pattern), and the consumer
|
||||
map empties on the transport-EOF path.
|
||||
|
||||
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).
|
||||
|
||||
## 4. Scope: two waves
|
||||
|
||||
**Wave 1 (complete):** the two targets above. Both drive already-
|
||||
`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
|
||||
target over the two-pump tunnel shape in the alkcall `manager_routing`
|
||||
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
|
||||
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
|
||||
assembly-side correlation registry (ADR-008 §3, assembly
|
||||
bookkeeping) grows into this crate.
|
||||
|
||||
Sequencing mirrors alkcall §7.4: init + wave-1 targets → 10–30 min
|
||||
campaigns per target via the detached runner → fix anything found →
|
||||
wave 2 (`session_pumps`) → further targets when justified.
|
||||
|
||||
## 5. Operational isolation: campaigns never share fate with the agent session
|
||||
|
||||
**This is an environment constraint, not a nice-to-have** (upstream
|
||||
doc §7.6, adopted verbatim as policy here). Agent sessions (opencode)
|
||||
run fuzzer builds/campaigns through a bash tool that spawns the fuzzer
|
||||
as a **child of the session's own process tree and cgroup**. A fuzz
|
||||
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
|
||||
in the cgroup, which can be the opencode server hosting the session.
|
||||
That is *recoverable* (sessions are recorded in a DB) but hugely
|
||||
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**:
|
||||
|
||||
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, 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, 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, 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, since it
|
||||
resolves its
|
||||
own repo root):
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Detached fuzzing runner for agent sessions: the campaign never runs
|
||||
# as a foreground child of the session (an OOM in a target must cost
|
||||
# the fuzzer, never the agent host), and survives the session ending.
|
||||
set -euo pipefail
|
||||
target="${1:?usage: run-detached.sh <target> [extra libfuzzer args...]}"
|
||||
shift
|
||||
runtime="${FUZZ_RUNTIME_SECS:-600}"
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
log_dir="$root/fuzz/artifacts"
|
||||
mkdir -p "$log_dir"
|
||||
log="$log_dir/${target}-$(date -u +%Y%m%d-%H%M%S).log"
|
||||
cd "$root"
|
||||
setsid nohup cargo fuzz run "$target" -- \
|
||||
-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 \
|
||||
-max_total_time="$runtime" "$@" \
|
||||
>"$log" 2>&1 < /dev/null &
|
||||
echo "pid=$! log=$log"
|
||||
```
|
||||
|
||||
**Agent polling protocol** (never `wait` on the detached process from a
|
||||
tool call; poll instead):
|
||||
|
||||
- `tail -n 50 fuzz/artifacts/<target>-*.log`: campaign progress.
|
||||
- `ls fuzz/artifacts/`: `crash-*` / `oom-*` / `timeout-*` / `leak-*`
|
||||
artifacts are the findings.
|
||||
- `pgrep -f "cargo fuzz run <target>"`: still running?
|
||||
- One detached campaign per target at a time; log filenames carry the
|
||||
UTC timestamp.
|
||||
|
||||
**Flags worth pinning beyond the runner defaults**: `-max_len=65536`
|
||||
on the codec target; `-use_value_profile=1`; a JSON token `-dict` for
|
||||
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:
|
||||
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
|
||||
verification checklist (AGENTS.md). Campaigns run manually via the
|
||||
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
|
||||
`datagram_codec` + `params_parse`; wave-2 deferred (§4).
|
||||
- **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
|
||||
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
|
||||
§2 (nightly-pinned subtree, stable `fuzz/shared` library, runner,
|
||||
dict, generator); `datagram_codec` + `params_parse` targets over
|
||||
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 +
|
||||
69 generated (fuzz/gen_fuzz_seeds.py) + 1 campaign-found crash
|
||||
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
|
||||
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, 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, 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,
|
||||
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`,
|
||||
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):
|
||||
`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;
|
||||
`-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,
|
||||
`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
|
||||
at budget end, so longer campaigns keep paying here.
|
||||
- `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
|
||||
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,
|
||||
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, 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 <target>"` 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
|
||||
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
|
||||
"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, since
|
||||
the
|
||||
wave-1 targets assert the decoder, not the session pump.
|
||||
- **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
|
||||
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, 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
|
||||
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
|
||||
that the p2c completion shuts the channel side down (the
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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),
|
||||
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, 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
|
||||
rewrite (the old hex patterns decode against the new op enum);
|
||||
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
|
||||
first minutes): the two ClearAll modeling defects.** (5) A
|
||||
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
|
||||
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
|
||||
harness now retires EVERY session on `ClearAll`.
|
||||
- **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
|
||||
live two-manager stack with real mux/demux tasks), 32 fork jobs,
|
||||
`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'), 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
|
||||
layering). The §1.3 backlog bound held everywhere: the
|
||||
`DatagramHalves.pending` queue never exceeded the
|
||||
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:
|
||||
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)
|
||||
|
||||
## 8. Verification commands
|
||||
|
||||
```bash
|
||||
cargo test # main suite (stable)
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay (fuzz gate)
|
||||
cargo fuzz build # in fuzz/ (nightly, ASAN)
|
||||
fuzz/run-detached.sh <target> # campaigns (never foreground)
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
cargo clippy --manifest-path fuzz/shared/Cargo.toml --all-targets -- -D warnings
|
||||
cargo fmt --manifest-path fuzz/shared/Cargo.toml --check
|
||||
cargo check --target wasm32-unknown-unknown # default crate stays wasm-clean
|
||||
```
|
||||
|
||||
Campaign flags per target: `datagram_codec -max_len=65536`;
|
||||
`params_parse -dict=json.dict -use_value_profile=1`;
|
||||
`session_pumps -max_len=4096 -use_value_profile=1`.
|
||||
|
||||
## 9. References
|
||||
|
||||
- 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
|
||||
- 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'
|
||||
fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust
|
||||
Fuzz Book (all linked from the upstream doc's §9)
|
||||
+161
-158
@@ -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": <id>, "substrate": "tcp" | "udp" | <extensible> }`.
|
||||
|
||||
### 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<dyn Any + Send + Sync>`; 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/<task-id>/`
|
||||
@@ -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)
|
||||
@@ -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<HashMap<resource, SubstrateHandle>>` + 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<Value> }` or a
|
||||
result. That is the recommended upstream evolution (small, additive,
|
||||
with `Establishment {}` → `Establishment { plan: Option<Value> }` 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<ChannelPlan> }` with
|
||||
`Establishment::new(plan)`; **typed-opaque** (`ChannelPlan =
|
||||
Arc<dyn Any + Send + Sync>`), not the `Option<Value>` sketch here —
|
||||
the payloads are live handles (dialed sockets, TTY handles) with no
|
||||
Arc<dyn Any + Send + Sync>`), not the `Option<Value>` 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).
|
||||
@@ -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<Identity>` — the explicit
|
||||
- `ServingConfig.identity: Option<Identity>`, 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<OperationRegistry>` 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<dyn Any + Send + Sync>` (ADR-049 amendment 2).
|
||||
A dialed-socket handle whose halves are
|
||||
`Box<dyn AsyncRead + Send + Unpin>` does NOT satisfy the bound —
|
||||
`Box<dyn AsyncRead + Send + Unpin>` does NOT satisfy the bound:
|
||||
`Arc<T>` 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`.
|
||||
@@ -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" | <extensible> }` 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": <code>, "detail": <string>}`.
|
||||
- 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.
|
||||
@@ -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<SocketAddr>`-shaped metadata field (additive, no wire change —
|
||||
Option<SocketAddr>`-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.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Review 003 — The correlation-miss arrival class: alkssh consumer input on the forwarded-arrival event vocabulary
|
||||
|
||||
## Status
|
||||
|
||||
**Resolved — W1 absorbed in alktunnels (2026-11-03); W2/W3 pointed at
|
||||
the sibling repos.** The ask rode
|
||||
`docs/plans/003-correlation-miss-resolve.md` §Decisions 1–4 and ADR-008
|
||||
Amendment 2 (`docs/architecture/decisions/008-listen-metadata-forwarded-op.md`):
|
||||
|
||||
- The forwarded op's serving side gained a correlation-gate establisher
|
||||
variant (`register_tunnel_forwarded_openable_with_establisher`): a
|
||||
stale/missing token fails the open as a typed
|
||||
`channel:open_failed` with `details.reason = "unknown_resource"`
|
||||
before the channel is born — framing question 2 answered (the class
|
||||
rides the existing typed vocabulary; `unknown_resource` reused, no
|
||||
wire extension).
|
||||
- Framing question 1 answered: `correlation-miss` (alkssh's shipped
|
||||
name — published wins) is the canonical event name for the residual
|
||||
post-open decline class; framing question 3's posture is recorded:
|
||||
declines at `debug`, assembly-side; the producer-side generic arm
|
||||
stays `warn`, now carrying the typed reason.
|
||||
- The base (establisher-less) serving shape stays byte-for-byte for
|
||||
compatibility + as the residual-race fallback.
|
||||
|
||||
**W2 (alkssh) and W3 (alksocks) are the sibling repos' adoption waves
|
||||
and remain open** — pointed at
|
||||
`docs/plans/003-correlation-miss-resolve.md` §W2/§W3; the plan's
|
||||
plan-level AC records them unticked until those repos land.
|
||||
|
||||
Doc-only in code impact. No wire contract, no API shape, no ACL
|
||||
behavior rides it — the ask is observability vocabulary alignment at
|
||||
the arrival boundary, so a composed deployment's traces name the same
|
||||
failure the same way in both crates.
|
||||
|
||||
## The situation
|
||||
|
||||
The `-R` leg composes across this crate's fabric and the door's
|
||||
arrival handling. A `forwarded-tcpip` / `forwarded-streamlocal`
|
||||
arrival can outlive its correlation partner on either side, and each
|
||||
crate currently names the resulting class differently:
|
||||
|
||||
- **alkssh's door side** (verified at `src/tunnel_map.rs`): the shared
|
||||
per-fabric-leg `ListenRouter` removes a session's tokens at
|
||||
teardown (the handler's `Drop`), so a fabric arrival for a token
|
||||
that no longer routes — or an echo-shape mismatch against the held
|
||||
route — is the **`correlation-miss`** class:
|
||||
`observe::decline_arrival(row, "correlation-miss", listen)` +
|
||||
teardown-at-birth, never a half-open SSH channel (also the
|
||||
`cap-exceeded` sibling class on a routed-but-full arrival).
|
||||
- **alktunnels' fabric side** (`src/producer.rs`, the bind-first
|
||||
accept loop): a forwarded open that fails against a stale/expired
|
||||
token lands in the generic failure arm — warn
|
||||
`"tunnel bind-first: forwarded open failed; dropping the accepted
|
||||
connection"`, drop, keep serving. The comment records the class
|
||||
distinctions ("scope-less opener → FORBIDDEN; old opener →
|
||||
NOT_FOUND; correlation miss → handler_error-class") — i.e. the
|
||||
distinction exists as a code comment and flows into the generic
|
||||
`handler_error` typed class (the producer error-mapping tables use
|
||||
`dial_failed` / `resource_shortage` / `unknown_resource` /
|
||||
`handler_error`).
|
||||
|
||||
The mismatch arms are the "unreachable" ones in a healthy run — a
|
||||
vanilla client never initiates `forwarded-tcpip` (an inbound open is
|
||||
the unvanilla class, declined), and a correlation miss requires a
|
||||
fabric arrival racing its session's teardown — which is exactly why
|
||||
the naming was left unsettled during implementation. But when it does
|
||||
happen (teardown races, retries, a hub relaying arrivals after a
|
||||
door restart), it is an operator-visible event and the one place a
|
||||
composed `-R` deployment's logs mix vocabularies: alkssh-side the
|
||||
trace says `correlation-miss`, fabric-side it says
|
||||
`handler_error` at warn level with the distinction recoverable only
|
||||
from prose in a comment.
|
||||
|
||||
## What a resolution decides (framing, not prescription)
|
||||
|
||||
1. The shared name and semantics for the stale-token arrival class —
|
||||
alkssh's `correlation-miss` is a candidate already shipped in a
|
||||
published consumer's events; adopting it (or ruling a different
|
||||
canonical name) is this repo's call.
|
||||
2. Whether the class distinction rides the typed error vocabulary
|
||||
(e.g. a `unknown_resource`-adjacent class for the stale-token
|
||||
forwarded open, which the bind-first arm could surface without
|
||||
behavior change) or stays a logging convention.
|
||||
3. Whether the bind-first failure arm's level/posture aligns with the
|
||||
door's (alkssh logs these declines at `debug` under its decline
|
||||
classes; alktunnels logs the generic arm at `warn`). Either answer
|
||||
is fine; the value is that it becomes recorded posture rather than
|
||||
two undocumented choices.
|
||||
|
||||
## References
|
||||
|
||||
- alkssh `src/tunnel_map.rs` — the `ListenRouter` doc (tokens removed
|
||||
at session teardown; "a fabric arrival after the session is gone is
|
||||
the correlation-miss class") and the `decline_arrival` sites
|
||||
(`correlation-miss`, `cap-exceeded`)
|
||||
- alkssh `src/observe.rs` — the door's field/level vocabulary (rows,
|
||||
`decline_class`, `reason`; declines at `debug`)
|
||||
- alkssh `docs/architecture/mapping.md` — the `forwarded-tcpip` row
|
||||
("correlation-miss → teardown-at-birth") and the request-level `signal`
|
||||
row's fidelity notes (OQ-10 lineage)
|
||||
- alkssh `tasks/implementation/review-wave-3.md` item 5 and
|
||||
`docs/plans/implementation.md` ("Still booked with their owners") —
|
||||
the original booking
|
||||
- `src/producer.rs` — the bind-first accept-loop failure arm (the
|
||||
in-comment class distinction) and the establisher error-mapping
|
||||
tables (`docs/architecture/producer.md` shape 3, ADR-008)
|
||||
- ADR-008 (bind-first + forwarded op; the mint discipline whose
|
||||
staleness races this class guards)
|
||||
+32
-32
@@ -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/<component>.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/<component>.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/<component>.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/<component>.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
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
target
|
||||
corpus/*
|
||||
!corpus/datagram_codec
|
||||
!corpus/params_parse
|
||||
!corpus/session_pumps
|
||||
# Grown corpus entries (libFuzzer hash-named) are gitignored; only the
|
||||
# hand-made seed-* files are committed (the quinn/h2 policy).
|
||||
corpus/*/[0-9a-f]*
|
||||
artifacts
|
||||
coverage
|
||||
Generated
+1297
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "alktunnels-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
alktunnels-fuzz-shared = { path = "shared" }
|
||||
|
||||
[dependencies.alktunnels]
|
||||
path = ".."
|
||||
|
||||
[[bin]]
|
||||
name = "datagram_codec"
|
||||
path = "fuzz_targets/datagram_codec.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "params_parse"
|
||||
path = "fuzz_targets/params_parse.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "session_pumps"
|
||||
path = "fuzz_targets/session_pumps.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,58 @@
|
||||
# alktunnels fuzzing
|
||||
|
||||
libFuzzer targets for alktunnels' untrusted-input surfaces (see
|
||||
`docs/research/fuzzing.md` for the full rationale, scope decision, and
|
||||
campaign plan; the tooling/corpus/runner/no-CI posture is inherited
|
||||
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
|
||||
`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/<target>/`: 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`, 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`: a JSON token dictionary for the params target
|
||||
(transferred from alkcall).
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | Drives | Invariants |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
|
||||
## 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.
|
||||
|
||||
```sh
|
||||
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
|
||||
cargo fuzz build
|
||||
|
||||
# agent sessions: detached campaign (never foreground; CWD-independent)
|
||||
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh datagram_codec -max_len=65536
|
||||
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh params_parse -dict=json.dict -use_value_profile=1
|
||||
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh session_pumps -max_len=4096 -use_value_profile=1
|
||||
|
||||
# corpus replay through the invariants (stable toolchain, no nightly)
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml
|
||||
|
||||
# coverage
|
||||
cargo fuzz coverage <target>
|
||||
```
|
||||
|
||||
The fuzz workspace is excluded from the main workspace and from the
|
||||
published package (`exclude` in the root `Cargo.toml`); it pins its own
|
||||
nightly toolchain via `rust-toolchain.toml` and does not affect the
|
||||
crate's stable MSRV.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
��bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbLine truncated
|
||||
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
��
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
��
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
4
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "postgres-primary", "substrate": "tcp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "postgres-primary", "substrate": "udp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "sock", "substrate": "unix"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "r", "substrate": "tcp", "extra": true}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "r"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "", "substrate": ""}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "substrate": "tcp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": null, "substrate": "tcp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": 1, "substrate": 2}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {"host": "db.internal", "port": 5432}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "udp", "target": {"host": "10.0.0.7", "port": 53}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "unix", "target": {"path": "/run/x.sock"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {"port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {"host": "h"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "unix", "target": {"path": "/p", "host": "h"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {"host": "h", "port": 65536}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {"host": "h", "port": -1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": [1, 2]}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "unix", "target": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"target": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 918273, "substrate": "tcp", "peer": {"host": "198.51.100.4", "port": 55231}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 918273, "substrate": "unix", "peer": {"path": "/run/x.sock"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 0, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": -1, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 1, "substrate": "tcp", "peer": {"host": "h", "port": 1}, "channel": 7}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 4294967296, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": "token", "substrate": "tcp", "peer": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "peer": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 1, "substrate": "tcp"}
|
||||
Loaded 100 of 209 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user