14 Commits
Author SHA1 Message Date
glm-5.3-flash d72820ddc2 release: v0.5.0 — the correlation gate (forwarded-op establishment phase)
- Cargo.toml: 0.4.1 -> 0.5.0 (minor, not patch: the release adds
  public API — ForwardedGate, ForwardedPlan,
  register_tunnel_forwarded_openable_with_establisher — additive only;
  the base registration stays byte-for-byte, the wire delta is empty)
- CHANGELOG: Unreleased cut to 0.5.0 (the establisher-variant entry +
  the release paragraph; the compare links updated)
- README: the forwarded-op serving snippet shows the recommended gate
  shape (register_tunnel_forwarded_openable_with_establisher) beside
  the base one

Review 003 W1 absorbed in-repo; W2/W3 are the alkssh/alksocks repos'
adoption waves. Verification: cargo test (default + --all-features),
fuzz corpus replay (fuzz/shared), clippy --all-targets -D warnings
(native + wasm32), wasm32 check, fmt --check, cargo doc --no-deps,
taskgraph validate (33 tasks), cargo publish --dry-run --allow-dirty
all green
2026-11-03 17:37:18 +00:00
glm-5.3-flash 74e6289a8e docs(tasks): fix the end-to-end-suite frontmatter, drop the W2/W3 pointer tasks
- tasks/tunnels/end-to-end-suite.md quoted the name value (the
  unquoted colon broke the YAML parse: TaskFrontmatter read as null)
- tasks/tunnels/corrmiss-alkssh-adopt.md and corrmiss-alksocks-adopt.md
  deleted (cross-repo pointer tasks; W2/W3 are booked in the alkssh
  and alksocks repos per plan 003 + review 003 §Status)
- the two Notes references to the deleted task ids re-pointed at plan
  003 §W2/§W3

taskgraph validate: 33 tasks, no issues
2026-11-03 17:37:12 +00:00
glm-5.3-flash ecb24678bb docs(readme): drop the redundant version-history section
The graduation and 0.2.1 through 0.4.1 release notes duplicate
CHANGELOG.md; the README now flows from the WASM target section
straight into Features.
2026-11-03 14:19:38 +00:00
glm-5.3-flash 0ceecf582b docs: remove em dashes from repo prose and forbid them going forward
- Rewrote the remaining em dashes outside docs/reviews/ (preserved as
  historical record, matching the alktty precedent): tests/ (313),
  fuzz/ (34 plus one shell comment), tasks/ (671 plus 12 kept in
  fences), docs/research + sdd_process + plans (525 plus 25 in
  fences), docs/architecture/overview.md fence labels (13), README,
  CHANGELOG, AGENTS.md, fuzz/README.md (176), and the
  .opencode/agents prompts (110)
- Rewrote fenced content too: ASCII tree and module-map label
  separators, Rust snippet comments, and template placeholders; wire
  data, JSON/BAST examples, and transcripts survive verbatim
- Rewrote 6 assert/expect message literals in tests/ and 2 format
  strings in fuzz/shared/src; no test data changed
- Added Project Convention 18: no em dashes in prose (doc comments,
  markdown including docs/ and tasks/, agent prompts, commit
  messages); fence content rule, en-dash clause rule, and the
  no-spaced-hyphen prohibition follow alktty's convention 15

Follows 6b20924 (src doc comments) and cd0afe0 (docs/architecture).

Verification: cargo test (141 pass); cargo clippy --all-targets -D
warnings; cargo fmt --check; cargo doc --no-deps; wasm check and
clippy for wasm32-unknown-unknown with -D warnings; em dash grep over
the repo returns matches only in docs/reviews/
2026-11-03 14:12:46 +00:00
glm-5.3-flash cd0afe0497 docs(architecture): eliminate em dashes from the spec docs
Rewrote every prose em dash under docs/architecture/ with natural
grammar (sentence splits, colons, semicolons, parentheses, commas);
wire-format fences, JSON/BAST examples, and byte diagrams untouched.

Verified: grep -rln '—' docs/architecture/ returns only
overview.md's fenced dependency/module-map blocks (12 dashes),
left verbatim per the fenced-code rule; all other files at zero.
2026-11-03 14:02:07 +00:00
glm-5.3-flash 6b20924f68 docs: remove em dashes from Rust doc comments
Rewrote each em-dash construction in doc and inline comments with
varied prose (colons, semicolons, parentheses, split sentences);
no code or identifiers changed. The one string-literal em dash
(UDP oversize error, local/mod.rs) became '; '.
No string-literal em dashes remain except none were found beyond it.

Verification:
- grep -rn '—' src/ --include='*.rs' -> no output
- cargo test: all ok (125 tests)
- cargo clippy --all-targets: 0 warnings
- cargo fmt --check: clean
2026-11-03 13:48:51 +00:00
glm-5.3-flash 92c3cc5123 docs: close out review 003's doc trail — status resolved-for-W1, plan 003 W1 AC ticked, stale-comment sweep
- docs/reviews/003-…alkssh-input.md: Status block now resolved —
  W1 absorbed (plan 003 §Decisions 1–4 + ADR-008 Amendment 2); W2/W3
  pointed at the plan's §W2/§W3, open until those repos land
- docs/plans/003-…: status header planned → W1 landed (W2/W3 open);
  the W1 plan-level AC box ticked; W2/W3 + family-coherence boxes
  left unticked (sibling-repo sessions)
- Stale-comment sweep (src/ + docs/architecture/) aligned three
  stragglers still riding the pre-amendment miss vocabulary:
  - src/producer.rs: the accept-loop arm's "correlation miss →
    handler_error-class" comment → the typed unknown_resource
    open_failed under the establisher variant (Amendment 2)
  - wire.md §The Forwarded Op reply/errors: the miss is the typed
    unknown_resource open_failed (establisher variant); base shape's
    in-session miss = the assembly-side post-open decline,
    correlation-miss at debug
  - producer.md §The Forwarded Op: "no establisher" → the two-shape
    posture (Amendment 2 pointer); last_updated bumped

Verification: cargo test (14 suites, 0 failures), clippy --all-targets
-D warnings clean, cargo fmt --check clean, cargo doc --no-deps clean.
2026-11-03 12:53:27 +00:00
glm-5.3-flash c7e8c875fd review(tunnels/corrmiss-review): the W1 gate surface approved before the consumer waves fork
- API vs ADR-008 Amendment 2 ink: all pins hold (ForwardedGate shape,
  registration signature, error mapping via the existing
  From<TunnelEstablishError> table, typed parse-failure posture,
  timeout: None, unchanged scope table); ForwardedPlan marker matches
  the API task's pinned payload choice
- Two-shape posture consistent across docs/re-exports; one drift
  fixed: make_forwarded_op_handler's doc header retitled from "the
  no-establisher serving shape" to "the serving-shape wrapper both
  registrations share"
- CHANGELOG: Added entry under [Unreleased] for the establisher
  variant (the implementation tasks left it unrecorded)
- Five gate tests re-checked to pin the wire-observable contract at
  the CallError surface (channel:open_failed / unknown_resource /
  FORBIDDEN ordering / residual-race reap / malformed-params delta)
- Verification (re-run at review): cargo test green (14 suites),
  clippy --all-targets -D warnings native clean, wasm clippy + check
  clean, fmt clean, --all-features green, cargo doc --no-deps clean
- Task status -> completed; Notes/Summary record the outcome. The
  surface is ready for the W2 (alkssh) / W3 (alksocks) adoptions.
2026-11-03 12:42:02 +00:00
glm-5.3-flash f42ce47d17 test(producer): the forwarded gate — gate miss/pass, residual race, scope-gate ordering (plan 003 W1)
- New tests/producer_forwarded_gate.rs (the establisher variant's pins;
  the base shape's tests/producer_forwarded.rs untouched):
  - gate miss: typed channel:open_failed with details.reason ==
    unknown_resource, no phantom channel, the pump handler never runs
    (the gate's invocation witness IS set — the miss is at the gate)
  - gate pass: the base serving flow unchanged — parsed params
    witnessed verbatim + full byte round-trip through the
    establisher-registered variant
  - residual race: gate pass + route gone at handler time → the
    handler's error path reaps via teardown-at-birth (the base
    correlation-miss semantics re-pinned under the established shape)
  - scope-gate ordering: tunnel:forwarded-less identity is FORBIDDEN
    and the gate closure is never invoked (ACL precedes the
    establishment phase)
  - the establisher malformed-params delta pinned: schema gates
    unchanged (INVALID_INPUT); an unknown top-level field becomes a
    typed channel:open_failed (reason handler_error) and the parse
    precedes the gate
- No harness changes (wire_with + Topology.consumer_registry as-is)
- Verification: cargo test green (14 suites, 0 failures); clippy
  --all-targets -D warnings native + wasm-target clippy clean (wasm
  --all-targets cannot compile on this baseline — pre-existing,
  mio/socket2 dev-deps do not target wasm; see the task Notes);
  cargo fmt --check clean; cargo check --target
  wasm32-unknown-unknown clean; --all-features test run green

Tasks: tunnels/corrmiss-gate-tests (status → completed)
2026-11-03 12:37:19 +00:00
glm-5.3-flash 5b2f7255f7 feat(producer): the forwarded-op establisher variant — ForwardedGate + register_tunnel_forwarded_openable_with_establisher
Implements plan 003 W1's API item (ADR-008 Amendment 2, task
tunnels/corrmiss-forwarded-gate): the assembly-injected correlation
gate wrapped into the alkcall OpenEstablisher dance for the forwarded
op's serving half.

- ForwardedGate: sync gate closure over the parsed
  TunnelForwardedParams; a gate Err → typed channel:open_failed
  before the channel is born (no phantom channel)
- register_tunnel_forwarded_openable_with_establisher: reuses
  make_forwarded_op_handler unchanged (additive-only; the base
  registration stays byte-for-byte compatible as the
  compatibility + residual-race-fallback posture)
- ForwardedPlan: empty marker plan payload (typed-opaque posture,
  payload choice pinned); no reply-field contribution
- Establisher parse failure maps TunnelEstablishError::HandlerError
  → channel:open_failed (deliberately pinned improvement over the
  base shape's silent birth-teardown for malformed ingress); gate
  errors pass through the existing From<TunnelEstablishError> table
  (no new variants; stale token = unknown_resource); timeout: None;
  wrapper order (schema → ACL → allocate → establisher → handler)
  pinned in the establisher doc
- Two-shape posture doc updates: make_forwarded_op_handler's
  correlation-miss block, register_tunnel_forwarded_openable's doc;
  wire.md §The Forwarded Op + tunnel_forwarded_spec() doc synced
  per Amendment 2's "syncs with the API task's in-code doc updates"

Verification: cargo test (85 green, existing suite untouched);
clippy --all-targets -D warnings (native + wasm32); cargo fmt --check;
cargo doc --no-deps (0 warnings); --all-features test run green
2026-11-03 12:28:00 +00:00
glm-5.3-flash 7d2b6d11f0 docs(adr): ADR-008 Amendment 2 — the forwarded op's serving-side establishment phase + the correlation-miss vocabulary
- The serving side of the forwarded op gains an establisher variant:
  an assembly-provided gate closure (over TunnelForwardedParams) the
  crate wraps into the alkcall OpenEstablisher dance via
  register_openable_with_establisher; a stale/missing listen token
  fails the open as a typed channel:open_failed with
  details.reason = unknown_resource — never a phantom channel
  (ADR-049's posture extended to the forwarded op's serving side)
- unknown_resource reused as-is; the wire vocabulary is unextended
  and wire.md is untouched (the empty wire delta stated explicitly)
- Gate order pinned: the tunnel:forwarded ACL precedes the
  establisher (alkcall's open-op wrapper order)
- The residual post-open race keeps teardown-at-birth; canonical
  event name correlation-miss at debug (assembly-side convention);
  the producer-side generic arm stays warn with the typed reason
- Additive-only surface: the base register_tunnel_forwarded_openable
  stays; the establisher variant is the recommended default posture
- Tri-vocabulary history recorded (alkssh correlation-miss/debug,
  alksocks prose/warn, alktunnels handler_error-class generic
  arm/warn + the fabric-side silence) with sources
- The amended §3 sentence is marked inline, not silently replaced

Resolves review 003 via docs/plans/003-correlation-miss-resolve.md
§Decisions 1-4 (W1's ADR bullet). Doc-only; review 003's status block
and wire.md belong to other tasks and are untouched.

Verification: md renders coherently (read back); alkcall 0.10.1
EstablishmentError/reason() mapping and wrapper order verified in the
registry source before pinning
2026-11-03 08:13:50 +00:00
glm-5.3-flash aa3db39e3e docs(tasks): decompose plan 003 — the correlation-miss gate (W1 in-repo, W2/W3 pointer tasks)
- tunnels/corrmiss-adr: the ADR ink (establishment phase for the
  forwarded op's serving side; unknown_resource reused; correlation-miss
  adopted + level posture; the tri-vocabulary history recorded)
- tunnels/corrmiss-forwarded-gate: the additive API — ForwardedGate +
  register_tunnel_forwarded_openable_with_establisher (candidate shape
  verified against alkcall 0.10.0's establisher surface); the two-shape
  doc posture
- tunnels/corrmiss-gate-tests: the four pins (typed gate miss / gate-pass
  flow / residual race reaping / scope-gate-before-establisher order)
- tunnels/corrmiss-review: the checkpoint before the consumer waves
  adopt the surface (the {W2, W3} fork's critical point)
- tunnels/corrmiss-doc-sync: review 003 status + plan checkboxes close-out
- tunnels/corrmiss-alkssh-adopt / corrmiss-alksocks-adopt: cross-repo
  pointer tasks (executed in alkssh / alksocks), gated on the review

Ordering W1 → {W2, W3} per the plan; no cycles; W1's doc-sync closes
after the review checkpoint.
2026-11-03 08:04:36 +00:00
glm-5.3-flash bd8a82b4c4 docs(plan): resolve review 003 — the correlation-miss arrival class
Plan for the review-003 resolution: route the forwarded-op correlation
miss through an establisher (typed unknown_resource failure, the
ADR-049 posture extended), adopt alkssh's correlation-miss as the
canonical event name for the residual decline, and align the family
(alkssh + alksocks waves). Adds the session's verification findings:
the alksocks prose variant (third vocabulary) and the fully-silent
in-session miss on the fabric side.

Verified: cargo fmt --check, cargo test (13 suites ok), clippy
--all-targets -D warnings (native + wasm32). Docs-only change.
2026-11-03 07:56:08 +00:00
glm-5.3-flash ec52a9620d docs(review): alkssh consumer input — the correlation-miss arrival class, event-vocabulary alignment at the forwarded-arrival boundary 2026-10-31 12:56:00 +00:00
94 changed files with 4721 additions and 2751 deletions

No files matched your search

+45 -44
View File
@@ -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
+16 -16
View File
@@ -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.
+3 -3
View File
@@ -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`)
+21 -21
View File
@@ -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.
+28 -28
View File
@@ -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
+4 -4
View File
@@ -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
+82 -66
View File
@@ -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,12 +160,12 @@ just spawned implementation agents.
backpressure; datagram-boundary preservation, if required, is a
wire-format decision for an ADR, not an accident.
11. **Vendored core types come from alkcall** — `Connection`,
11. **Vendored core types come from alkcall**: `Connection`,
`ProtocolHandler`, `BiStream`, `BidiStreamSource`, `AuthContext`,
`Identity`, `IdentityProvider`, `AccessControl`,
`OwnershipProvider`, `HandlerError`, `StreamError` come from
`alkcall::core`. Do not vendor copies into this crate. alkcall is
v0.10.x — breaking changes are expected at this major-zero stage;
v0.10.x, where breaking changes are expected at this major-zero stage;
this is an early consumer, so we find and fix issues upstream
rather than working around them. Pin `alkcall = "0.10.0"` and bump
deliberately. The 0.6.0 establishment surface (ADR-049 + amendment
@@ -173,50 +173,50 @@ just spawned implementation agents.
`register_openable_with_establisher` so a refused target dial is
a typed `channel:open_failed` call error, never a phantom channel,
and the establisher returns the dialed handle via
`Establishment::new(plan)` (typed-opaque `ChannelPlan` — its
`Establishment::new(plan)` (typed-opaque `ChannelPlan`, whose
`Send + Sync` bound constrains plan payloads; socket handles carry
`+ Sync`) — no side-channel handoff. The 0.7.0 identity surface
`+ Sync`); no side-channel handoff. The 0.7.0 identity surface
(ledger CF-005/CF-006) is load-bearing for the serving side: a
connect-side serving op resolves the caller identity in
precedence order — payload `auth_token` >
precedence order (payload `auth_token` >
`ServingConfig.identity` > transport identity (set via
`Connection::set_identity` before dialing) — and the establisher/
`Connection::set_identity` before dialing), and the establisher/
pump handler receive the per-call opener identity, not the
install-time context.
12. **BAST document for the wire format** — when the tunnel wire format
12. **BAST document for the wire format**: when the tunnel wire format
gains binary framing (if any beyond pass-through), it carries a
BAST (Binary Abstract Syntax Tree) document as its machine-readable
spec under `docs/architecture/`, conforming to the BAST meta-schema
at `https://alk.dev/bast/v1/schema`. BAST is plain JSON — no
at `https://alk.dev/bast/v1/schema`. BAST is plain JSON; no
dependency required to author or consume it. alktunnels does not
depend on alktype; hand-rolled codecs are fine for trivial formats,
with the BAST doc as the contract. Do not roll your own offset map
or validator for complex formats — use alktype (as an optional dep)
or validator for complex formats: use alktype (as an optional dep)
when that surfaces.
13. **Access control** — the producer's openable channels get
13. **Access control**: the producer's openable channels get
authorization for free via `ChannelCore::register_openable`, which
wires `AccessControl` into the operation spec (the registry runs
the ACL before the open handler). Scope-gate tunnel opens (e.g.
`TUNNEL_OPEN_SCOPE`, shape following alktty's `TTY_OPEN_SCOPE`) and
optionally consult `OwnershipProvider` for resource ownership
(`provider.owns(id_ref, kind, &id, "tunnel")` — the 4-arg shape;
(`provider.owns(id_ref, kind, &id, "tunnel")`, the 4-arg shape;
`OwnershipStore::record` is the 3-arg shape). Tunnels reach local
networks — treat the open gate as the security boundary. See
networks; treat the open gate as the security boundary. See
alknet ADR-024 (registry layering, alkcall ADR-019), alknet
ADR-050 (ownership, alkcall ADR-011).
14. **Feature flags** — substrate backends may be feature-gated if the
14. **Feature flags**: substrate backends may be feature-gated if the
need arises. The base crate should compile lean (no socket/platform
deps unless the feature is on). Verify both `cargo test` (default)
and `cargo test --all-features` pass if features are added.
15. **Naming** — Rust standard: `snake_case` for functions/variables/
15. **Naming**: Rust standard, `snake_case` for functions/variables/
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
constants.
16. **Module structure** — one module per file under `src/`, re-exported
16. **Module structure**: one module per file under `src/`, re-exported
from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The
shape is pinned by the architecture spec (ADR-004, ADR-005): a
producer half (adapter / open-handler for `alk/tunnel`-family
@@ -225,12 +225,28 @@ just spawned implementation agents.
modules are feature-gated and never imported from the
shared/producer/consumer modules.
17. **ALPN naming** — this crate owns the `alk/tunnel`-family ALPN(s).
17. **ALPN naming**: this crate owns the `alk/tunnel`-family ALPN(s).
One ALPN per protocol per alkcall ADR-004; keep the `alk/` prefix
convention. If multiple tunnel flavors need distinct ALPNs (e.g.
stream vs datagram), decide via ADR before the first consumer —
stream vs datagram), decide via ADR before the first consumer;
ALPN strings are wire-stable once published.
18. **No em dashes in prose.** Do not use the em dash (U+2014) in
source doc comments, markdown docs (including `docs/` and `tasks/`),
agent prompts under `.opencode/agents/`, or commit messages.
Restructure instead: split the sentence, use parentheses, commas, a
colon, or a semicolon, whichever reads naturally, and vary the
choice rather than converting every instance the same way. A spaced
hyphen (` - `) is not an acceptable substitute; it reads as a
different but equally recognizable writing tell. Inside fenced code
blocks the rule applies to prose-y content only: line comments,
ASCII-diagram label separators, and template placeholders are
rewritten too, while wire data, JSON/BAST examples, and tool
transcripts survive verbatim. The same rule covers en dashes used as
clause separators, though en dashes inside numeric ranges keep their
normal meaning. Hyphens in compound words (`wasm-clean`,
`tty-wire`, `alk/tunnel`) are untouched.
## Verification Commands
Run these before committing. All must pass.
@@ -247,70 +263,70 @@ cargo publish --dry-run --allow-dirty # before a release
```
The wasm check is the structural guard for the "default crate is
wasm-clean" invariant (same as alktty) — run it whenever a
wasm-clean" invariant (same as alktty), and run it whenever a
non-backend module changes.
## Architecture Context
- `docs/architecture/` — the authoritative spec (Phase 1 of the SDD
- `docs/architecture/` holds the authoritative spec (Phase 1 of the SDD
process, complete): overview/producer/consumer/wire spec docs, ADRs
001..006 (all Accepted), the wire-format BAST document, and
`open-questions.md` (the authoritative OQ tracker). Read it before
non-trivial changes. Phase 0 research remains in `docs/research/`
(its OQ ledger is superseded by the tracker).
- The prior art for this crate:
- **alknet-channels POC** —
- **alknet-channels POC**, with its summary at
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
(§POC Target 3: tunnel handler). Validates the demux→Connection→
handler→mux path and the two-pump tunnel shape over a stand-in
transport. POC code at `/workspace/alknet-channels-poc/`.
- **alkcall** — `/workspace/@alkdev/alkcall` (v0.10.x, crates.io).
- **alkcall** (`/workspace/@alkdev/alkcall`, v0.10.x, crates.io).
The substrate: call protocol + channels multiplexing. The tunnel
crate consumes `alkcall::core` types and the channels
`ChannelCore`/`ChannelClient`/`register_openable` surface.
- **alktty** — `/workspace/@alkdev/alktty` (v0.1, crates.io). The
- **alktty**, found at `/workspace/@alkdev/alktty` (v0.1, crates.io). The
sibling pattern to follow: a producer/consumer protocol crate on
alkcall channels, with the backend inversion point, wasm-clean
default crate, and feature-gated local backend.
- Key upstream ADRs that inform this crate's design (alknet numbers;
alkcall's ports live in `/workspace/@alkdev/alkcall/docs/architecture/
decisions/`):
- alknet ADR-093 / alkcall ADR-035 — channels pure channel
- alknet ADR-093 / alkcall ADR-035: channels pure channel
multiplexing (8-byte header, no `stream_type`); the tunnel payload
is raw bytes inside the `BiStream`
- alknet ADR-078 / alkcall ADR-050 — two-pump shutdown-on-completion
- alknet ADR-078 / alkcall ADR-050: two-pump shutdown-on-completion
(the tunnel handler pattern); the helper is pinned upstream as
`alkcall::channels::pump_bidi` — use it, do not hand-roll
- alkcall ADR-049 (amendment 2) — the establishment phase; the
`alkcall::channels::pump_bidi` (use it, do not hand-roll)
- alkcall ADR-049 (amendment 2): the establishment phase; the
establisher returns the dialed handle via `Establishment::new`
(typed-opaque `ChannelPlan`), replacing the POC's side-channel
handoff
- alknet ADR-074 / alkcall ADR-038 — `ChannelConnection` as a
- alknet ADR-074 / alkcall ADR-038: `ChannelConnection` as a
`BidiStreamSource`; every handler (TTY, tunnel, call) receives a
`Connection`
- alknet ADR-075 / alkcall ADR-039 — `ChannelsAdapter` /
- alknet ADR-075 / alkcall ADR-039: `ChannelsAdapter` /
`ChannelManager` (substrate-agnostic demux; `params` is
ALPN-specific — for tunnels, the target resource)
- alknet ADR-071 / alkcall ADR-034 — channels wire format (one-way
ALPN-specific, i.e. for tunnels the target resource)
- alknet ADR-071 / alkcall ADR-034: channels wire format (one-way
door)
- alkcall ADR-037 — channel lifecycle operations (`channel/open` on
- alkcall ADR-037: channel lifecycle operations (`channel/open` on
channel 0; `params` carries the tunnel target)
- alkcall ADR-042 — hub relay (byte-for-byte data-channel forwarding
with ID rewrite — tunnels traverse relays transparently)
- alkcall ledger CF-005/CF-006 (0.7.0) — the connect-side serving
- alkcall ADR-042: hub relay (byte-for-byte data-channel forwarding
with ID rewrite, so tunnels traverse relays transparently)
- alkcall ledger CF-005/CF-006 (0.7.0), the connect-side serving
identity seam: caller identity precedence (token >
`ServingConfig.identity` > transport), per-call opener identity
on the open-op hooks (the reverse-flow POC's W1, resolved)
- alknet ADR-085 — workspace scope: `alknet/tunnel` was flagged
- alknet ADR-085, workspace scope: `alknet/tunnel` was flagged
"POC-validated, minimal spec needed, not yet specced"; this crate
is that spec
- What the POCs settled (Phase 0 complete — 2026-09-07): both POCs
- What the POCs settled (Phase 0 complete as of 2026-09-07): both POCs
ran clean (forward UDP POC + reverse-flow POC; see
`docs/research/poc-summary.md` and `reverse-poc-summary.md`). Phase 1
is complete (2026-09-07): the OQ ledger is promoted, and the params,
ALPN, codec, and API-surface decisions are pinned by ADR-001..006
(including consumer-session teardown ownership — reverse POC W3).
(including consumer-session teardown ownership from reverse POC W3).
Phase 2 (implementation) is decomposed in `tasks/`.
- If a TODO references a design direction that an ADR has since decided
against, the TODO is stale — remove it and align with the ADR. Do not
against, the TODO is stale; remove it and align with the ADR. Do not
implement the rejected design.
+77 -60
View File
@@ -6,11 +6,27 @@ 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
no wire change; the only file-level contacts with the bump are
`Cargo.toml`/`Cargo.lock`.
### Changed
@@ -28,7 +44,7 @@ no wire change — the only file-level contacts with the bump are
channels through the same adopt path), though no change in this
crate's own surfaces was needed. No API, wire-format
(`EventEnvelope`, the 8-byte chunk header), or documented-cap
behavior change upstream — the fix surface is private; the behavior
behavior change upstream: the fix surface is private; the behavior
only strengthens. 136 tests pass; wasm-clean verified (check +
clippy on `wasm32-unknown-unknown`).
@@ -36,7 +52,7 @@ no wire change — the only file-level contacts with the bump are
The dependency-major release: the alkcall substrate bumped 0.9.0 →
0.10.0 (the gateway ownership-seam release). No alktunnels API
change, no wire change; zero source changes — the only file-level
change, no wire change, and zero source changes; the only file-level
contacts with the bump are `Cargo.toml`/`Cargo.lock`. Minor rather
than patch for the 0.3.0 reason: the semver-incompatible dependency
bump plus this crate's public re-exports of alkcall types
@@ -48,11 +64,11 @@ the producer API) make it semver-visible to downstream consumers.
- **alkcall 0.9.0 → 0.10.0** (crates.io). The gateway
ownership-seam release (review 012): `GatewayDispatch` gains
`with_ownership_provider` / `with_static_ownership_fallback`, and
the previously-silent static fallback becomes loud — an unwired
the previously-silent static fallback becomes loud: an unwired
spine refuses (an error envelope, `FORBIDDEN`, pre-dispatch) an op
whose spec carries `access_control.resource_type`. None of it is on
this crate's paths: alktunnels does not use the `gateway` feature
or `GatewayDispatch` — tunnel opens serve via
or `GatewayDispatch`; tunnel opens serve via
`ChannelCore::register_openable`/`register_openable_with_establisher`
registry dispatch on the native wire, which 0.10.0 leaves
untouched. All of this crate's alkcall contact points
@@ -68,7 +84,7 @@ the producer API) make it semver-visible to downstream consumers.
The dependency-major release: the alkcall substrate bumped 0.8.1 →
0.9.0 (the stub-sweep release). No alktunnels API change, no wire
change; the only code contact with the 0.9.0 breaking surface was
test-side (`Establishment` no longer derives `Debug` — three
test-side (`Establishment` no longer derives `Debug`; three
`expect_err`/`panic!` sites restructured), which is why this is a
minor bump rather than a patch: the semver-incompatible dependency
bump plus this crate's public re-exports of alkcall types make it
@@ -86,7 +102,7 @@ ride on 0.2.0).
enumerator use, no generic `op/register` announcements), so no
behavior change here. Of the 0.9.0 breaking list,
`Establishment` dropping its `Debug` derive was the only surface
this crate touched — two `bindfirst_establisher` unit tests'
this crate touched: two `bindfirst_establisher` unit tests'
`expect_err` calls and one integration test's untyped
`panic!("{other:?}")` fall-through restructured (the error type
itself remains `Debug`; the `Ok` arm now formats via `panic!("got
@@ -104,7 +120,7 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change.
### Added
- **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)** — three
- **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)**: three
cargo-fuzz targets over this crate's own attacker-reachable surfaces
(the envelope/chunk/manager layer is upstream and fuzzed there per
alkcall 0.8.1): `datagram_codec` (the ADR-003 codec: no-panic,
@@ -116,7 +132,7 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change.
exec, the `TunnelSession` data planes mirrored, no-wedge +
shutdown-on-completion + lossless p2c + EOF-sentinel teardown + the
`pending` backlog bound asserted). Invariant logic lives in
`fuzz/shared/` — a stable-toolchain crate replayed as plain
`fuzz/shared/`: a stable-toolchain crate replayed as plain
`cargo test`, so the committed corpora (96 + 8 seeds, plus the
campaign-promoted `seed-900`) stay executable without nightly.
`fuzz/` is excluded from the workspace and from the published
@@ -124,12 +140,12 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change.
waves 1–2, zero crashes/hangs/OOMs; all findings were
harness-model defects, no crate bugs.
- **`TunnelSession::datagram_backlog()`** — the UDP `pending` count
- **`TunnelSession::datagram_backlog()`**: the UDP `pending` count
(the datagrams decoded from a chunk beyond the one returned), the
wave-2 harness's asserted backlog bound made caller-visible;
`None` for stream sessions or after the halves were taken.
- **`DatagramReader::retained_bytes()`** — the decoder-state byte
- **`DatagramReader::retained_bytes()`**: the decoder-state byte
count (leftover buffer + the in-flight partial datagram); post-feed
bounded by one frame (≤ `MAX_DATAGRAM_LEN` + 2), pinned by the
`datagram_codec` target.
@@ -139,7 +155,7 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change.
- **alkcall 0.8.0 → 0.8.1** (crates.io). The bug-fix release: a
duplicate `adopt_channel`/`open_channel` no longer destroys the
live channel it collided with (found by alkcall's own
`manager_routing` fuzz target) — directly relevant to this crate's
`manager_routing` fuzz target), directly relevant to this crate's
reverse-path adopt flow. No alktunnels API or wire change in this
bump; the full suite passes unchanged.
@@ -151,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
@@ -250,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`).
@@ -289,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
@@ -329,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
@@ -338,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`);
@@ -346,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.
@@ -358,7 +374,8 @@ 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.4.1...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
Generated
+1 -1
View File
@@ -48,7 +48,7 @@ dependencies = [
[[package]]
name = "alktunnels"
version = "0.4.1"
version = "0.5.0"
dependencies = [
"alkcall",
"bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "alktunnels"
version = "0.4.1"
version = "0.5.0"
edition = "2021"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
+46 -82
View File
@@ -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, &registry, &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, &registry, &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,65 +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).
0.2.1 is the hardening release: the `fuzz/` workspace (three
cargo-fuzz targets over the datagram codec, the params parsers, and a
stateful session-pump target; corpora replay as plain `cargo test`) —
`docs/research/fuzzing.md` — and the alkcall bump 0.8.0 → 0.8.1
(duplicate adopt/open no longer destroys the live channel — relevant
to the reverse-path adopt flow). Two additive diagnostics:
`TunnelSession::datagram_backlog()` and
`DatagramReader::retained_bytes()`. No API break, no wire change.
0.3.0 rides the alkcall bump 0.8.1 → 0.9.0 (the stub-sweep release —
the four registered-but-always-failing stubs became real
implementations; none were on this crate's paths). No alktunnels API
change, no wire change; minor rather than patch because the
semver-incompatible dependency bump + this crate's alkcall type
re-exports make it semver-visible downstream.
0.4.0 rides the alkcall bump 0.9.0 → 0.10.0 (the gateway
ownership-seam release — the loud ownership-wiring default on the
`GatewayDispatch` spine, behind the opt-in `gateway` feature; none of
it is on this crate's paths, which serve via
`ChannelCore::register_openable*` dispatch). No alktunnels API
change, no wire change; same minor-not-patch reasoning as 0.3.0.
0.4.1 rides the alkcall bump 0.10.0 → 0.10.1 (the adoption-window
ordering fix — a chunk routed inside the `adopt_channel` install→drain
window can no longer overtake earlier-parked early arrivals). No
alktunnels API change, no wire change; patch because the dependency
bump is backwards-compatible.
## 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
View File
@@ -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
View File
@@ -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
+51 -51
View File
@@ -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)
+49 -49
View File
@@ -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
+42 -42
View File
@@ -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)
alktunnels (default: wasm-clean)
├── alkcall 0.10.1 (core types, channels: ChannelCore/ChannelClient/manager,
│ pump_bidi, ServingConfig identity seam — CF-005/006,
│ pump_bidi, the ServingConfig identity seam (CF-005/006),
│ establisher reply projection + flavor-form discovery
│ derivation + ChannelRelay/HubLegTemplate — review 008/ADR-051)
│ derivation + ChannelRelay/HubLegTemplate (review 008/ADR-051))
├── tokio (wasm-clean subset: rt, sync, io-util, macros, time)
├── bytes, futures, serde/serde_json, thiserror, tracing, async-trait
└── (no backend deps — sockets/process live in feature-gated modules)
└── (no backend deps: sockets/process live in feature-gated modules)
alktunnels (local feature) — non-wasm by design
└── adds: tokio/net (TCP dial, UDP connect, unix sockets — unix per OQ-TN-14)
alktunnels (local feature): non-wasm by design
└── adds: tokio/net (TCP dial, UDP connect, unix sockets; unix per OQ-TN-14)
```
Same posture as alktty: the default crate is protocol-only and
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
+76 -66
View File
@@ -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
View File
@@ -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`
+142
View File
@@ -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)
+109 -100
View File
@@ -1,28 +1,28 @@
# Fuzzing alktunnels: Proposal and Plan
**Status:** adopted — wave-1 complete (`datagram_codec` +
**Status:** adopted; wave-1 complete (`datagram_codec` +
`params_parse` implemented, seeds committed, two 10-min campaigns
clean, two harness-model findings fixed — see §7); wave-2
(`session_pumps`) implemented and campaigning — see §3.3/§7.
Campaigns run via the detached runner (§6) — **never as a foreground
clean, two harness-model findings fixed; see §7); wave-2
(`session_pumps`) implemented and campaigning (see §3.3/§7).
Campaigns run via the detached runner (§6), **never as a foreground
child of an agent session** (§5).
**Date:** 2026-09-28
**Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
(0.8.0 — 0.8.1 campaign; one real finding: duplicate adopt/open destroying
(0.8.0 to 0.8.1 campaign; one real finding: duplicate adopt/open destroying
the live channel, fixed in 0.8.1). Tooling, corpus policy, runner, and the
no-hosted-CI posture are inherited from that doc wholesale — this doc does
no-hosted-CI posture are inherited from that doc wholesale: this doc does
not re-litigate them; it scopes them to this crate.
---
## 1. Should this crate be fuzzed?
**Yes — but scoped smaller than alkcall's.** The decision follows from
**Yes, but scoped smaller than alkcall's.** The decision follows from
three lines, all pointing the same way:
1. **alkcall's fuzz doc already assigned this to us.** Its §5
"non-surfaces (deliberately)" note ends: *the channels layer carries
opaque `Bytes` and the handler owns sub-stream framing (ADR-035) —
opaque `Bytes` and the handler owns sub-stream framing (ADR-035);
those downstream parsers are downstream crates' fuzz targets.* Our
`wire.rs` datagram codec IS that named downstream target. Fuzzing it
completes alkcall's fuzz scope rather than duplicating it.
@@ -30,11 +30,11 @@ three lines, all pointing the same way:
2. **We own one hand-rolled parse surface of exactly the covered class.**
`DatagramReader::feed` (`src/wire.rs:100`) is an incremental
length-prefix state machine reassembling `[len: u16 BE][payload]`
frames across arbitrary chunk boundaries — length arithmetic and
frames across arbitrary chunk boundaries: length arithmetic and
boundary handling, the dominant rust-fuzz trophy-case classes. It is
sync, pure, allocation-light, and infallible **by contract** (the
decode direction cannot fail; oversize is rejected at frame time) —
a fuzz target is how that contract is *demonstrated* rather than
decode direction cannot fail; oversize is rejected at frame time),
and a fuzz target is how that contract is *demonstrated* rather than
believed.
3. **The stateful DoS class applies to us directly.** One pre-fuzz
@@ -43,19 +43,19 @@ three lines, all pointing the same way:
(`src/consumer.rs:78`) queues every datagram decoded from a chunk
beyond the one returned to the caller. A single 16 MiB chunk of
`[00 00]` pairs = ~8.4 M empty datagrams → ~8.4 M `VecDeque`
entries (≈ 400+ MiB) from ONE peer chunk — a ~25–50× memory
entries (≈ 400+ MiB) from ONE peer chunk, a ~25–50× memory
amplification riding a bounded channel buffer. Example-based tests
structurally cannot find this; a byte-sequence target with a
pending-bytes assertion will.
**The honest caveat** (mirroring alkcall §1): the highest-value alkcall
targets — envelope framing, chunk header, demux, manager — are upstream
targets (envelope framing, chunk header, demux, manager) are upstream
and now fuzzed (0.8.1). Our params parsing is serde + registry-side
jsonschema (upstream's `spec_parse` target covers attacker-shaped
schemas compiling at registration). Expected yield here: datagram-codec
pathologies, the pending-queue bound (§1.3), serde/untagged-enum edge
cases in params, and lifecycle invariants in the two-pump shape. That
is worthwhile, not five-targets-worth of new machinery on day one —
is worthwhile, not five-targets-worth of new machinery on day one,
hence the two-wave scope in §4. If the first campaigns find nothing,
that is the successful-negative-result posture alkcall's §1 codified:
"we think the codec is robust" becomes a demonstrated property, and the
@@ -69,7 +69,7 @@ nightly confined to the `fuzz/` subtree via `fuzz/rust-toolchain.toml`,
the crate itself stays stable at MSRV 1.88. AFL optional/deferred;
bolero deferred (same async-friction argument); libafl/honggfuzz/OSS-Fuzz
not adopted. Sanitizer: `--sanitizer=address` with debug assertions
(cargo-fuzz defaults) — no MSAN (no `unsafe` in this crate).
(cargo-fuzz defaults); no MSAN (no `unsafe` in this crate).
**Layout (the alkcall pattern, load-bearing):**
@@ -81,7 +81,7 @@ fuzz/
│ ├── datagram_codec.rs
│ ├── params_parse.rs
│ └── session_pumps.rs
├── shared/ alktunnels-fuzz-shared — STABLE-toolchain library
├── shared/ alktunnels-fuzz-shared, the STABLE-toolchain library
│ └── src/… invariant logic + corpus replay (§3)
├── corpus/<target>/ committed hand-made seeds (§3)
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
@@ -90,16 +90,16 @@ fuzz/
```
- **Invariant logic lives in `fuzz/shared/`**, not inline in the target
binaries — the stable-toolchain corpus replay
binaries: the stable-toolchain corpus replay
(`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME
invariant functions the fuzzer runs, on stable, in the release
verification checklist. This is CI tier-3's deliverable (alkcall
§7.2) minus automation; per alkcall §7.9 this repo has no hosted CI
and will not get one (same gitea posture) — corpus replay as plain
and will not get one (same gitea posture); corpus replay as plain
`cargo test` is the fuzz gate.
- **Root `Cargo.toml` needs the explicit `[workspace]` table**
(`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the
publish exclude list — without it cargo auto-discovers `fuzz/shared/`
publish exclude list; without it cargo auto-discovers `fuzz/shared/`
into the main workspace and the MSRV toolchain tries to build the
nightly-consumed dev-deps (the exact failure alkcall hit).
- **Corpus policy:** commit hand-made seeds, gitignore grown corpora
@@ -120,12 +120,12 @@ arbitrary). Drives `DatagramReader::feed` + `frame_datagram`
- **no-panic on any byte sequence** (the baseline; the one
`unreachable!` in `feed` is a structural invariant of the state
machine — the target is how it stays true).
- **infallibility**: `feed` never errors (contract — oversize is
machine; the target is how it stays true).
- **infallibility**: `feed` never errors (contract: oversize is
rejected at frame time; the decode direction is infallible by
design, `src/wire.rs` docs).
- **datagram-size bound**: every yielded datagram is
`<= MAX_DATAGRAM_LEN` (u16 frame field makes this structural — the
`<= MAX_DATAGRAM_LEN` (u16 frame field makes this structural; the
target pins it against future refactors).
- **no partial yields**: a datagram completes only when exactly
`declared` payload bytes are buffered (`is_mid_datagram` observable
@@ -144,15 +144,15 @@ arbitrary). Drives `DatagramReader::feed` + `frame_datagram`
`MAX_DATAGRAM_LEN`, echoing the sizes.
- **pending-queue bound (the §1.3 finding-class assertion)**: total
bytes retained across `buf` + `current` + `pending` after a feed is
bounded by the fed chunk size plus the one in-flight frame —
bounded by the fed chunk size plus the one in-flight frame, so
an adversarial peer chunk cannot force unbounded queue growth in
the decoder state. (NOTE: this is the *decoder-state* bound; the
consumer-side `DatagramHalves.pending` backlog between chunk-read
and `recv_datagram` pulls is a session-shape question tracked in
§4/§8 — the wave-2 stateful target, or a code fix if the bound is
§4/§8 (the wave-2 stateful target, or a code fix if the bound is
judged unacceptable. The fuzzer documents the amplification factor;
the remediation decision is ours. **Wave-2 resolution (§3.3):** the
session-shape backlog is now an asserted invariant — the harness's
session-shape backlog is now an asserted invariant: the harness's
per-op drain holds the pending queue to the routed-batch remainder
(≤ one chunk's worth of batched datagrams, drained by
`recv_datagram` pulls), so the amplification question is bounded
@@ -162,7 +162,7 @@ arbitrary). Drives `DatagramReader::feed` + `frame_datagram`
### 3.2 `params_parse` (wave 1)
Input: raw bytes → `serde_json` (with alkcall's bounded
`arbitrary_value` approach — depth/width-capped `Value` generation) →
`arbitrary_value` approach, depth/width-capped `Value` generation) →
`TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` /
`SubstrateAddr` deserialization + `parse_listen_reply` /
`listen_reply_fields` (`src/params.rs`). Invariants:
@@ -176,7 +176,7 @@ Input: raw bytes → `serde_json` (with alkcall's bounded
constructed params of every substrate.
- **deny-unknown posture**: unknown top-level fields reject;
untagged-variant leniency (extra fields inside a `SubstrateAddr`
variant) is the documented asymmetry — asserted, not "fixed".
variant) is the documented asymmetry (asserted, not "fixed").
- **the `listen: 0` pin**: zero rejects at serde level.
- **projection agreement**: `listen_reply_fields` yields both-`Some`
iff `parse_listen_reply` is `Ok`; it never panics and never refuses
@@ -186,16 +186,16 @@ Input: raw bytes → `serde_json` (with alkcall's bounded
### 3.3 `session_pumps` (wave 2)
Input: op sequences (`#[derive(Arbitrary)]` `SessionSequence` — the
Input: op sequences (`#[derive(Arbitrary)]` `SessionSequence`, the
`manager_routing` pattern) over one live channels stack per exec:
MuxRunner → raw chunk stream → the real `run_demux_loop_for_client`
demux → `ChannelManager` per side (producer Connect / consumer
Accept over one duplex transport — the reverse POC's topology). Ops:
Accept over one duplex transport, the reverse POC's topology). Ops:
`Route` (producer→consumer framed datagrams / raw stream bytes via
`route_payload`), `AdoptDatagram` / `AdoptStream` (the
`TunnelSession` data-plane mirrors — codec reader + `pending`, and
`TunnelSession` data-plane mirrors: codec reader + `pending`, and
`pump_bidi` against in-process peer halves), `Pump` (peer→channel;
`len == 0` drops the p2c source — the EOF path), `Teardown`,
`len == 0` drops the p2c source, the EOF path), `Teardown`,
`ClearAll`. Invariants:
- **no-panic / no-wedge on any op sequence** (the baseline; the
@@ -203,23 +203,24 @@ Accept over one duplex transport — the reverse POC's topology). Ops:
and §5's `-timeout=25` fork containment is the outer net).
- **two-pump shutdown-on-completion (convention 8)**: after the p2c
source drops, the pump's channel-side shutdown must propagate and
the pump must complete once the teardown EOFs the c2p source —
the pump must complete once the teardown EOFs the c2p source,
pinned by the `reconcile` join with the 5 s bound.
- **lossless p2c copy**: the pump's `p2c` count equals exactly what
the harness pushed into the peer half (copy never truncates).
- **EOF-sentinel teardown semantics**: after a sentinel (teardown /
`clear_all`), the reassembled stream drains its queue before
reporting EOF — every routed datagram consumable, nothing lost;
reporting EOF, with every routed datagram consumable and nothing
lost;
the consumer map empties; the codec's `len == 0` sentinel layering
holds at the session layer (a routed empty datagram is data; the
channels EOF is a separate mechanism).
- **FIFO reconciliation**: every complete frame routed through the
real demux path arrives as exactly one datagram of the declared
length, in order (the model walks the framing as the codec does —
the wave-1 F-2 walk, reused at the session layer).
length, in order (the model walks the framing as the codec does,
the wave-1 F-2 walk reused at the session layer).
- **pending-queue bound (the §1.3 finding-class assertion)**: the
session-shape `DatagramHalves.pending` backlog never exceeds the
unrouted remainder — i.e. the backlog holds only datagrams decoded
unrouted remainder (i.e. the backlog holds only datagrams decoded
from routed chunks beyond the ones consumed; with the harness's
per-op drain the queue returns to zero after every op, so an
amplification factor > 1× chunk size would trip the assert. (The
@@ -230,12 +231,12 @@ Accept over one duplex transport — the reverse POC's topology). Ops:
Bounds: 128 ops, ≤ 512-byte payloads (generator-clamped; the codec's
`MAX_DATAGRAM_LEN` stays the wire bound). Even-id masking (ADR-047
§5 — the consumer is the Accept side; the producer allocates even).
§5: the consumer is the Accept side; the producer allocates even).
## 4. Scope: two waves
**Wave 1 (complete):** the two targets above. Both drive already-
`pub` APIs — no `#[cfg(fuzzing)]` exposure needed (matching alkcall's
`pub` APIs, with no `#[cfg(fuzzing)]` exposure needed (matching alkcall's
experience where none of five targets needed it).
**Wave 2 (this session):** `session_pumps`, the stateful op-sequence
@@ -244,7 +245,7 @@ style (`#[derive(Arbitrary)]` op enum; live mux + demux runners; the
alkcall harness lessons applied: even-id masking, drained-state
asserts). Drives: the `TunnelSession` data-plane shapes (the datagram
codec plane + the `pump_bidi` stream plane) over a REAL channels
stack — the two-pump shutdown-on-completion contract (AGENTS.md
stack: the two-pump shutdown-on-completion contract (AGENTS.md
convention 8), the EOF-sentinel teardown semantics, and the §1.3
backlog bound as byte-accounting invariants (§3.3). The
`forwarded_routing` correlation target stays deferred until the
@@ -265,40 +266,44 @@ run is precisely the workload shape that kills its own host:
- **OOM-killer shared fate**: the target's worst case IS unbounded
allocation. If libFuzzer's soft RSS guard loses the race with the
kernel, the OOM killer fires — and it picks the largest-RSS process
kernel, the OOM killer fires, and it picks the largest-RSS process
in the cgroup, which can be the opencode server hosting the session.
That is *recoverable* (sessions are recorded in a DB) but hugely
derailing — the session's live environment access dies mid-task.
derailing, since
the session's live environment access dies mid-task.
An OOM in a fuzz target must cost the fuzzer, never the session.
- **Fork bombs / CPU saturation**: `-fork=N` workers; a runaway
campaign starves the session host.
- **Wedged targets**: a deadlock in an async harness would hang a
foreground bash call forever.
Three layers of defense, cheapest first — **all mandatory**:
Three layers of defense, cheapest first, **all mandatory**:
1. **libFuzzer soft limits (always on)**: `-rss_limit_mb=2048`
`-malloc_limit_mb=2048` make libFuzzer exit cleanly (artifact
recorded) before the kernel gets involved. Note the RSS limit is
*soft* (polls `/proc/self/statm`); a fast huge allocation can beat
it — that is what layers 2–3 are for. Never `ulimit -v` with ASAN
it, and that is
what layers 2–3 are for. Never `ulimit -v` with ASAN
(it reserves terabytes of virtual address space); the ASAN-safe
hard-limit recipe is `-fork=1`.
2. **Fork mode as blast-radius containment (default)**: `-fork=1`
(or a small number) runs each input in a short-lived child — a
(or a small number) runs each input in a short-lived child, so a
crash/timeout/OOM/leak kills only that child; the harness survives,
records the artifact, keeps fuzzing. Also the deadlock answer: the
per-input timeout kills the child, not the session's bash call.
3. **Process detachment from the session tree (MANDATORY for agent-run
campaigns)**: never run fuzzing as a foreground child of the
session. The runner script wraps `cargo fuzz run` in `setsid` +
`nohup` + full redirection to a log file: (a) removes the fuzzer
`nohup` + full redirection to a log file, which (a) removes the
fuzzer
from the session's controlling terminal and signal relation, (b)
survives the session ending, (c) gives the agent a pollable log +
artifact directory instead of a blocking call. Combined with fork
mode, an OOM-class finding costs one child process.
**The runner** (`fuzz/run-detached.sh`, CWD-independent — resolves its
**The runner** (`fuzz/run-detached.sh`, CWD-independent, since it
resolves its
own repo root):
```bash
@@ -323,12 +328,12 @@ echo "pid=$! log=$log"
```
**Agent polling protocol** (never `wait` on the detached process from a
tool call — poll instead):
tool call; poll instead):
- `tail -n 50 fuzz/artifacts/<target>-*.log` — campaign progress.
- `ls fuzz/artifacts/` — `crash-*` / `oom-*` / `timeout-*` / `leak-*`
- `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?
- `pgrep -f "cargo fuzz run <target>"`: still running?
- One detached campaign per target at a time; log filenames carry the
UTC timestamp.
@@ -338,29 +343,29 @@ the params target (alkcall's `json.dict` transfers as-is).
## 6. No hosted CI
Per alkcall §7.9 — the standing policy applies to this repo identically:
Per alkcall §7.9, the standing policy applies to this repo identically:
no hosted CI will exist; do not add Actions/Gitea-Actions workflow
files. **Corpus replay is the fuzz gate as plain `cargo test`**:
`cargo test --manifest-path fuzz/shared/Cargo.toml` replays every
committed seed through the invariant functions — add it to the release
committed seed through the invariant functions; add it to the release
verification checklist (AGENTS.md). Campaigns run manually via the
detached runner (§6) — e.g. before a release, or after touching
detached runner (§6), e.g. before a release, or after touching
`src/wire.rs`, the consumer data planes, or `src/params.rs`.
## 7. Implementation log
- **2026-09-28 — proposal adopted** (this doc). Wave-1 targets
- **2026-09-28, proposal adopted** (this doc). Wave-1 targets
`datagram_codec` + `params_parse`; wave-2 deferred (§4).
- **2026-09-28 — `alkcall` bumped 0.8.0 → 0.8.1.** Bug-fix release, no
- **2026-09-28, `alkcall` bumped 0.8.0 → 0.8.1.** Bug-fix release, no
API/wire change; the fix (duplicate adopt/open no longer destroying
the live channel) is directly relevant to this crate's
`TunnelSession::adopt` path — the changelog's "open-op response
`TunnelSession::adopt` path: the changelog's "open-op response
replayed / id re-announced" scenario is our reverse path. Verification:
full suite (135 passed), clippy, fmt, wasm check (§8 commands).
- **2026-09-28 — wave-1 implemented.** `fuzz/` workspace laid out per
- **2026-09-28, wave-1 implemented.** `fuzz/` workspace laid out per
§2 (nightly-pinned subtree, stable `fuzz/shared` library, runner,
dict, generator); `datagram_codec` + `params_parse` targets over
already-`pub` APIs — no `#[cfg(fuzzing)]` exposure needed. One new
already-`pub` APIs, with no `#[cfg(fuzzing)]` exposure needed. One new
public diagnostic: `DatagramReader::retained_bytes()` (the
decoder-state byte count the §3.1 bound asserts against, in the
`is_mid_datagram` diagnostic style). Seeds committed: 27 +
@@ -368,99 +373,103 @@ detached runner (§6) — e.g. before a release, or after touching
input promoted to `datagram_codec/seed-900` (see the finding below).
Stable side: corpus replay green, shared crate clippy/fmt clean;
main suite untouched and green; `cargo fuzz build` clean.
- **2026-09-28 — first campaign finding: a HARNESS-model bug, not a
- **2026-09-28, first campaign finding: a HARNESS-model bug, not a
crate bug.** The `datagram_codec` campaign crashed in its first
minute on input `00 2b 01 00 00 05` (`fuzz/artifacts/datagram_codec/
crash-e9ba284e…`, exit 77, fork-mode containment worked as
designed — the harness parent survived and persisted the artifact).
Analysis: the codec is CORRECT — `00 2b` declares a 43-byte
designed: the harness parent survived and persisted the artifact).
Analysis: the codec is CORRECT, since `00 2b` declares a 43-byte
datagram; `01 00 00 05` is its in-flight payload; nothing is
yielded. The invariant that tripped ("an empty datagram must come
only from a [00 00] length header") was mis-modeled: it counted
`00 00` byte pairs anywhere in the RAW stream, but a `00 00` pair
sitting INSIDE a frame's payload region is payload, not a length
header — exactly the same class as alkcall §7.7's probe (the
header, which is exactly the same class as alkcall §7.7's probe (the
harness claimed `4 + len` when the prefix counts only the body).
Fix: the count now walks the framing as the decoder does (complete
frames from the start; the walk stops where the decoder would be
stuck mid-frame). The crash input is committed as seed-900 and
replays clean; the campaign relaunched clean.
- **2026-09-28 — second harness finding (caught by corpus replay,
- **2026-09-28, second harness finding (caught by corpus replay,
pre-campaign): `establishment_reason` is a pass-through extractor,
not a validated table.** The params.rs doc names the pinned reason
strings, but the upstream implementation (alkcall
`channels/client.rs` `establishment_reason`) extracts
`details.reason` AS-IS whenever the code is `channel:open_failed` —
`details.reason` AS-IS whenever the code is `channel:open_failed`,
so
a made-up reason is surfaced as-is by design, and validation is the
consumer's job. The harness first asserted a closed set of reason
strings and corpus replay tripped it on `made_up_reason` before any
campaign ran. Invariant corrected to assert the extraction shape
(Some iff code == `channel:open_failed` and reason is a string).
- **2026-09-28 — second harness finding (same replay pass):
- **2026-09-28, second harness finding (same replay pass):
`is_mid_datagram` means a partial DATAGRAM, not buffered header
bytes.** A dangling 1-byte length prefix at input end is buffered
header state (no frame in flight, `is_mid_datagram == false`);
the harness first asserted `true` for that case. Corrected:
mid-datagram iff the framing walk leaves a remainder ≥ 2 bytes
(a parsed-but-unfinished payload), not `!= 0`.
- **2026-09-28 — wave-1 campaigns run (10-min detached runs, §6 runner;
- **2026-09-28, wave-1 campaigns run (10-min detached runs, §6 runner;
`-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25`;
codec with `-max_len=65536`, params with `-dict=json.dict
-use_value_profile=1`). Both exited 0.**
- `params_parse` — 3.14M execs (~5.3k exec/s), 33 fork jobs,
- `params_parse`: 3.14M execs (~5.3k exec/s), 33 fork jobs,
`oom/timeout/crash: 0/0/0` throughout. Coverage 2049 edges /
15.6k features; in-memory corpus 69 committed seeds → 3018
entries — the serde/untagged-enum shape space was still expanding
entries; the serde/untagged-enum shape space was still expanding
at budget end, so longer campaigns keep paying here.
- `datagram_codec` (post-harness-fix) — 827k execs, 33 fork jobs,
- `datagram_codec` (post-harness-fix): 827k execs, 33 fork jobs,
`oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 221
edges (ft 1198) — the 2-byte-header state machine is fully
edges (ft 1198); the 2-byte-header state machine is fully
enumerated, matching alkcall §7.7's `chunk_header` saturation
profile. Exec/s reads low (~400) because each exec replays the
input through a ~35-feed 3-byte-sliced sequence alongside the
whole-input feed; the state space is small and exhausted early.
- The pre-fix `datagram_codec` run (the §7 finding above) crashed at
job 4 and exited 77; fork-mode containment behaved as designed —
the harness survived, persisted the artifact, and the run wrapped
up. That artifact is the promoted seed-900.
job 4 and exited 77; fork-mode containment behaved as designed,
with the harness surviving, persisting the artifact, and the run
wrapping up. That artifact is the promoted seed-900.
- Grown corpora (3018 params entries) were **not** merged into the
committed seeds — per the §2 policy, grown corpora stay
committed seeds, since
per the §2 policy, grown corpora stay
gitignored.
- Operational note for future sessions: `cargo fuzz run` returns
before its last fork-mode children fully exit; a `pkill -f
"cargo fuzz run <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
process match. Prefer `pgrep -af` to LIST, kill by explicit pid
from the runner's echoed `pid=`, and never pattern-kill-and-recheck
in one call.
- **NO crate bugs found by wave 1.** The two findings this session
were both harness-model defects (§7 above), each invisible to
example-based review and caught by fuzzing/replay — the alkcall §7.7
example-based review and caught by fuzzing/replay: the alkcall §7.7
"fuzzer as a harness-checker" pattern, confirmed. The §1.3
decoder-state bound held everywhere: `retained_bytes()` never
exceeded fed + one frame on any adversarial input. The session-shape
`DatagramHalves.pending` amplification remains a documented
§1.3/§4 note (wave-2's motivation), not a campaign finding — the
§1.3/§4 note (wave-2's motivation), not a campaign finding, since
the
wave-1 targets assert the decoder, not the session pump.
- **2026-09-28 — wave-2 implemented: `session_pumps` (the stateful
- **2026-09-28, wave-2 implemented: `session_pumps` (the stateful
two-pump/EOF-sentinel target, §3.3).** The harness mirrors
`TunnelSession`'s data-plane shapes over one REAL channels stack
per exec (MuxRunner → raw chunk stream → the real
`run_demux_loop_for_client` demux → `ChannelManager` per side over
one duplex transport; producer Connect / consumer Accept — the
one duplex transport; producer Connect / consumer Accept, the
reverse POC's topology). Ops: Route/AdoptDatagram/AdoptStream/
Pump/Teardown/ClearAll; invariants per §3.3. One diagnostic
addition to the crate: `TunnelSession::datagram_backlog()` (the
`DatagramHalves.pending` count the §3.3 bound asserts against, in
the `retained_bytes()` diagnostic style) + a suite test pinning
its rest states. Seeds: 8 committed (deterministic decode-scan
hex patterns, the `manager_routing` precedent — arbitrary ships
hex patterns, the `manager_routing` precedent, since
arbitrary ships
no encoder) + 2 in-source semantic fixtures (the §1.3 batched-
backlog probe; the W4 half-close shape). Two harness-model
findings caught by corpus replay pre-campaign:
- **the p2c-EOF pump-join wedge is the DESIGNED half-close
shape, not a wedge.** The first `reconcile` draft awaited the
pump after dropping only the p2c source — but the c2p pump's
pump after dropping only the p2c source, but the c2p pump's
source (the channel read half) EOFs only via teardown, so the
join legitimately blocked (the 5 s bound tripped). `pump_bidi`
completes when BOTH pumps finish; the half-close contract is
@@ -468,46 +477,46 @@ detached runner (§6) — e.g. before a release, or after touching
observable), and the pump completes once the c2p source EOFs
too. The harness now models exactly that: drop p2c source →
teardown the entry → join with the 5 s bound. (The alkcall §7.7
harness-checker pattern again — the fuzzer harness's first
harness-checker pattern again: the fuzzer harness's first
campaign-found crash in wave 1 and this replay catch are both
harness-model defects, not crate bugs.)
- **the stream-plane c2p accounting is asynchronous** — the pump
- **the stream-plane c2p accounting is asynchronous**: the pump
writes channel→peer into the peer duplex while a far-end reader
task counts; the reconcile join (after the pump completes) is
when the count is final. First draft read it early. Fixed by
reading after the reader task's join.
- **2026-09-28 — third harness finding (campaign-found, fork-mode
- **2026-09-28, third harness finding (campaign-found, fork-mode
containment as designed): duplicate adopt is a LEGAL manager
outcome, not a finding.** The first campaign crashed at ~49 s on
`AdoptDatagram { channel_id: 0 }` after the id had already been
adopted — `ChannelExists` is the manager's documented
adopted. `ChannelExists` is the manager's documented
id-collision error, and my adopt arms treated every error as a
finding. Fix: both adopt arms model the documented error table
(`ChannelExists` / `TooManyChannels` are no-ops, exactly the
alkcall `manager_routing` harness's treatment). The crash input
is committed as seed-900 and replays clean; the campaign
relaunched clean.
- **2026-09-28 — fourth harness finding (second campaign): the
- **2026-09-28, fourth harness finding (second campaign): the
harness modeled the producer→consumer path through the WRONG
primitive.** The second campaign crashed at ~5 s on `Route`:
raw-id addressing (`even_id` masked arbitrary ids; collisions +
an `UnknownChannel`-class state the model didn't track), `len ==
0` raw routes doubling as EOF sentinels mid-model, and — the
structural one — `route_payload` was used as the producer's
0` raw routes doubling as EOF sentinels mid-model, and
(the structural one) `route_payload` was used as the producer's
forward write. `route_payload` routes into the LOCAL manager's
channel entry (the demux direction); the producer pump handler
writes the channel's SEND half (the mux pump frames onto the
transport). Bytes never reached the consumer. Rewrite (the real
protocol shape, matching `manager_routing`'s op-vocabulary
lesson): producer `Open` (`open_channel` allocates the id, the
harness holds the send half — the pump handler's write side),
harness holds the send half, the pump handler's write side),
session-INDEX addressing (no masking, no collisions), `Adopt`
consumes the producer-allocated id (the early-arrival drain is
the real adopt-race path), `Route` writes the producer send half
(the real wire path: mux pump → transport → consumer demux →
reassembly), `Pull` (the `recv_datagram` pull pops the backlog +
the model), and a STATEFUL framing model (partial frames persist
across ops — the wave-1 walk carried across ops). EOF sentinels
across ops, with the wave-1 walk carried across ops). EOF sentinels
now arise only from teardown/sender-drop (the real mechanisms),
never from `len == 0` routes (F-2: a routed empty datagram is
DATA). All committed session_pumps seeds regenerated by the
@@ -515,22 +524,22 @@ detached runner (§6) — e.g. before a release, or after touching
the semantic fixtures re-pinned. Verification of the fix: the
campaign's 0-exec crash went away; batched frames now reconcile
FIFO through the real two-manager stack.
- **2026-09-28 — fifth + sixth harness findings (third campaign's
- **2026-09-28, fifth + sixth harness findings (third campaign's
first minutes): the two ClearAll modeling defects.** (5) A
double-adopt of a live session panicked the harness's own guard —
double-adopt of a live session panicked the harness's own guard,
but a re-adopt of a LIVE id is the manager's documented
`ChannelExists` outcome (a legal no-op; alkcall 0.8.1's
insert-replace fix leaves the live entry intact). Modeled as a
no-op. (6) A `ClearAll` followed by `Route` on the same session
tripped the backlog reconciliation — the harness retired only the
tripped the backlog reconciliation. The harness retired only the
sessions whose ids appeared in `clear_all`'s RETURN value, but
that value is the OPENER-LEDGER records (ADR-047 §7): adopted
channels carry no ledger entry, so the set is empty while the map
itself empties. The alkcall §7.7 lesson ("`drained.len() ==
open_count()` is not an invariant"), learned again at this
crate's layer — the map-empties assert is the ground truth; the
crate's layer: the map-empties assert is the ground truth; the
harness now retires EVERY session on `ClearAll`.
- **2026-09-28 — wave-2 campaign run (10-min detached run, §6
- **2026-09-28, wave-2 campaign run (10-min detached run, §6
runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048
-timeout=25 -max_len=4096 -use_value_profile=1`): exited 0.**
228,676 execs (~380 exec/s; each exec replays up to 128 ops on a
@@ -538,7 +547,7 @@ detached runner (§6) — e.g. before a release, or after touching
`oom/timeout/crash: 0/0/0` throughout. Coverage 3427 edges /
17,529 features / 1184 in-memory corpus entries and STILL
EXPANDING at budget end (the op-interleaving space is much larger
than the parser targets') — longer campaigns keep paying. The
than the parser targets'), so longer campaigns keep paying. The
two-pump contract held across every adversarial sequence: no
wedged pump (the 5 s reconcile join), no lost datagram (the
FIFO/backlog reconciliation), no premature EOF (the sentinel
@@ -547,14 +556,14 @@ detached runner (§6) — e.g. before a release, or after touching
routed-but-unpulled remainder.
- **NO crate bugs found by wave 2.** All six findings across waves
1–2 were harness-model defects (four this session), each
invisible to example-based review and caught by fuzzing/replay —
invisible to example-based review and caught by fuzzing/replay:
the alkcall §7.7 "fuzzer as a harness-checker" pattern,
re-confirmed. The stateful thesis is validated from the harness
side: the harness defects themselves (double-adopt semantics, the
clear_all ledger/map distinction, the route_payload direction)
are exactly the interleaving-shape mistakes the alkcall §7.8
campaigns caught in their own drivers.
- (progress appended as implementation proceeds — see git log)
- (progress appended as implementation proceeds; see git log)
## 8. Verification commands
@@ -579,9 +588,9 @@ Campaign flags per target: `datagram_codec -max_len=65536`;
- Upstream campaign + operational model (authoritative for tooling,
runner, corpus policy, no-CI posture):
`/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
- alkcall 0.8.1 changelog — the finding our adopt path is exposed to
- alkcall 0.8.1 changelog: the finding our adopt path is exposed to
- This crate: ADR-003 (codec framing), ADR-002 (ALPN), ADR-007/008
(direct/forwarded ops), `docs/architecture/wire.md`
- External: RUSTSEC-2026-0037 (quinn-proto — the maintainers'
- External: RUSTSEC-2026-0037 (quinn-proto, the maintainers'
fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust
Fuzz Book (all linked from the upstream doc's §9)
+161 -158
View File
@@ -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)
+64 -60
View File
@@ -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).
+85 -80
View File
@@ -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`.
+84 -83
View File
@@ -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.
+74 -65
View File
@@ -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
View File
@@ -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
```
+3 -3
View File
@@ -27,9 +27,9 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b8ef0591beff0e3fe64b7277a67d8e7caeccf71c27538b4b7aced028d22ca44"
checksum = "690d747f42baf385dc22cd120f79d43cf1ea239bc426675e0e1e225b57fc031a"
dependencies = [
"async-trait",
"bytes",
@@ -48,7 +48,7 @@ dependencies = [
[[package]]
name = "alktunnels"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"alkcall",
"bytes",
+7 -7
View File
@@ -7,19 +7,19 @@ from alkcall's `docs/research/fuzzing.md`).
## Layout
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers.
- `shared/` — the invariant logic, as a plain library so normal
- `fuzz_targets/`: one binary per target; thin `fuzz_target!` wrappers.
- `shared/`, the invariant logic, as a plain library so normal
`cargo test` (stable toolchain) can replay the committed corpora
through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]`
modules; quinn's CI pattern). The fuzz binaries are nightly-only;
the shared crate is stable-clean.
- `corpus/<target>/` — committed hand-made seeds (regenerate with
- `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` — mandatory runner for agent sessions: wraps
- `run-detached.sh`, the mandatory runner for agent sessions: wraps
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
in a target can never take down the agent host (fuzzing.md §5).
- `json.dict` — JSON token dictionary for the params target
- `json.dict`: a JSON token dictionary for the params target
(transferred from alkcall).
## Targets
@@ -28,13 +28,13 @@ from alkcall's `docs/research/fuzzing.md`).
|---|---|---|
| `datagram_codec` | `DatagramReader::feed` / `frame_datagram` (`src/wire.rs`) | no-panic; infallible decode; yielded datagrams ≤ `MAX_DATAGRAM_LEN`; F-2 sentinel layering (empty datagram ≠ EOF, only from `[00 00]` headers); splitting transparency (3-byte slices == whole-input feed); re-frame round-trip; oversize rejection exactly at the bound; `is_mid_datagram` tracks framing; decoder-state retained-bytes bound |
| `params_parse` | `TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` / `SubstrateAddr` serde parses + `parse_listen_reply` / `listen_reply_fields` / `establishment_reason` (`src/params.rs`) | no-panic on raw bytes and structured Values; clean typed failure only; round-trip for valid params; `listen: 0` pin; loud/lenient projection-pair agreement; pinned establishment-reason table |
| `session_pumps` | op sequences (`SessionSequence`) over one live channels stack per exec (MuxRunner → real demux → `ChannelManager` per side; the `TunnelSession` data-plane mirrors — codec plane + `pump_bidi` stream planes) | no-panic / no-wedge (5 s reconcile bound); two-pump shutdown-on-completion; lossless p2c copy count; EOF-sentinel teardown semantics (drain-before-EOF, map empties); FIFO datagram reconciliation (framing-walk model); `pending` backlog ≤ routed-batch remainder (the §1.3 bound); decoder-state retained-bytes bound re-asserted per op; monotonic counters |
| `session_pumps` | op sequences (`SessionSequence`) over one live channels stack per exec (MuxRunner → real demux → `ChannelManager` per side; the `TunnelSession` data-plane mirrors, codec plane + `pump_bidi` stream planes) | no-panic / no-wedge (5 s reconcile bound); two-pump shutdown-on-completion; lossless p2c copy count; EOF-sentinel teardown semantics (drain-before-EOF, map empties); FIFO datagram reconciliation (framing-walk model); `pending` backlog ≤ routed-batch remainder (the §1.3 bound); decoder-state retained-bytes bound re-asserted per op; monotonic counters |
## Commands
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
(or deeper) as the working directory so rustup selects the pinned
nightly toolchain — the detached runner handles that itself.
nightly toolchain. The detached runner handles that itself.
```sh
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Detached fuzzing runner for agent sessions: the fuzz campaign never
# runs as a foreground child of the session (OOM in a target must not
# take down the agent host — the opencode server shares the process
# take down the agent host (the opencode server shares the process
# tree and cgroup), and survives the session ending.
#
# Usage: fuzz/run-detached.sh <target> [extra libfuzzer args...]
+1 -1
View File
@@ -1,4 +1,4 @@
//! `Arbitrary` support for `serde_json::Value` — a bounded-depth
//! `Arbitrary` support for `serde_json::Value`: a bounded-depth
//! recursive generator shared by the semantic targets. Depth and size
//! bounds keep exec/s high (serde_json's own 128-depth recursion limit
//! is the decode-side guard; this generator explores structure).
+7 -6
View File
@@ -10,8 +10,8 @@ use alktunnels::wire::{frame_datagram, DatagramCodecError, DatagramReader, MAX_D
/// accumulation stay bounded by the fed chunk size plus one frame
/// (the header + a full u16 payload). The session-shape
/// `DatagramHalves.pending` backlog between chunk reads and
/// `recv_datagram` pulls is a separate question (fuzzing.md §3.1) —
/// this asserts the decoder state itself never grows with input.
/// `recv_datagram` pulls is a separate question (fuzzing.md §3.1).
/// This asserts the decoder state itself never grows with input.
fn assert_retained_bound(reader: &DatagramReader, fed: usize) {
let retained = reader.retained_bytes();
let max = fed + MAX_DATAGRAM_LEN + 2;
@@ -25,7 +25,7 @@ fn assert_retained_bound(reader: &DatagramReader, fed: usize) {
pub fn fuzz_datagram_codec(data: &[u8]) {
// The infallible decode direction: any chunk sequence feeds
// cleanly. Drive the reader with the whole input and with awkward
// 3-byte slices — splitting transparency must hold for both.
// 3-byte slices; splitting transparency must hold for both.
let mut whole = DatagramReader::new();
let whole_dgs = whole.feed(data);
assert_retained_bound(&whole, data.len());
@@ -57,7 +57,8 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
}
// F-2 sentinel layering: an empty datagram comes only from a real
// `[00 00]` length header **at a frame boundary** — never invented
// `[00 00]` length header **at a frame boundary**: it is never
// invented
// by the codec, and never treated as stream end. The count walks
// the framing (a [00 00] pair inside a payload region is payload,
// not a header).
@@ -139,7 +140,7 @@ fn count_empty_frames(data: &[u8]) -> usize {
}
/// Split the input into complete `[len: u16 BE][payload]` frames from
/// the start. Returns the frames and the byte length they occupy —
/// the start. Returns the frames and the byte length they occupy;
/// the remainder (if any) is a partial/trailing region.
fn split_frames(data: &[u8]) -> (Vec<&[u8]>, usize) {
let mut frames = Vec::new();
@@ -160,7 +161,7 @@ fn ends_inside_frame(data: &[u8], well_framed_len: usize) -> bool {
// Mid-datagram iff the well-framed prefix leaves a *parsed but
// incomplete* frame: a remainder ≥ 2 bytes means the length header
// was consumed and the payload is in flight (a remainder of 1 byte
// is a buffered header fragment — not yet a datagram in flight).
// is a buffered header fragment, not yet a datagram in flight).
let remainder = data.len() - well_framed_len;
remainder >= 2
}
+3 -3
View File
@@ -30,7 +30,7 @@ pub fn fuzz_params_parse(data: &[u8]) {
}
/// Run every params/reply parser against one JSON value. Each parser
/// must return cleanly (Result / typed projection) — a panic is the
/// must return cleanly (Result / typed projection); a panic is the
/// finding.
fn parse_shapes(value: &Value) {
// Open-op params: wire-stable {resource, substrate}.
@@ -100,8 +100,8 @@ where
assert_eq!(&back, params);
}
/// The typed establishment-reason surface: a pass-through extractor —
/// `Some(details.reason)` iff the code is `channel:open_failed` and
/// The typed establishment-reason surface: a pass-through extractor,
/// yielding `Some(details.reason)` iff the code is `channel:open_failed` and
/// the reason is a string, `None` otherwise (any string is surfaced
/// as-is; validation is the consumer's job). The harness must model
/// extraction, not a validated table.
+28 -28
View File
@@ -1,18 +1,18 @@
//! Invariants for the stateful two-pump session shape (fuzzing.md
//! §3.3, wave 2): one real channels stack per sequence — MuxRunner →
//! §3.3, wave 2): one real channels stack per sequence (MuxRunner →
//! raw chunk stream → `run_demux_loop_for_client` → ChannelManager
//! per side — under adversarial op sequences, with the two-pump
//! per side), under adversarial op sequences, with the two-pump
//! shutdown-on-completion contract (AGENTS.md convention 8), the
//! EOF-sentinel teardown semantics, and the `DatagramHalves.pending`
//! backlog bound (§1.3) asserted by byte accounting.
//!
//! Topology (the real protocol shape, mirrored): the PRODUCER is the
//! connect side — `Open` allocates a session (producer
//! connect side: `Open` allocates a session (producer
//! `open_channel` + a producer-side drainer, the pump handler's
//! stand-in) and the CONSUMER (accept side) `Adopt`s the
//! producer-allocated id (the early-arrival drain is the real
//! adopt-race path). Sessions are addressed by INDEX into the
//! harness's session table (no id collisions, no masking — the
//! harness's session table (no id collisions, no masking, so the
//! generator stays simple); ids are the manager's own allocation.
//!
//! Data-plane shapes mirror `TunnelSession`: datagram planes drive
@@ -26,7 +26,7 @@
//!
//! The framing model is STATEFUL per datagram session: a partial
//! trailing frame persists in the model's walk offset (the decoder
//! buffers it; the next chunk completes it) — exactly the wave-1
//! buffers it; the next chunk completes it), exactly the wave-1
//! framing walk, carried across ops. Sentinel layering (F-2) holds
//! at the session layer: a routed empty datagram is DATA (the codec
//! yields it); the channels EOF sentinel is a separate mechanism
@@ -51,13 +51,13 @@ use alktunnels::wire::{DatagramReader, MAX_DATAGRAM_LEN};
const MAX_PAYLOAD: usize = 512;
/// The op sequence the fuzzer generates. Channel addressing is by
/// SESSION INDEX (0..8) — the harness maps indices to the manager's
/// SESSION INDEX (0..8); the harness maps indices to the manager's
/// allocated ids; the generator never names raw ids.
#[derive(Debug, arbitrary::Arbitrary, Clone)]
pub enum SessionOp {
/// The producer opens a session (allocates the channel id, the
/// producer-side drainer runs). `datagram` shapes the consumer
/// data plane (the params' substrate rode the open call — the
/// data plane (the params' substrate rode the open call; the
/// harness re-states it here, the adopt's shape input).
Open { datagram: bool },
/// Route one framed datagram (the `[len: u16 BE]` form, clamped
@@ -68,14 +68,14 @@ pub enum SessionOp {
/// against in-process peer halves for stream sessions).
Adopt { session: u8 },
/// Push bytes into a stream session's peer half (the pump's p2c
/// source). `len == 0` drops the peer write half — the EOF path.
/// source). `len == 0` drops the peer write half (the EOF path).
Pump { session: u8, len: u16, byte: u8 },
/// Pull one datagram off the session's backlog (the
/// `recv_datagram` pull — the consumer-driven drain). `None`
/// `recv_datagram` pull, the consumer-driven drain). `None`
/// backlog = a no-op (the pull would block on the stream read;
/// the harness models the batched-arrival case only).
Pull { session: u8 },
/// Tear the session's id down (both sides — the out-of-band
/// Tear the session's id down (both sides, the out-of-band
/// `channel/close` shape: the serving side reaps; the consumer
/// session reaps its own entry).
Teardown { session: u8 },
@@ -88,7 +88,7 @@ pub struct SessionSequence {
pub ops: Vec<SessionOp>,
}
/// A datagram session's consumer-side plane — the `DatagramHalves`
/// A datagram session's consumer-side plane: the `DatagramHalves`
/// mirror (reader + pending queue), with counters.
struct DatagramPlane {
recv: MpscRecvStream,
@@ -98,8 +98,8 @@ struct DatagramPlane {
eof: bool,
}
/// The framing model per datagram session — the wave-1 walk, carried
/// across ops (stateful): partial trailing frames persist.
/// The framing model per datagram session is the wave-1 walk,
/// carried across ops (stateful): partial trailing frames persist.
#[derive(Default)]
struct DgModel {
/// Expected datagram payload lengths (FIFO), in rout order.
@@ -126,8 +126,8 @@ impl DgModel {
}
}
/// A stream session's consumer-side plane — the `pump_against`
/// mirror with counters.
/// A stream session's consumer-side plane (the `pump_against`
/// mirror) with counters.
struct StreamPlane {
/// The pump's p2c source (the harness pushes Pump ops here);
/// `None` once dropped (the EOF path).
@@ -139,7 +139,7 @@ struct StreamPlane {
/// reader task's counter).
c2p_counter: Arc<std::sync::atomic::AtomicU64>,
/// The c2p far-end reader task (ends when the pump's write half
/// shuts down — the shutdown-on-completion observable).
/// shuts down, the shutdown-on-completion observable).
reader: tokio::task::JoinHandle<()>,
}
@@ -148,7 +148,7 @@ struct StreamPlane {
struct Session {
channel_id: u32,
/// The producer's channel write half (the mux-registered
/// sender — the pump handler's write side).
/// sender, the pump handler's write side).
producer_send: Option<alkcall::channels::reassembly::MpscSendStream>,
dg_model: Option<DgModel>,
dg_plane: Option<DatagramPlane>,
@@ -265,10 +265,10 @@ impl SessionHarness {
let channel_id = s.channel_id;
let is_datagram = s.dg_model.is_some();
// A re-adopt of a LIVE session is the manager's
// documented id-collision outcome (ChannelExists) —
// documented id-collision outcome (ChannelExists):
// a legal no-op, not a finding (the alkcall
// duplicate-adopt harness lesson; the live entry is
// left intact — alkcall 0.8.1's fix).
// left intact, per alkcall 0.8.1's fix).
if s.adopted {
return Ok(());
}
@@ -363,7 +363,7 @@ impl SessionHarness {
SessionOp::ClearAll => {
// clear_all empties the ENTIRE channel map; the
// returned entries are the opener-ledger records
// (ADR-047 §7) — adopted channels carry no ledger
// (ADR-047 §7); adopted channels carry no ledger
// entry (the remote side opened them), so the
// return value is NOT the reaped-id set (the
// alkcall §7.7 harness lesson, learned again). The
@@ -490,7 +490,7 @@ impl SessionHarness {
let Some(plane) = s.dg_plane.as_ref() else {
unreachable!("drained above");
};
// The FIFO expectation: the session consumes in order —
// The FIFO expectation: the session consumes in order;
// reconcile the pending queue head-by-head against the
// model's expected tail. The model's `expected` holds
// the routed-but-not-pulled datagrams (Pull pops both
@@ -500,7 +500,7 @@ impl SessionHarness {
// check; equal lengths are the lossless-delivery check.
if plane.pending.len() != model.expected.len() {
return Err(format!(
"session {index}: backlog {} != expected {} — routed datagrams \
"session {index}: backlog {} != expected {}; routed datagrams \
must be either pulled or pending, never lost",
plane.pending.len(),
model.expected.len()
@@ -516,7 +516,7 @@ impl SessionHarness {
}
// The session-shape bound (the §1.3 assertion): the
// backlog holds exactly the routed batch beyond the
// pulled datagrams — with the per-op drain the queue
// pulled datagrams: with the per-op drain the queue
// never exceeds one chunk's worth of batched datagrams,
// and every routed datagram is eventually pullable. The
// amplification question is thus bounded: the backlog
@@ -554,7 +554,7 @@ impl SessionHarness {
})
.expect("pump task");
// The p2c count equals what the harness pushed
// (lossless two-pump copy — the copy never truncates).
// (lossless two-pump copy; the copy never truncates).
assert_eq!(
p2c, plane.p2c_written,
"session {index}: p2c copy count {p2c} != pushed {}",
@@ -603,7 +603,7 @@ pub fn fuzz_session_pumps(seq: &SessionSequence) {
let retained = plane.reader.retained_bytes();
assert!(
retained <= MAX_DATAGRAM_LEN + 2,
"session {index}: decoder retained {retained} bytes — \
"session {index}: decoder retained {retained} bytes; \
the state must stay bounded by one frame"
);
}
@@ -621,7 +621,7 @@ pub fn fuzz_session_pumps_arbitrary(data: &[u8]) {
match <SessionSequence as arbitrary::Arbitrary>::arbitrary_take_rest(u) {
Ok(seq) => fuzz_session_pumps(&seq),
Err(_) => {
// A sequence that does not decode is not a finding — the
// A sequence that does not decode is not a finding. The
// generator's op space is structured; malformed byte
// streams are the corpus's job, not the harness's.
}
@@ -652,7 +652,7 @@ mod corpus_replay {
);
}
/// Semantic fixture: the §1.3 amplification probe — open, adopt
/// Semantic fixture: the §1.3 amplification probe: open, adopt
/// a datagram session, route batched frames (3-byte, empty,
/// 1-byte) through the real demux; the backlog must reconcile
/// against the FIFO expectation exactly (F-2: the empty
@@ -685,7 +685,7 @@ mod corpus_replay {
}
/// Semantic fixture: the half-close shape (the W4 probe at the
/// session layer) — open a stream session, adopt, pump bytes,
/// session layer): open a stream session, adopt, pump bytes,
/// drop the p2c source (EOF), then teardown: the pump must
/// complete and the pushed bytes must reconcile (the two-pump
/// contract).
+47 -47
View File
@@ -1,4 +1,4 @@
//! The consumer half: `TunnelSession` — the typed client for tunnel
//! The consumer half: `TunnelSession`, the typed client for tunnel
//! channels (ADR-005). Forward path (`open`), reverse path (`adopt`
//! and `open_reverse_channel`), substrate-shaped data planes
//! (`stream_halves`, `take_halves`, `send_datagram`, `recv_datagram`),
@@ -6,7 +6,7 @@
//! (`close`, `join`, `Drop`).
//!
//! One session = one channel = one tunnel (the channel ID is the flow
//! key, OQ-TN-02). The session owns the adopted channel entry —
//! key, OQ-TN-02). The session owns the adopted channel entry;
//! nothing upstream awaits the adopter's pump (the W3 gap); the
//! session closes it structurally. Role follows the resource, not the
//! connection (OQ-TN-03): `-L` and `-R` consumers share this one API.
@@ -44,7 +44,7 @@ use crate::wire::{frame_datagram, DatagramReader};
///
/// Fields are `Option` so `take_halves`/`pump_against` can move the
/// data plane and pump out of the `Drop`-impl type; an emptied session
/// drops as a no-op (teardown is idempotent — an already-reaped entry
/// drops as a no-op (teardown is idempotent; an already-reaped entry
/// reaps `Err(UnknownChannel)`, ignored).
pub struct TunnelSession {
pub channel_id: u32,
@@ -54,7 +54,7 @@ pub struct TunnelSession {
}
enum DataPlane {
/// Raw pass-through (stream substrates: `tcp`, `unix`) — the
/// Raw pass-through (stream substrates: `tcp`, `unix`): the
/// halves ARE the tunnel (ADR-003); zero tunnel-level framing.
Stream(StreamHalves),
/// The mandatory `[len: u16 BE]` codec over the halves (`udp`,
@@ -71,7 +71,7 @@ struct DatagramHalves {
read: ReadHalf<BiStream>,
write: WriteHalf<BiStream>,
reader: DatagramReader,
/// Datagrams decoded from a chunk beyond the one returned — a
/// Datagrams decoded from a chunk beyond the one returned; a
/// single chunk may batch several frames (the codec decodes all
/// completions per feed); they queue here for the next
/// `recv_datagram`.
@@ -84,7 +84,7 @@ impl TunnelSession {
/// (`ChannelClient::open_channel(OP_TUNNEL_OPEN, params,
/// TUNNEL_ALPN)`), adopts the returned channel ID, splits the
/// channel `BiStream`, and presents the substrate-shaped data
/// plane. A failed open never yields a session (typed errors —
/// plane. A failed open never yields a session (typed errors,
/// ADR-049 §4; no phantom session).
pub async fn open(
client: &ChannelClient,
@@ -108,22 +108,22 @@ impl TunnelSession {
}
/// Open a tunnel channel toward a CALLER-NAMED target (the direct
/// op — dynamic-target egress, ADR-007; the ssh `direct-tcpip` /
/// op: dynamic-target egress, ADR-007; the ssh `direct-tcpip` /
/// SOCKS5 CONNECT shape). A separate constructor from [`open`](Self::open),
/// not a params enum: `tunnel:direct` and `tunnel:open` are two
/// scopes — two capabilities — and the caller states which one it
/// scopes (two capabilities), and the caller states which one it
/// holds.
///
/// Serializes `{substrate, target}`, calls
/// `open_channel(OP_TUNNEL_DIRECT, …, TUNNEL_ALPN)` (plain
/// `open_channel` — dial establishers never bind, so no reply
/// `open_channel`; dial establishers never bind, so no reply
/// fields are expected), and presents the identical session type,
/// data planes, and teardown API as [`open`](Self::open).
///
/// Error surface: the base open errors minus `unknown_resource`
/// (no registry lookup); a malformed target is the schema /
/// `invalid_input`-class failure (registry-side); an old producer
/// rejects the unknown op loudly (`NOT_FOUND` — the SSH posture).
/// rejects the unknown op loudly (`NOT_FOUND`; the SSH posture).
pub async fn open_direct(
client: &ChannelClient,
target: SubstrateAddr,
@@ -150,13 +150,13 @@ impl TunnelSession {
/// Adopt a producer-allocated channel ID (the reverse path, `-R`):
/// install the data plane from the adopted halves. Early arrivals
/// are parked by the manager and drained on adopt (the adoption
/// race — ADR-047 §5). An adopt failure is `AdoptFailed`-class on
/// race, ADR-047 §5). An adopt failure is `AdoptFailed`-class on
/// the typed error surface, not an establishment error.
///
/// The substrate shapes the data plane (raw halves vs the UDP
/// codec) and rode the open call that produced the `channel_id`;
/// `adopt` takes it so the session presents the right variant (the
/// task sketch omitted it — a datagram session cannot exist without
/// task sketch omitted it: a datagram session cannot exist without
/// it; documented in the task notes).
pub async fn adopt(
manager: &ChannelManager,
@@ -204,7 +204,7 @@ impl TunnelSession {
}
/// Borrow the raw halves (stream substrates). The halves ARE the
/// tunnel — raw pass-through (ADR-003). For owned access, use
/// tunnel: raw pass-through (ADR-003). For owned access, use
/// [`TunnelSession::take_halves`]. `None` for datagram sessions
/// (drive those with `send_datagram`/`recv_datagram`) or after the
/// halves were taken.
@@ -226,11 +226,11 @@ impl TunnelSession {
/// Take the raw halves (either substrate): consumes the session's
/// data plane into owned boxed halves. The session stays alive as
/// the channel-entry owner — reap with `close`/`join`/`Drop`
/// the channel-entry owner; reap with `close`/`join`/`Drop`
/// (`join` on a pump-less session completes immediately, reaps
/// only, `(0, 0, reaped)`). The halves may be taken once; later
/// data-plane calls fail with [`TunnelIoError::ChannelTaken`].
/// Datagram halves carry the codec framing (ADR-003) — raw bytes
/// Datagram halves carry the codec framing (ADR-003): raw bytes
/// on the wire are `[len: u16 BE][payload]` frames.
pub fn take_halves(mut self) -> TakenHalves {
let manager = self.manager.take();
@@ -261,7 +261,7 @@ impl TunnelSession {
/// UDP: send one datagram (frame → write → flush; ADR-003's
/// mandatory framing). `Oversize` above 65535 (the u16 length
/// field would wrap). Empty payloads are legal (`len=0` — the
/// field would wrap). Empty payloads are legal (`len=0`, the
/// F-2 layering).
pub async fn send_datagram(&mut self, payload: &[u8]) -> Result<(), TunnelIoError> {
match self.data.as_mut() {
@@ -278,7 +278,7 @@ impl TunnelSession {
/// UDP: datagrams decoded from a chunk beyond the one returned to
/// the caller, queued for the next [`recv_datagram`](Self::recv_datagram)
/// (the `DatagramHalves.pending` backlog — the diagnostic the
/// (the `DatagramHalves.pending` backlog, the diagnostic the
/// wave-2 fuzz harness asserts against, `docs/research/fuzzing.md`
/// §3.3). `None` for stream sessions or after the halves were
/// taken (the [`stream_halves`](Self::stream_halves) Option
@@ -292,8 +292,8 @@ impl TunnelSession {
}
/// UDP: receive the next datagram (boundary-preserving, the
/// incremental decode path — the only consumer-visible decode
/// path). `Some(bytes)` per datagram — possibly empty (`len=0`
/// incremental decode path; the only consumer-visible decode
/// path). `Some(bytes)` per datagram, possibly empty (`len=0`
/// legal); `None` only on stream EOF (the channels-level sentinel;
/// the codec never collides with it, F-2). A stream end
/// mid-datagram is [`TunnelIoError::TruncatedDatagram`]
@@ -321,14 +321,14 @@ impl TunnelSession {
/// Spawn `pump_bidi(channel_halves, accepted_read, accepted_write)`
/// against an accepted local connection and hold the returned
/// handle (the reverse-flow use; ADR-005). Builder-style: returns
/// the session, which now owns the pump — `close` aborts it,
/// the session, which now owns the pump: `close` aborts it,
/// `join` awaits it, `Drop` aborts it. Consumes the data plane
/// (the pump owns the channel halves from here).
///
/// For UDP sessions the accepted halves must present the same
/// framing as the channel side (ADR-003): hand the FRAMED adapter
/// (the `local` feature's framing shape, or an assembly
/// equivalent), never a raw UDP socket — the pump copies raw
/// equivalent), never a raw UDP socket; the pump copies raw
/// bytes and cannot know which side carries the `[len: u16 BE]`
/// framing.
pub async fn pump_against<S>(mut self, accepted: S) -> Self
@@ -370,7 +370,7 @@ impl TunnelSession {
.unwrap_or(false)
}
/// Await pump completion (both directions finished — half-close
/// Await pump completion (both directions finished; half-close
/// semantics fall out of `pump_bidi`, W4), THEN reap the adopted
/// channel. Returns the `(u64, u64)` copy counts for
/// observability plus the reap result. **Pump-less sessions**
@@ -401,7 +401,7 @@ impl TunnelSession {
impl Drop for TunnelSession {
/// Abort the pump and sync-reap the channel (`teardown_channel` is
/// sync — best-effort; `Drop` cannot await). Dropping without
/// sync, best-effort; `Drop` cannot await). Dropping without
/// close/join never leaks the channel entry (ADR-005).
fn drop(&mut self) {
if let Some(pump) = self.pump.take() {
@@ -422,29 +422,29 @@ pub struct TakenHalves {
pub write: Box<dyn AsyncWrite + Send + Unpin>,
}
/// The listen-flow consumer session (ADR-008 §4) — the sibling of
/// [`TunnelSession`]: it OPENS a listen tunnel (`channels/tunnel/sub`
/// — the establisher shape is the producer's per-registration choice,
/// The listen-flow consumer session (ADR-008 §4), the sibling of
/// [`TunnelSession`]: it OPENS a listen tunnel (`channels/tunnel/sub`:
/// the establisher shape is the producer's per-registration choice,
/// invisible here), reads the bind-first reply fields (`bound` /
/// `listen`) via the lenient [`crate::params::listen_reply_fields`]
/// projection, and owns the listen channel's teardown (ADR-005's
/// ownership posture extended to the listen flow).
///
/// **The held stream (the one new thing):** a listen session's channel
/// stream has NO data-plane role — its only job is to EOF when the
/// stream has NO data-plane role; its only job is to EOF when the
/// session drops. The struct holds the channel's write half
/// (`MpscSendStream`, the mux sender) IDLE and never pumps it; the
/// drop → EOF ordering is structural (the mux sender drops → the mux
/// pump writes the zero-length EOF sentinel → the far accept
/// dispatcher's idle-stream watch fires and tears the far listener
/// down — ADR-008 §1's contract). The stream is deliberately not
/// down, per ADR-008 §1's contract). The stream is deliberately not
/// exposed: pumping on a listen channel is meaningless (the far side's
/// dispatcher ignores it too).
///
/// **Forwarded sessions are NOT owned by the listener.** They arrive
/// as ordinary adopted-side [`TunnelSession`]s the assembly correlates
/// via the [`listen`](Self::listen) token and owns (the correlation
/// registry is assembly-side bookkeeping — consumer.md's posture).
/// registry is assembly-side bookkeeping, consumer.md's posture).
/// Listener teardown does NOT cascade to already-adopted forwarded
/// sessions; the far listener dying stops NEW accepts only.
///
@@ -458,22 +458,22 @@ pub struct TakenHalves {
/// ```
///
/// Fields are `Option` so `close` can move them out of the `Drop`-impl
/// type; an emptied listener drops as a no-op (teardown is idempotent —
/// type; an emptied listener drops as a no-op (teardown is idempotent:
/// an already-reaped entry reaps `Err(UnknownChannel)`, ignored).
pub struct TunnelListener {
/// The adopted listen channel's ID (one session = one channel —
/// The adopted listen channel's ID (one session = one channel;
/// OQ-TN-02's resolution; observability + correlation).
pub channel_id: u32,
manager: Option<ChannelManager>,
/// The `bound` reply field (the real OS-chosen address) — `None`
/// The `bound` reply field (the real OS-chosen address); `None`
/// for accept-first/dial establishers or old producers (the Option
/// posture). Informational surface, NOT addressing.
bound: Option<SubstrateAddr>,
/// The `listen` reply field (the producer-minted correlation
/// token) — `None` for establishers that mint nothing (the Option
/// token); `None` for establishers that mint nothing (the Option
/// posture).
token: Option<u32>,
/// The listen channel's write half, held IDLE — the structural
/// The listen channel's write half, held IDLE, the structural
/// teardown signal (see the type docs). Never pumped, never
/// exposed.
write: Option<MpscSendStream>,
@@ -482,18 +482,18 @@ pub struct TunnelListener {
impl TunnelListener {
/// Open a listen tunnel (the listen flow, ADR-008 §4). Calls the
/// open op through [`ChannelClient::open_channel_with_reply`] (the
/// U-2 surface, alkcall 0.8.0) — the full reply arrives, the
/// U-2 surface, alkcall 0.8.0); the full reply arrives, the
/// bind-first reply fields are projected via
/// [`crate::params::listen_reply_fields`], and the already-adopted
/// channel entry is held (adopt-free construction — the client
/// channel entry is held (adopt-free construction: the client
/// adopted it). A failed open never yields a listener (typed
/// errors — ADR-049 §4; no phantom session).
/// errors, ADR-049 §4; no phantom session).
///
/// `bound()`/`listen()` surface `None` when the establisher
/// supplied nothing (accept-first/dial producers, or old producers
/// — the additive posture); `bound` is informational surface and
/// supplied nothing (accept-first/dial producers, or old producers;
/// the additive posture); `bound` is informational surface and
/// MUST NOT be treated as a dial target (the producer owns the
/// backing — ADR-001's invariant).
/// backing, ADR-001's invariant).
pub async fn open(
client: &ChannelClient,
params: TunnelParams,
@@ -516,7 +516,7 @@ impl TunnelListener {
})
}
/// The `bound` reply field — the real OS-chosen bound address
/// The `bound` reply field: the real OS-chosen bound address
/// (port `0` resolved), present only when the establisher supplied
/// it (bind-first producers). reply#1-class fidelity (BND.ADDR).
/// Informational surface, never addressing.
@@ -524,7 +524,7 @@ impl TunnelListener {
self.bound.as_ref()
}
/// The `listen` reply field — the correlation token (ADR-008
/// The `listen` reply field: the correlation token (ADR-008
/// Amendment 1) the producer minted for this listen tunnel. The
/// assembly keys its forwarded-op correlation map on it; the
/// listener does not own the receipt queue (forwarded sessions
@@ -534,7 +534,7 @@ impl TunnelListener {
}
/// Tear the listen tunnel down: shut the held write half (the
/// graceful EOF sentinel — the far accept dispatcher's teardown
/// graceful EOF sentinel, the far accept dispatcher's teardown
/// signal), then reap the adopted channel entry
/// (`teardown_channel`). Returns whether the entry existed. The
/// far-side teardown cascades (EOF → accept-loop cancel → far
@@ -554,8 +554,8 @@ impl TunnelListener {
impl Drop for TunnelListener {
/// Drop the held write half (the mux pump writes the implicit-EOF
/// chunk — the far dispatcher's teardown signal) and sync-reap the
/// adopted channel entry (`teardown_channel` is sync —
/// chunk, the far dispatcher's teardown signal) and sync-reap the
/// adopted channel entry (`teardown_channel` is sync,
/// best-effort; `Drop` cannot await). Dropping without close never
/// leaks the channel entry (ADR-005's Drop posture, listen-
/// flavored).
@@ -568,7 +568,7 @@ impl Drop for TunnelListener {
}
/// Read from the chunk stream until one complete datagram is
/// assembled (the incremental decode path — ADR-003). `Ok(None)` on
/// assembled (the incremental decode path, ADR-003). `Ok(None)` on
/// stream EOF; mid-datagram EOF is `TruncatedDatagram` (fail-loud).
async fn read_one_datagram<R: AsyncRead + Unpin>(
reader: &mut DatagramReader,
@@ -593,7 +593,7 @@ async fn read_one_datagram<R: AsyncRead + Unpin>(
/// Call the producer's open op on channel 0 and extract the allocated
/// `channel_id` (the reverse path's first step; the POC's shape). The
/// optional `auth_token` rides the payload — the hub-forwarding path
/// optional `auth_token` rides the payload, the hub-forwarding path
/// (precedence: token > `ServingConfig.identity` > transport identity,
/// CF-005). The assembly layer then constructs the session with
/// [`TunnelSession::adopt`].
+10 -9
View File
@@ -20,7 +20,7 @@ pub enum TunnelError {
/// An I/O failure on the session's data plane.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// A reply field failed its typed projection — the wire carried a
/// A reply field failed its typed projection: the wire carried a
/// malformed shape (e.g. [`crate::params::parse_listen_reply`]
/// found `bound` not substrate-shaped, a non-positive `listen`,
/// or a reply with no `channel_id` at all). A misbehaving
@@ -30,7 +30,7 @@ pub enum TunnelError {
}
/// A failed tunnel open or adopt. A failure never yields a session
/// (no phantom session, mirroring the no-phantom-channel property —
/// (no phantom session, mirroring the no-phantom-channel property of
/// ADR-005). Branch on [`ChannelOpenError::establishment_reason`]
/// through [`TunnelOpenError::open_ref`] for `channel:open_failed`'s
/// reason (`dial_failed` / `unknown_resource` / `resource_shortage` /
@@ -38,7 +38,7 @@ pub enum TunnelError {
/// (`FORBIDDEN`, `channel:too_many_channels`).
#[derive(Debug, thiserror::Error)]
pub enum TunnelOpenError {
/// The open op failed, or the reply/adopt failed — the typed
/// The open op failed, or the reply/adopt failed. The typed
/// [`ChannelOpenError`] carries the wire [`CallError`] verbatim
/// (adopt failures surface as `AdoptFailed`).
#[error("tunnel open failed: {0}")]
@@ -61,11 +61,12 @@ pub enum TunnelIoError {
/// The underlying stream failed.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// The datagram codec rejected the operation (e.g. `Oversize` —
/// The datagram codec rejected the operation (e.g. `Oversize`:
/// a >65535-byte send, rejected at frame time per ADR-003).
#[error("codec: {0}")]
Codec(#[from] DatagramCodecError),
/// The stream ended mid-datagram — declared length not satisfied
/// The stream ended mid-datagram; the declared length was not
/// satisfied
/// (a clean peer never does this; the fail-loud posture, OQ-TN-13
/// / ADR-003: never a silent partial datagram).
#[error("stream ended mid-datagram (truncated)")]
@@ -74,20 +75,20 @@ pub enum TunnelIoError {
/// plane (e.g. `send_datagram` on a stream tunnel).
#[error("wrong substrate for this operation")]
WrongSubstrate,
/// The session's channel halves were already taken — by
/// The session's channel halves were already taken: by
/// `take_halves` (caller-driven) or `pump_against` (pump-owned);
/// no data plane remains on the session.
#[error("channel halves already taken from this session")]
ChannelTaken,
}
/// The reverse-path open call failed (`open_reverse_channel` — the
/// The reverse-path open call failed (`open_reverse_channel`; the
/// assembly layer calls it before [`crate::consumer::TunnelSession::
/// adopt`]; the hub's call surface is a `CallConnection`, so the
/// adopt`], and the hub's call surface is a `CallConnection`, so the
/// two-step is the honest API).
#[derive(Debug, thiserror::Error)]
pub enum ReverseOpenError {
/// The open op failed — the wire [`CallError`]; branch on
/// The open op failed (the wire [`CallError`]); branch on
/// `details.reason` of `channel:open_failed` (ADR-049 §4).
#[error("open call failed: {0:?}")]
Call(CallError),
+4 -3
View File
@@ -1,4 +1,4 @@
//! alktunnels — arbitrary bidirectional tunnels over alkcall channels.
//! alktunnels: arbitrary bidirectional tunnels over alkcall channels.
//!
//! TCP, UDP, unix-socket, and other stream/datagram substrates, in the
//! `ssh -L` / `ssh -D` / `ssh -R` sense. A producer/consumer protocol
@@ -6,14 +6,14 @@
//! is the sibling precedent): the producer half registers the
//! `alk/tunnel` open op and pumps bytes between the channel and the
//! substrate; the consumer half is the typed session
//! ([`TunnelSession`]) that opens tunnel channels and owns teardown —
//! ([`TunnelSession`]) that opens tunnel channels and owns teardown,
//! plus [`TunnelListener`] (the listen-flow session, ADR-008) for the
//! bind-first listen shape.
//!
//! A tunnel is a resource, not an address: the open-op params identify
//! a produced resource + substrate ([`TunnelParams`], ADR-001); the
//! producer owns the backing. The protocol layer is substrate-agnostic
//! by construction — socket/platform I/O is confined to the
//! by construction: socket/platform I/O is confined to the
//! feature-gated `local` backend (the default crate is wasm-clean).
//!
//! - Wire format: `docs/architecture/wire.md` (+ BAST at `bast.md`)
@@ -42,6 +42,7 @@ pub use params::{TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
pub use producer::{
bindfirst_establisher, make_bindfirst_accept_handler, make_forwarded_op_handler,
register_tunnel_bindfirst_openable, register_tunnel_forwarded_openable,
register_tunnel_forwarded_openable_with_establisher, ForwardedGate, ForwardedPlan,
};
pub use producer::{
direct_establisher, direct_establisher_with_witness, register_tunnel_direct_openable,
+60 -60
View File
@@ -1,39 +1,39 @@
//! The `local` feature module: real-socket halves functions the
//! assembly layer injects as `DialFn`/`AcceptFn` closures (ADR-004 —
//! assembly layer injects as `DialFn`/`AcceptFn` closures (ADR-004:
//! "produce boxed halves for a resource" is a function, not a trait).
//! Non-wasm by design: this module IS the platform boundary — the
//! Non-wasm by design: this module IS the platform boundary; the
//! default crate stays wasm-clean (convention 16: `local`-gated code
//! is never imported from the shared/producer/consumer modules).
//!
//! Substrate coverage (overview.md §Feature Gates):
//! - **TCP** — [`dial_tcp`] (5s connect timeout per the POCs) and the
//! - **TCP**: [`dial_tcp`] (5s connect timeout per the POCs) and the
//! [`TcpListenerHalves`] accept half for listen producers.
//! - **UDP** — [`connect_udp`]: a connected `UdpSocket` wrapped in the
//! FRAMED adapter ([`UdpHalf`], ADR-003 — the codec composes at this
//! - **UDP**: [`connect_udp`]: a connected `UdpSocket` wrapped in the
//! FRAMED adapter ([`UdpHalf`], ADR-003; the codec composes at this
//! boundary: the channel side speaks the `[len: u16 BE]` frames, the
//! target side sees payload-only datagrams; the pump never sees UDP
//! specifics). Truncation fails loud (OQ-TN-13's resolved posture).
//! - **Unix** — [`dial_unix`], in v1 per OQ-TN-14 (same halves shape
//! as TCP). **Stdio is NOT here** — a spawned process's stdio is
//! - **Unix**: [`dial_unix`], in v1 per OQ-TN-14 (same halves shape
//! as TCP). **Stdio is NOT here**: a spawned process's stdio is
//! alktty's pipe mode (exit codes + signals are tty semantics);
//! remote command execution composes via alktty, not here.
//! - **Bind-first** (shape 3, ADR-008) — [`bind_tcp_first`] /
//! - **Bind-first** (shape 3, ADR-008): [`bind_tcp_first`] /
//! [`bind_unix_first`] (the real-socket [`BindFn`] sources: the
//! `bound` reply field's data — the REAL OS-chosen address, port 0
//! `bound` reply field's data, the REAL OS-chosen address, port 0
//! resolved), [`TcpListenerHalves::accept_pair`] /
//! [`UnixListenerHalves::accept_pair`] (one accept per call yielding
//! `(peer, halves)` — the [`BindFirstAcceptLoop`] contract), and the
//! `(peer, halves)`; the [`BindFirstAcceptLoop`] contract), and the
//! assembly convenience closures [`local_bind_first()`] +
//! [`local_bindfirst_accept_loop()`]. UDP bind-first is NOT here —
//! [`local_bindfirst_accept_loop()`]. UDP bind-first is NOT here:
//! a UDP socket has no accept; the per-peer associate shape is a
//! separate design decision (the bind closure fails loud for udp).
//!
//! Error mapping: [`TunnelEstablishError`] variants (the POC's
//! `Into<EstablishmentError>` path) — dial refusal → `dial_failed`,
//! `Into<EstablishmentError>` path); dial refusal → `dial_failed`,
//! bind/resource exhaustion → `resource_shortage`, handler-internal
//! → `handler_error`.
//!
//! `TargetHandle` re-used from producer.rs — the halves type lives
//! `TargetHandle` re-used from producer.rs; the halves type lives
//! there; `local/` depends on it, never vice versa (convention 16).
use std::sync::Arc;
@@ -52,7 +52,7 @@ const DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
// --- TCP ----------------------------------------------------------------
/// Dial a TCP target (`host:port`): `TcpStream::connect` bounded by
/// [`DIAL_TIMEOUT`], `into_split` → boxed halves (raw pass-through —
/// [`DIAL_TIMEOUT`], `into_split` → boxed halves (raw pass-through;
/// the halves ARE the tunnel, ADR-003).
pub async fn dial_tcp(target: &str) -> Result<TargetHandle, TunnelEstablishError> {
let stream = tokio::time::timeout(DIAL_TIMEOUT, tokio::net::TcpStream::connect(target))
@@ -67,11 +67,11 @@ pub async fn dial_tcp(target: &str) -> Result<TargetHandle, TunnelEstablishError
}
/// Bind a TCP listener (the listen shape's assembly-side half).
/// The binding decision belongs to the caller (OQ-TN-04) — this is a
/// The binding decision belongs to the caller (OQ-TN-04); this is a
/// `local`-feature convenience, not protocol behavior.
///
/// **Bind posture (review 001 U-2):** the caller-supplied address is
/// deliberate — a wildcard bind (`0.0.0.0` or `::`; note `::1` is
/// deliberate: a wildcard bind (`0.0.0.0` or `::`; note `::1` is
/// loopback, not a wildcard) is the caller's explicit choice and
/// exposes the listener per the host's firewall. There is no implicit
/// wildcard default here.
@@ -102,7 +102,7 @@ impl TcpListenerHalves {
/// The accept loop: accept connections until the listener fails,
/// pushing each accepted handle into the queue. Returns when the
/// listener is closed (accept errors end the loop — the assembly
/// listener is closed (accept errors end the loop; the assembly
/// layer aborts this task on teardown).
pub async fn accept_loop(&self, queue: AcceptQueue) -> Result<(), TunnelEstablishError> {
loop {
@@ -124,16 +124,16 @@ impl TcpListenerHalves {
}
}
/// ONE accept as a `(peer, halves)` pair — the BIND-FIRST shape's
/// ONE accept as a `(peer, halves)` pair, the BIND-FIRST shape's
/// substrate half (ADR-008 §1): the accept dispatcher invokes the
/// injected [`BindFirstAcceptLoop`] closure PER accept; this method
/// is what the closure calls. The peer address rides `accept()`'s
/// return value (the forwarded op's `peer` carrier — reply#2-class
/// fidelity, ADR-008 §3).
/// return value (the forwarded op's `peer` carrier,
/// reply#2-class fidelity, ADR-008 §3).
///
/// This is deliberately NOT the [`AcceptQueue`]-feeding
/// [`TcpListenerHalves::accept_loop`] (the accept-FIRST shape's
/// contract — the establisher pops pre-accepted handles): the
/// contract, the establisher pops pre-accepted handles): the
/// bind-first dispatcher owns the accept pacing, so one accept per
/// closure call with the peer attached is the honest shape.
pub async fn accept_pair(&self) -> Result<(SubstrateAddr, TargetHandle), TunnelEstablishError> {
@@ -160,16 +160,16 @@ impl TcpListenerHalves {
/// Connect a UDP "tunnel" to a target: resolve the target, bind an
/// ephemeral socket on the MATCHING address family (never an implicit
/// wildcard mismatch — a v6-only target dials from a v6-bound socket;
/// wildcard mismatch: a v6-only target dials from a v6-bound socket;
/// review 001 U-2's posture), `connect(target)` (the
/// associated-socket posture — one peer), and wrap it in the FRAMED
/// associated-socket posture, one peer), and wrap it in the FRAMED
/// adapter (ADR-003): `UdpHalf` composes the `[len: u16 BE]` codec at
/// this boundary — the channel side speaks framed bytes, the target
/// this boundary: the channel side speaks framed bytes, the target
/// side sees payload-only datagrams; the pump never sees UDP
/// specifics.
pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishError> {
use std::net::ToSocketAddrs;
// Same posture as tokio's own `UdpSocket::connect(target: &str)` —
// Same posture as tokio's own `UdpSocket::connect(target: &str)`;
// it resolves inline too; numeric targets (the registry's shape)
// make this a non-blocking parse.
let resolved = target
@@ -200,9 +200,9 @@ pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishEr
/// The FRAMED UDP adapter (ADR-003's substrate-boundary codec): the
/// pump-facing face speaks the tunnel's `[len: u16 BE][payload]`
/// channel framing; the socket face speaks whole raw datagrams.
/// Orientation (review 001 §Re-validation — the fix sketch's caution):
/// Orientation (review 001 §Re-validation, the fix sketch's caution):
/// `UdpHalf`'s caller is the pump, and the CHANNEL side is the framed
/// side —
/// side:
///
/// - `poll_write` is fed CHANNEL bytes (already-framed frames, from
/// the session's codec) → it **de-frames** (an incremental
@@ -210,13 +210,13 @@ pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishEr
/// as one raw datagram. A frame may straddle successive poll_write
/// buffers (the pump's 8 KiB copy chunks split large frames); the
/// decoder carries the partial state until the frame completes.
/// `len=0` decodes to a zero-payload datagram — a real empty
/// `len=0` decodes to a zero-payload datagram: a real empty
/// datagram, NOT EOF (the F-2 fix end-to-end).
/// - `poll_read` yields bytes destined for the CHANNEL (the session's
/// codec decodes there) → it **frames** ([`frame_datagram`]) each
/// raw target datagram and hands the frame bytes to the pump. A
/// frame larger than the caller's buffer is staged and handed out
/// across successive reads — a partial frame is never misread as a
/// across successive reads; a partial frame is never misread as a
/// shorter one.
///
/// The target sees payload-only bytes; an empty datagram crosses the
@@ -224,10 +224,10 @@ pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishEr
///
/// **Truncation never happens silently** (OQ-TN-13's fail-loud
/// posture, ADR-003): the socket-facing recv uses a full 65535-byte
/// scratch (`try_recv` truncates at the buffer size — a smaller
/// scratch (`try_recv` truncates at the buffer size; a smaller
/// scratch would corrupt the framing before any size check runs), and
/// a datagram beyond [`MAX_DATAGRAM_LEN`] (impossible for a
/// well-formed peer — the u16 frame bound) errors loudly instead of
/// well-formed peer, the u16 frame bound) errors loudly instead of
/// truncating.
///
/// The scratch is allocated lazily on the first recv (review 001 U-3:
@@ -237,7 +237,7 @@ pub struct UdpHalf {
sock: Arc<tokio::net::UdpSocket>,
kind: UdpHalfKind,
/// Read-half state: the recv scratch (lazily grown to the full
/// 65535 on first use — `try_recv` truncates silently at the
/// 65535 on first use; `try_recv` truncates silently at the
/// buffer size, so the full-size read is correctness-load-bearing)
/// plus the frame-staging state for handing a >caller-buffer
/// frame out across reads.
@@ -270,13 +270,13 @@ struct UdpReadStage {
struct UdpWriteDeFrame {
reader: DatagramReader,
/// Decoded datagrams awaiting send (FIFO). poll_write accepts all
/// offered bytes into the de-framer — the pump's copy window
/// offered bytes into the de-framer; the pump's copy window
/// cannot be held closed for later top-up (a full window is
/// re-offered unsatisfied until drained); backpressure is the
/// accepted-byte high-water mark instead.
queue: std::collections::VecDeque<bytes::Bytes>,
/// The datagram mid-send (the socket returned WouldBlock under
/// it — retried first on every poll).
/// it, retried first on every poll).
sending: Option<bytes::Bytes>,
/// Accepted-byte accounting for the high-water mark (the bytes
/// accepted but not yet sent on the socket).
@@ -341,14 +341,14 @@ impl AsyncRead for UdpHalf {
// Hand out staged frame bytes first (a frame larger than the
// caller's buffer was split across reads; the frame's length
// prefix already crossed, so the pump's de-framer on the far
// side reassembles — no shorter-datagram misread is possible).
// side reassembles; no shorter-datagram misread is possible).
if !this.stage.pending.is_empty() {
let n = this.stage.pending.len().min(buf.remaining());
buf.put_slice(&this.stage.pending.split_to(n));
return Poll::Ready(Ok(()));
}
// Recv one raw datagram (full-size scratch — try_recv
// Recv one raw datagram (full-size scratch; try_recv
// truncates silently at the buffer size, so the scratch must
// hold the largest possible datagram before any size check).
if this.stage.recv_scratch.is_none() {
@@ -370,7 +370,7 @@ impl AsyncRead for UdpHalf {
std::io::ErrorKind::InvalidData,
format!(
"udp datagram {len} bytes exceeds the codec frame bound {max} \
(truncation fails loud — OQ-TN-13)"
(truncation fails loud; OQ-TN-13)"
),
)
},
@@ -380,7 +380,7 @@ impl AsyncRead for UdpHalf {
} else {
// The frame exceeds the caller's buffer: hand out
// the first slice, stage the rest (the length
// prefix rode along — the far-side de-framer
// prefix rode along; the far-side de-framer
// reassembles across reads).
let n = buf.remaining();
buf.put_slice(&framed[..n]);
@@ -416,7 +416,7 @@ impl tokio::io::AsyncWrite for UdpHalf {
// Backpressure: if too many accepted bytes are still queued
// for the socket, try to drain first; if the queue is still
// over the line, return Pending (the pump's copy re-offers
// its window unsatisfied — safe, nothing is lost).
// its window unsatisfied; safe, nothing is lost).
if this.deframe.accepted > WRITE_QUEUE_HIGH_WATER {
match this.pump_queue_to_socket(cx) {
Poll::Ready(Ok(_)) => {}
@@ -432,15 +432,15 @@ impl tokio::io::AsyncWrite for UdpHalf {
// Accept ALL offered bytes into the incremental de-framer and
// report them all consumed. The pump's copy window cannot be
// held closed for a later top-up (a full window is re-offered
// unsatisfied until drained — a partial-frame tail could sit
// unsatisfied until drained; a partial-frame tail could sit
// forever in a full window), and reporting fewer than all
// bytes risks the copy's `WriteZero` on an empty re-offer.
// The DatagramReader's partial-frame state is bounded by
// construction (the u16 cap — review 001 N-2), so accepting
// construction (the u16 cap, review 001 N-2), so accepting
// everything is bounded memory; socket-side backpressure is
// the accepted-byte high-water mark above.
// The de-framer's feed is infallible (the oversize bound is
// enforced at frame time — see the DatagramCodecError docs):
// enforced at frame time; see the DatagramCodecError docs):
// accept everything, bounded by the u16 cap (review 001 N-2).
for dg in this.deframe.reader.feed(buf) {
this.deframe.accepted += dg.len() + 2;
@@ -462,7 +462,7 @@ impl tokio::io::AsyncWrite for UdpHalf {
) -> std::task::Poll<std::io::Result<()>> {
// The send pump: tokio's copy polls flush after draining its
// window, so the queued datagrams drain here whenever the
// pump cycles — and a flush-resolved Ready(Ok(())) tells the
// pump cycles, and a flush-resolved Ready(Ok(())) tells the
// copy the bytes are on the wire. UDP sends are all-or-nothing
// per datagram; a WouldBlock keeps the datagram queued (the
// send waker is registered) and the queue persists until it
@@ -507,7 +507,7 @@ impl UdpHalf {
self.deframe.accepted -= dg.len() + 2;
}
// WouldBlock (EAGAIN) and ENOBUFS both mean "the socket
// cannot take this datagram now" — the unresolved-
// cannot take this datagram now": the unresolved-
// neighbor + shrunken-SNDBUF state reports ENOBUFS(105);
// the stash + waker retry is the same behavior for both.
Err(e)
@@ -530,7 +530,7 @@ impl UdpHalf {
// --- Unix ---------------------------------------------------------------
/// Dial a unix-socket target (OQ-TN-14 — ships in v1, the same halves
/// Dial a unix-socket target (OQ-TN-14, ships in v1, the same halves
/// shape as TCP): `UnixStream::connect` bounded by [`DIAL_TIMEOUT`],
/// `into_split` → boxed halves (raw pass-through, ADR-003).
pub async fn dial_unix(path: &str) -> Result<TargetHandle, TunnelEstablishError> {
@@ -546,10 +546,10 @@ pub async fn dial_unix(path: &str) -> Result<TargetHandle, TunnelEstablishError>
}
/// Bind a unix-socket listener (the listen shape's assembly-side half,
/// OQ-TN-14's dial parity — same wrapper shape as
/// OQ-TN-14's dial parity; same wrapper shape as
/// [`TcpListenerHalves`]). The binding decision belongs to the caller
/// (OQ-TN-04); a stale socket file at `path` fails loud (the
/// caller unlinks first — `bind` never silently reuses).
/// caller unlinks first; `bind` never silently reuses).
pub fn bind_unix(path: &str) -> Result<UnixListenerHalves, TunnelEstablishError> {
let listener = tokio::net::UnixListener::bind(path)
.map_err(|e| TunnelEstablishError::ResourceShortage(format!("bind `{path}`: {e}")))?;
@@ -562,7 +562,7 @@ pub fn bind_unix(path: &str) -> Result<UnixListenerHalves, TunnelEstablishError>
/// accept loop ([`UnixListenerHalves::accept_loop`]) is the
/// accept-FIRST shape's feed ([`AcceptQueue`]); the per-accept
/// [`UnixListenerHalves::accept_pair`] is the bind-FIRST shape's
/// (one accept = one `(peer, halves)` pair — the peer is the
/// (one accept = one `(peer, halves)` pair; the peer is the
/// connecting path, `accept()`'s return value).
pub struct UnixListenerHalves {
listener: Arc<tokio::net::UnixListener>,
@@ -586,7 +586,7 @@ impl UnixListenerHalves {
/// The accept-FIRST shape's loop: accept until the listener fails,
/// pushing each accepted handle into the queue (the assembly layer
/// aborts this task on teardown). Unix peer addresses are not
/// reliably readable from an accepted stream — the queue contract
/// reliably readable from an accepted stream; the queue contract
/// carries halves only, same as the TCP shape's loop.
pub async fn accept_loop(&self, queue: AcceptQueue) -> Result<(), TunnelEstablishError> {
loop {
@@ -608,11 +608,11 @@ impl UnixListenerHalves {
}
}
/// ONE accept as a `(peer, halves)` pair — the BIND-FIRST shape's
/// ONE accept as a `(peer, halves)` pair: the BIND-FIRST shape's
/// substrate half for unix (OQ-TN-14 parity: the bind path is the
/// same shape as [`TcpListenerHalves::accept_pair`]). The peer is
/// the ACCEPTED side's bound path ([`UnixStream::peer_addr`]);
/// an unnamed peer (an autobind client) fails loud — the
/// an unnamed peer (an autobind client) fails loud; the
/// forwarded op's `peer` param is required on the wire, so an
/// addressless peer cannot be represented (ADR-008 §3).
pub async fn accept_pair(&self) -> Result<(SubstrateAddr, TargetHandle), TunnelEstablishError> {
@@ -645,9 +645,9 @@ impl UnixListenerHalves {
/// Bind a unix-socket listener for the BIND-FIRST shape (the
/// real-socket [`BindFn`] source, OQ-TN-14's dial/bind parity): the
/// returned [`BoundListener::bound`] is the REAL bound socket path
/// (the `bound` reply field's data — reply#1-class fidelity; the
/// (the `bound` reply field's data, reply#1-class fidelity; the
/// forwarded op's `peer` shape matches). The opaque `listener` half is
/// the [`UnixListenerHalves`] wrapper — the assembly's
/// the [`UnixListenerHalves`] wrapper; the assembly's
/// [`BindFirstAcceptLoop`] closure downcasts to it and calls
/// [`UnixListenerHalves::accept_pair`] per accept.
pub async fn bind_unix_first(path: &str) -> Result<BoundListener, TunnelEstablishError> {
@@ -662,7 +662,7 @@ pub async fn bind_unix_first(path: &str) -> Result<BoundListener, TunnelEstablis
}
/// The local dial closure (the ADR-004 injection point): one
/// [`DialFn`]-shaped function covering tcp/udp/unix — the assembly
/// [`DialFn`]-shaped function covering tcp/udp/unix; the assembly
/// layer injects this (closed over nothing substrate-specific beyond
/// this module).
pub fn local_dial() -> DialFn {
@@ -681,13 +681,13 @@ pub fn local_dial() -> DialFn {
// --- Bind-first (shape 3, ADR-008 §1) ------------------------------------
/// Bind a TCP listener for the BIND-FIRST shape (the real-socket
/// [`BindFn`] source — shape 3's backing, ADR-008 §1): `port 0` = the
/// [`BindFn`] source; shape 3's backing, ADR-008 §1): `port 0` = the
/// OS chooses, and the returned [`BoundListener::bound`] is the REAL
/// resolved address (`local_addr()` — the `bound` reply field's data,
/// resolved address (`local_addr()`: the `bound` reply field's data,
/// reply#1-class fidelity). The caller-supplied address is deliberate
/// ([`bind_tcp`]'s wildcard-bind posture applies unchanged).
///
/// The opaque `listener` half is the [`TcpListenerHalves`] wrapper —
/// The opaque `listener` half is the [`TcpListenerHalves`] wrapper;
/// the assembly's [`BindFirstAcceptLoop`] closure downcasts to it and
/// calls [`TcpListenerHalves::accept_pair`] per accept.
pub async fn bind_tcp_first(addr: &str) -> Result<BoundListener, TunnelEstablishError> {
@@ -704,7 +704,7 @@ pub async fn bind_tcp_first(addr: &str) -> Result<BoundListener, TunnelEstablish
/// The TCP bind closure + accept-pair loop, bundled (the assembly
/// convenience): [`local_bind_first()`] renders the [`BindFn`] shape-3
/// expects (the dispatch substrate picks tcp/unix; udp fails loud —
/// expects (the dispatch substrate picks tcp/unix; udp fails loud:
/// a UDP socket has no accept, so bind-first is not defined there),
/// [`local_bindfirst_accept_loop()`] renders the [`BindFirstAcceptLoop`]
/// that downcasts the plan's opaque listener to [`TcpListenerHalves`] /
@@ -730,12 +730,12 @@ pub fn local_bind_first() -> BindFn {
/// [`local_bind_first()`]): downcasts the plan's opaque listener to
/// the concrete wrapper the bind closure produced and accepts ONE
/// connection as a `(peer, halves)` pair per invocation (the
/// dispatcher's per-accept contract — one closure call = one accept;
/// dispatcher's per-accept contract: one closure call = one accept;
/// the loop lives in the protocol's dispatcher).
///
/// The downcast is the assembly side of the type-erased injection
/// pattern: the protocol crate never knows the listener's type, so the
/// closure must — a listener not produced by this module's bind
/// closure must; a listener not produced by this module's bind
/// closures (or a mismatched substrate/backing pairing in the
/// assembly's registry) fails loud as `handler_error`.
pub fn local_bindfirst_accept_loop() -> BindFirstAcceptLoop {
+56 -52
View File
@@ -1,18 +1,18 @@
//! Open-op params: `TunnelParams {resource, substrate}` — the
//! Open-op params: `TunnelParams {resource, substrate}`, the
//! wire-stable carrier of "what to open" (ADR-001), plus the open-op
//! `OperationSpec` builder, the scope/ALPN/op-id constants, and the
//! typed establishment-reason helper (ADR-049 §4 surface).
//!
//! The direct op's params ([`TunnelDirectParams`] + [`SubstrateAddr`],
//! ADR-007) and its spec builder live here too: `{substrate, target}`
//! — the caller names the dial target at session time (the ssh
//! ADR-007) and its spec builder live here too: `{substrate, target}`;
//! the caller names the dial target at session time (the ssh
//! `direct-tcpip` / SOCKS5 CONNECT shape), scope-gated by
//! `tunnel:direct` (a separate grant from `tunnel:open`, never implied
//! by it).
//!
//! The forwarded op's params ([`TunnelForwardedParams`], ADR-008 §3
//! as amended) and its spec builder live here as well:
//! `{listen, substrate, peer}` — the accept-as-open ingress op, keyed
//! `{listen, substrate, peer}`, the accept-as-open ingress op, keyed
//! by the producer-minted correlation token (Amendment 1), scope-gated
//! by `tunnel:forwarded` (a separate grant again). The bind-first
//! listen open's reply projection ([`TunnelListenReply`] +
@@ -21,11 +21,11 @@
//! contributes.
//!
//! The params layout is a one-way door once a consumer exists
//! (ADR-001): `{resource, substrate}` only — the producer owns the
//! (ADR-001): `{resource, substrate}` only; the producer owns the
//! backing; no URL-style addressing. `deny_unknown_fields` is the
//! loud-rejection posture (unknown fields fail, never silently
//! ignored); an older producer rejecting a newer substrate value is
//! the SSH "unknown channel type" posture — loud, not silent.
//! the SSH "unknown channel type" posture: loud, not silent.
pub use alkcall::channels::client::ChannelOpenError;
use alkcall::registry::spec::{
@@ -46,25 +46,25 @@ pub const TUNNEL_ALPN: &str = "alk/tunnel";
/// The scope gating tunnel opens (ADR-006). Stable once published.
pub const TUNNEL_OPEN_SCOPE: &str = "tunnel:open";
/// The `channels/tunnel/direct` operation id (the direct op —
/// The `channels/tunnel/direct` operation id (the direct op:
/// dynamic-target egress, ADR-007; the flavor-form op-id convention,
/// ADR-002 Amendment 1).
pub const OP_TUNNEL_DIRECT: &str = "channels/tunnel/direct";
/// The scope gating the direct op (ADR-007 §3 / ADR-006 Amendment 1).
/// A separate grant from [`TUNNEL_OPEN_SCOPE`] — **never implied by
/// A separate grant from [`TUNNEL_OPEN_SCOPE`], **never implied by
/// it**: "dial `postgres-primary`" and "dial anything from my
/// network" are different capabilities. Stable once published.
pub const TUNNEL_DIRECT_SCOPE: &str = "tunnel:direct";
/// The `channels/tunnel/forwarded` operation id (the forwarded op —
/// The `channels/tunnel/forwarded` operation id (the forwarded op:
/// accept-as-open ingress, ADR-008 §3; the flavor-form op-id
/// convention, ADR-002 Amendment 1).
pub const OP_TUNNEL_FORWARDED: &str = "channels/tunnel/forwarded";
/// The scope gating the forwarded op (ADR-008 §3 / ADR-006 Amendment
/// 1). A separate grant from [`TUNNEL_OPEN_SCOPE`] and
/// [`TUNNEL_DIRECT_SCOPE`] — **never implied by either**: receiving
/// [`TUNNEL_DIRECT_SCOPE`], **never implied by either**: receiving
/// producer-initiated forwarded connections (ingress) is a different
/// capability from dialing egress. Stable once published.
pub const TUNNEL_FORWARDED_SCOPE: &str = "tunnel:forwarded";
@@ -75,7 +75,7 @@ pub const TUNNEL_FORWARDED_SCOPE: &str = "tunnel:forwarded";
#[serde(deny_unknown_fields)]
pub struct TunnelParams {
/// The produced resource identifier: the producer's stable name
/// for the tunnel target — NOT an address (the producer's
/// for the tunnel target, NOT an address (the producer's
/// registry maps it to its backing, OQ-TN-11).
pub resource: String,
/// The extensible substrate discriminator; selects the data-plane
@@ -84,7 +84,7 @@ pub struct TunnelParams {
}
/// The substrate discriminator (ADR-001). `Unix` ships in v1 (the
/// `local` feature implements it — OQ-TN-14); a new substrate is a
/// `local` feature implements it; OQ-TN-14); a new substrate is a
/// new value, not a format change.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -95,12 +95,12 @@ pub enum Substrate {
}
/// The substrate-shaped address object (ADR-007 §2): keyed by the
/// sibling `substrate` field of the params — `tcp`/`udp` →
/// sibling `substrate` field of the params: `tcp`/`udp` →
/// [`SubstrateAddr::HostPort`] (domain-form hosts stay unresolved on
/// the wire — producer-side resolution, the ssh `-D` semantic),
/// the wire: producer-side resolution, the ssh `-D` semantic),
/// `unix` → [`SubstrateAddr::Path`]. Untagged: the shapes are
/// mutually exclusive in practice and self-describing. Also the
/// `peer` value shape of the forwarded op (ADR-008 §3) — defined
/// `peer` value shape of the forwarded op (ADR-008 §3), defined
/// once here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
@@ -114,7 +114,7 @@ pub enum SubstrateAddr {
/// shape-vs-substrate correspondence is pinned by the spec's
/// `input_schema` (per-substrate `oneOf`, validated registry-side
/// before the establisher) and re-checked by serde here (the
/// backstop — a mismatch parses as the wrong variant or fails).
/// backstop: a mismatch parses as the wrong variant or fails).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelDirectParams {
@@ -128,7 +128,7 @@ pub struct TunnelDirectParams {
impl TunnelDirectParams {
/// Render the target to the backing-string form the `DialFn` /
/// registry backings use: `"host:port"` for tcp/udp, the path for
/// unix. The direct establisher calls this before dialing — the
/// unix. The direct establisher calls this before dialing; the
/// dial closure stays substrate-agnostic (ADR-007 §4).
pub fn render_target(&self) -> String {
match &self.target {
@@ -179,11 +179,11 @@ pub fn tunnel_open_spec() -> OperationSpec {
/// The direct-op spec (`channels/tunnel/direct`, ADR-007): `Sub`-typed,
/// external, channel-open marker on the `alk/tunnel` ALPN, scope-gated
/// by `tunnel:direct` (a separate grant from `tunnel:open` — never
/// by `tunnel:direct` (a separate grant from `tunnel:open`, never
/// implied by it, ADR-006 Amendment 1), input/output schemas per
/// wire.md §The Direct Op. The target sub-schema is per-substrate —
/// wire.md §The Direct Op. The target sub-schema is per-substrate:
/// `if`/`then` conditioned on the sibling `substrate` value
/// (`tcp`/`udp` → `{host, port}`, `unix` → `{path}`) — so a malformed
/// (`tcp`/`udp` → `{host, port}`, `unix` → `{path}`), so a malformed
/// target fails the registry's schema validation (the typed
/// `INVALID_INPUT` class) before the establisher runs.
pub fn tunnel_direct_spec() -> OperationSpec {
@@ -272,7 +272,7 @@ pub fn tunnel_direct_spec() -> OperationSpec {
/// The reply arrives as `{channel_id, bound?, listen?}` (the
/// wrapper-reserved `channel_id` key plus the establisher's additive
/// fields); unknown extra reply fields must be tolerated (the additive
/// posture — old consumers ignore unknown fields). Parse the full
/// posture: old consumers ignore unknown fields). Parse the full
/// reply with [`parse_listen_reply`], never with `deny_unknown_fields`
/// deserialization of the whole reply. Both fields are `None` for
/// accept-first/dial opens (the establisher supplied nothing).
@@ -280,33 +280,33 @@ pub fn tunnel_direct_spec() -> OperationSpec {
#[serde(deny_unknown_fields)]
pub struct TunnelListenReply {
/// The real OS-chosen bound address (port `0` resolved),
/// substrate-shaped — present only when the establisher supplied
/// substrate-shaped, present only when the establisher supplied
/// it. Informational surface, NOT addressing: a consumer MUST NOT
/// treat `bound` as a dial target (the producer owns the backing —
/// treat `bound` as a dial target (the producer owns the backing,
/// ADR-001's invariant); it exists for RFC-1928-reply-class
/// fidelity (BND.ADDR) and observability.
#[serde(skip_serializing_if = "Option::is_none")]
pub bound: Option<SubstrateAddr>,
/// The correlation token (ADR-008 Amendment 1): the u32 the
/// establisher minted — the key the forwarded op's `listen` param
/// establisher minted, the key the forwarded op's `listen` param
/// carries. Present only when the establisher supplied it.
#[serde(skip_serializing_if = "Option::is_none")]
pub listen: Option<u32>,
}
/// Project the bind-first listen open's reply (ADR-008 §2 as amended)
/// out of the FULL success output [`Value`] — the shape
/// out of the FULL success output [`Value`], the shape
/// `ChannelClient::open_channel_with_reply` returns. Extracts the
/// optional `bound` + `listen` fields into [`TunnelListenReply`].
///
/// - Unknown extra reply fields are **tolerated** — the reply viewer
/// - Unknown extra reply fields are **tolerated**; the reply viewer
/// must not deny unknown fields (the additive posture; the wire is
/// plain JSON reply fields, and the projection is a typed view).
/// - `channel_id` must be present and a positive integer (the
/// wrapper-owned key — its absence means the caller is not looking
/// wrapper-owned key: its absence means the caller is not looking
/// at an open reply at all; fail loud).
/// - A present-but-malformed `bound` (not substrate-shaped) or
/// `listen` (not a positive u32) is a loud error — a misbehaving
/// `listen` (not a positive u32) is a loud error: a misbehaving
/// producer is never silently coerced ([`TunnelError::
/// MalformedReply`]).
pub fn parse_listen_reply(reply: &Value) -> Result<TunnelListenReply, TunnelError> {
@@ -341,7 +341,7 @@ pub fn parse_listen_reply(reply: &Value) -> Result<TunnelListenReply, TunnelErro
}
/// Extract the bind-first listen open's reply fields (ADR-008 §2 as
/// amended) from an open-op success reply — the **lenient, infallible
/// amended) from an open-op success reply, the **lenient, infallible
/// projection** the typed consumer surface reads: both fields
/// [`Option`], absent for dial/accept-first producers, unknown extra
/// reply fields tolerated (the additive posture).
@@ -350,9 +350,9 @@ pub fn parse_listen_reply(reply: &Value) -> Result<TunnelListenReply, TunnelErro
/// projects a malformed reply to both-`None`: the session type
/// presents `Option` fields and never refuses construction over an
/// informational field (`bound`/`listen` are informational surface,
/// NOT addressing — ADR-008 §2's invariant; leniency here is safe by
/// NOT addressing, per ADR-008 §2's invariant; leniency here is safe by
/// construction). A malformed shape is logged loud (`tracing::error!`)
/// and surfaces as `None` — the session's caller cannot distinguish
/// and surfaces as `None`; the session's caller cannot distinguish
/// "producer supplied nothing" from "producer misbehaved"; use
/// [`parse_listen_reply`] directly when that distinction matters (the
/// fail-loud posture for consumers that must reject misbehaving
@@ -377,13 +377,13 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply {
/// The forwarded-op input params (ADR-008 §3 as amended). Wire-stable:
/// all three fields required; renames are a breaking wire change.
/// `deny_unknown_fields` is the loud-rejection posture (ADR-001's
/// precedent — unknown fields fail, never silently ignored).
/// precedent: unknown fields fail, never silently ignored).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelForwardedParams {
/// The correlation token (ADR-008 Amendment 1): the u32 the
/// bind-first establisher minted and returned in the listen
/// open's `listen` reply field — NOT the listen channel's ID
/// open's `listen` reply field, NOT the listen channel's ID
/// (that key is superseded: the serving-side handler never sees
/// its own channel ID, and generic relays don't rewrite params).
/// Positive by wire contract (schema `minimum: 1`; the serde pin
@@ -394,7 +394,7 @@ pub struct TunnelForwardedParams {
/// data-plane framing (ADR-003).
pub substrate: Substrate,
/// The accepted connection's remote address, keyed by the sibling
/// `substrate` field ([`SubstrateAddr`]). Required in v1 — a
/// `substrate` field ([`SubstrateAddr`]). Required in v1: a
/// producer that cannot determine the peer does not serve a
/// bind-first listener over that substrate (ADR-008 §3).
pub peer: SubstrateAddr,
@@ -403,7 +403,7 @@ pub struct TunnelForwardedParams {
/// The serde-level pin behind `listen`'s schema `minimum: 1`: the
/// correlation token is positive by contract (the establisher seeds
/// the mint counter non-zero), so a `listen: 0` on the wire is a
/// malformed open — rejected at parse time, never silently accepted.
/// malformed open, rejected at parse time, never silently accepted.
fn positive_u32<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: serde::Deserializer<'de>,
@@ -417,20 +417,24 @@ where
/// The forwarded-op spec (`channels/tunnel/forwarded`, ADR-008 §3 as
/// amended): `Sub`-typed, external, channel-open marker on the
/// `alk/tunnel` ALPN, scope-gated by `tunnel:forwarded` (ingress — a
/// `alk/tunnel` ALPN, scope-gated by `tunnel:forwarded` (ingress: a
/// separate grant from `tunnel:open` and `tunnel:direct`, ADR-006
/// Amendment 1), input/output schemas per wire.md §The Forwarded Op.
/// All three params required; the `peer` sub-schema is per-substrate —
/// All three params required; the `peer` sub-schema is per-substrate:
/// `if`/`then` conditioned on the sibling `substrate` value (`tcp`/
/// `udp` → `{host, port}`, `unix` → `{path}`), so a malformed peer
/// fails the registry's schema validation (the typed
/// `INVALID_INPUT` class) before the opener-side handler runs.
///
/// **No establisher** on the forwarded op — the data plane starts
/// immediately (the opener-side serving registration is
/// `register_openable`, handler = correlate by `listen` + accept the
/// **Serving registration** (ADR-008 §3 as amended by Amendment 2):
/// establisher-less base shape: `register_openable`, the data plane
/// starts immediately (handler = correlate by `listen` + accept the
/// `BiStream` + pump; the correlation registry is assembly-side
/// bookkeeping, ADR-008 §3).
/// bookkeeping), or the recommended establisher variant,
/// `register_tunnel_forwarded_openable_with_establisher` (the
/// injected correlation gate; a stale/missing token fails the open as
/// a typed `channel:open_failed`). The spec itself is unchanged: the
/// same op id, params, scope, and reply surface under both shapes.
pub fn tunnel_forwarded_spec() -> OperationSpec {
OperationSpec::new(
OP_TUNNEL_FORWARDED,
@@ -600,7 +604,7 @@ mod tests {
#[test]
fn serde_rejects_unknown_substrate_value() {
// The serde layer (the enum deserializer), not the JSON Schema —
// The serde layer (the enum deserializer), not the JSON Schema:
// the registry's real schema gate is pinned end-to-end by the
// integration probes (`schema_rejects_unknown_substrate_loudly`,
// `malformed_target_is_schema_class_and_leaves_no_channel`, the
@@ -705,7 +709,7 @@ mod tests {
});
assert!(serde_json::from_value::<TunnelDirectParams>(v).is_err());
// The untagged target is lenient about extra target fields
// (untagged variants carry no deny_unknown_fields) — a
// (untagged variants carry no deny_unknown_fields); a
// cross-shape target parses as whichever variant fits. The
// SCHEMA is the gate that rejects shape-vs-substrate
// mismatches registry-side (additionalProperties: false per
@@ -928,7 +932,7 @@ mod tests {
#[test]
fn listen_reply_bare_projection() {
// The accept-first/dial shape: the establisher contributed
// nothing — both fields None.
// nothing; both fields are None.
let reply = json!({"channel_id": 7});
let projected = parse_listen_reply(&reply).unwrap();
assert_eq!(
@@ -942,7 +946,7 @@ mod tests {
#[test]
fn listen_reply_tolerates_unknown_extra_fields() {
// The additive posture — the reply viewer must NOT deny
// The additive posture: the reply viewer must NOT deny
// unknown fields (future additive reply fields keep old
// consumers working).
let reply = json!({
@@ -965,9 +969,9 @@ mod tests {
#[test]
fn listen_reply_rejects_malformed_shapes() {
// No channel_id at all — not an open reply.
// No channel_id at all: not an open reply.
assert!(parse_listen_reply(&json!({"bound": {"path": "/p"}})).is_err());
// channel_id = 0 (the wrapper never allocates 0 — channel 0 is
// channel_id = 0 (the wrapper never allocates 0; channel 0 is
// the call plane).
assert!(parse_listen_reply(&json!({"channel_id": 0})).is_err());
// Malformed bound (not substrate-shaped).
@@ -1011,9 +1015,9 @@ mod tests {
#[test]
fn forwarded_spec_peer_schema_gates_shape_per_substrate() {
// The per-substrate peer sub-schema: the same if/then
// structure the direct op pins its target with — structural
// assertions per tunnels/params' precedent (no jsonschema dep
// here).
// structure the direct op pins its target with, asserted
// structurally per tunnels/params' precedent (no jsonschema
// dep here).
let schema = tunnel_forwarded_spec().input_schema;
let allof = schema["allOf"].as_array().expect("allOf branches");
assert_eq!(allof.len(), 2, "tcp/udp branch + unix branch");
@@ -1052,7 +1056,7 @@ mod tests {
// fails loud at the deserializer. `deny_unknown_fields` holds:
// unknown top-level params reject; the untagged peer is
// lenient about extra peer fields (same documented asymmetry
// as the direct op's target — the SCHEMA is the shape-vs-
// as the direct op's target: the SCHEMA is the shape-vs-
// substrate gate).
let cross_shape = json!({
"listen": 1,
@@ -1105,7 +1109,7 @@ mod tests {
#[test]
fn listen_reply_fields_tolerates_unknown_extra_fields() {
// The additive posture — the lenient projection must NOT gate
// The additive posture: the lenient projection must NOT gate
// unknown reply fields.
let reply = json!({
"channel_id": 7,
+261 -118
View File
@@ -1,4 +1,4 @@
//! The producer half: the `channels/tunnel/sub` open op — its
//! The producer half: the `channels/tunnel/sub` open op, its
//! establisher (dial shape), its substrate-agnostic pump handler, and
//! its registration (`register_tunnel_openable`) per `producer.md` /
//! ADR-004.
@@ -6,11 +6,11 @@
//! The establisher is the open op's awaited, bounded establishment
//! phase (alkcall ADR-049): parse params semantically, look the
//! resource up in the producer's registry, dial via the injected
//! `DialFn` (ADR-004 — "produce boxed halves for a resource" is a
//! `DialFn` (ADR-004: "produce boxed halves for a resource" is a
//! function, not a trait), and return the handle in
//! `Establishment::new(plan)` (R-01 — typed-opaque `ChannelPlan`; its
//! `Establishment::new(plan)` (R-01: typed-opaque `ChannelPlan`; its
//! `Send + Sync` bound is why `TargetHandle`'s halves carry `+ Sync`).
//! The dial closure closes over substrate code — the protocol crate
//! The dial closure closes over substrate code: the protocol crate
//! never sees a socket type.
//!
//! The pump handler downcasts the plan and awaits
@@ -19,13 +19,13 @@
//! return = teardown-at-birth). It cannot know what the halves came
//! from and must not (AGENTS.md convention 7).
//!
//! The listen establisher variant (the producer-side listener — the
//! The listen establisher variant (the producer-side listener, the
//! `-R` far-side listener shape) is [`listen_establisher`] +
//! [`AcceptQueue`] below (the `tunnels/producer-listen` shape). The
//! direct op's establisher ([`direct_establisher`] — dynamic-target
//! direct op's establisher ([`direct_establisher`], dynamic-target
//! egress, ADR-007) is the dial shape minus the registry lookup.
//!
//! The bind-first shape's plan payload ([`BindFirstPlan`] — the opaque
//! The bind-first shape's plan payload ([`BindFirstPlan`]: the opaque
//! listener + the correlation token, ADR-008 as amended) is defined
//! here; the establisher and accept dispatcher that consume it land
//! with `tunnels/bindfirst-producer` (the wire-stable types pin
@@ -53,7 +53,7 @@ pub use crate::params::{
TUNNEL_OPEN_SCOPE,
};
/// The dialed target handle — the plan payload (typed-opaque
/// The dialed target handle: the plan payload (typed-opaque
/// `ChannelPlan`; establisher and handler agree on the concrete
/// type). The boxed halves carry `+ Sync` to satisfy the plan's
/// `Send + Sync` bound (POC F-1, documented upstream).
@@ -75,29 +75,29 @@ impl std::fmt::Debug for TargetHandle {
/// the assembly's listener handle + the correlation token + the listen
/// tunnel's substrate.
///
/// **The listener is NOT `TargetHandle` halves** — a listener is
/// **The listener is NOT `TargetHandle` halves**: a listener is
/// accepted FROM, not pumped. The plan carries whatever the assembly's
/// `BindFn` produced, typed-opaque ([`Any`]; the same
/// injection pattern as `DialFn`/`AcceptFn` — the accept dispatcher
/// injection pattern as `DialFn`/`AcceptFn`; the accept dispatcher
/// downcasts it to the assembly's listener type via a type-erased
/// accept closure; the ADR's "listener handle in the plan" is
/// assembly-typed, and the protocol sees only this opaque pair).
///
/// **The correlation token (ADR-008 Amendment 1):** minted by the
/// bind-first establisher — a fresh u32 per open, unique per producer
/// bind-first establisher: a fresh u32 per open, unique per producer
/// (an `AtomicU32` counter seeded non-zero, e.g. 1, inside the
/// establisher's closure state; wraps are acceptable — u32, and a wrap
/// establisher's closure state; wraps are acceptable: u32, and a wrap
/// collision requires 2^32 concurrent opens on one producer). Carried
/// twice: in the plan (the accept dispatcher stamps it into every
/// forwarded open it opens for this listener) and as the additive
/// `listen` reply field (the opener correlates its waiting session on
/// it). It is NOT the listen channel's ID — the serving-side handler
/// it). It is NOT the listen channel's ID; the serving-side handler
/// never sees its own channel ID, and generic hub relays forward
/// params untouched (Amendment 1's rationale). Per-open fresh,
/// per-producer unique; retired with the listener session.
pub struct BindFirstPlan {
/// The assembly-typed listener handle (e.g. a `TcpListener`
/// wrapper the `BindFn` produced). Opaque to the protocol — the
/// wrapper the `BindFn` produced). Opaque to the protocol: the
/// accept dispatcher downcasts via a type-erased accept closure.
pub listener: Arc<dyn Any + Send + Sync>,
/// The correlation token (see above): minted fresh per open,
@@ -106,11 +106,11 @@ pub struct BindFirstPlan {
pub token: u32,
/// The listen tunnel's substrate (ADR-008 §3), stamped here at
/// establisher time (the establisher already parsed the params
/// successfully — one parse, one source of truth). The accept
/// successfully: one parse, one source of truth). The accept
/// dispatcher reads it from the plan it already downcast; it must
/// NOT re-parse wire input (a silent `Tcp` fallback on a parse
/// failure would mismatch a UDP listen tunnel's data-plane
/// framing — review 002 M-1).
/// framing, review 002 M-1).
pub substrate: Substrate,
}
@@ -158,9 +158,9 @@ impl From<TunnelEstablishError> for EstablishmentError {
/// The producer's registry of produced resources:
/// `(resource, substrate)` → backing. An assembly-owned construct
/// (OQ-TN-11 — the collision domain is the producer's registry; two
/// (OQ-TN-11: the collision domain is the producer's registry; two
/// producers on one connection may expose the same resource name
/// independently). The backing is opaque to the protocol — the
/// independently). The backing is opaque to the protocol: the
/// `DialFn` closes over whatever shape the assembly layer chose.
#[derive(Clone, Default)]
pub struct ResourceRegistry {
@@ -173,7 +173,7 @@ impl ResourceRegistry {
}
/// Produce a resource: map `(resource, substrate)` to its backing
/// (a local port, a socket path, an in-process address — the
/// (a local port, a socket path, an in-process address; the
/// dial closure's business, not the wire's).
pub async fn register(&self, resource: &str, substrate: Substrate, backing: &str) {
self.targets
@@ -208,7 +208,7 @@ pub type DialFn = Arc<
>;
/// The witness capturing the establisher's per-call opener identity
/// (CF-006 probe — the establisher sees the END CALLER overlaid onto
/// (CF-006 probe: the establisher sees the END CALLER overlaid onto
/// the install-time context, not a synthetic install-time value).
pub type IdentityWitness = Arc<tokio::sync::Mutex<Option<String>>>;
@@ -256,26 +256,26 @@ fn parse_params(input: &Value) -> Result<TunnelParams, TunnelEstablishError> {
.map_err(|e| TunnelEstablishError::HandlerError(format!("invalid params: {e}")))
}
/// The direct establisher (ADR-007 §4 — the dial shape minus the
/// The direct establisher (ADR-007 §4, the dial shape minus the
/// registry lookup): parse `TunnelDirectParams`, render the target to
/// the same backing-string form the registry backings use
/// (`"host:port"` for tcp/udp, the path for unix), and call the
/// injected [`DialFn`] — the SAME dial function the base op uses, so
/// injected [`DialFn`]; the SAME dial function the base op uses, so
/// a producer that registered `dial_tcp` for the base op registers
/// the direct op with zero new substrate code (the UDP framed-adapter
/// wrap, ADR-003, happens inside the dial closure — the establisher
/// stays substrate-agnostic). No registry lookup — `unknown_resource`
/// wrap, ADR-003, happens inside the dial closure; the establisher
/// stays substrate-agnostic). No registry lookup, so `unknown_resource`
/// can never fire.
///
/// The per-call `auth` carries the opener identity (CF-006); record it
/// into `identity_witness` when provided (the direct op is the
/// arbitrary-egress capability — identity visibility matters more,
/// arbitrary-egress capability: identity visibility matters more,
/// not less).
///
/// Error mapping (ADR-007 §4's table minus the registry row): the
/// serde-parse failure of a schema-valid-shaped target maps
/// [`TunnelEstablishError::HandlerError`] (the schema gate should
/// have caught it — the parse is the backstop); dial failures pass
/// have caught it; the parse is the backstop); dial failures pass
/// through the `DialFn`'s own typed errors unchanged (`dial_failed` /
/// `resource_shortage` / `handler_error`).
pub fn direct_establisher(dial: DialFn) -> OpenEstablisher {
@@ -307,17 +307,17 @@ pub fn direct_establisher_with_witness(
})
}
/// The listen establisher (shape 2 — producer.md §The Establisher):
/// The listen establisher (shape 2, producer.md §The Establisher):
/// the same open op, same params, same typed errors; the establisher
/// pops the next accepted connection from the injected [`AcceptFn`]
/// instead of dialing. The resource lookup follows the same registry
/// miss mapping (`unknown_resource`); the accept closure's failures
/// map at the closure (empty queue → `resource_shortage`, closed
/// listener → `dial_failed` — producer.md's table). The pump handler
/// listener → `dial_failed`; producer.md's table). The pump handler
/// is UNCHANGED: the listen variant is plan-flow with a different
/// halves source.
///
/// The registry check is a namespace gate only — the listen producer's
/// The registry check is a namespace gate only: the listen producer's
/// "backing" is its accept queue, not a dial target. A resource the
/// producer does not produce is still `unknown_resource` (same as
/// dial); an openable resource with nothing yet accepted is
@@ -344,9 +344,9 @@ pub fn listen_establisher(registry: ResourceRegistry, accept: AcceptFn) -> OpenE
}
/// The accepted-connection source the assembly layer injects for a
/// LISTEN producer (shape 2 — the `-R` far-side listener; producer.md
/// LISTEN producer (shape 2, the `-R` far-side listener; producer.md
/// §The Establisher): pops one accepted connection's halves. The
/// assembly layer owns the listener + its accept loop — the protocol
/// assembly layer owns the listener + its accept loop; the protocol
/// crate never binds (OQ-TN-04). The queue contract lives in
/// [`AcceptQueue`]; the closure pops from it (or from whatever the
/// assembly chose).
@@ -360,17 +360,17 @@ pub type AcceptFn = Arc<
/// listen establisher (the protocol-side queue contract). The accept
/// loop feeds ([`AcceptQueue::push`]); the establisher pops during the
/// open op's establishment phase ([`AcceptQueue::pop`]). `pop` is
/// bounded by the establisher's own deadline (ADR-049 §2) — but an
/// bounded by the establisher's own deadline (ADR-049 §2), but an
/// empty queue is NOT inherently an error (a late accept is
/// legitimate); the mapping to `resource_shortage` is the ASSEMBLY
/// layer's posture (its accept closure knows the listener's bound —
/// layer's posture (its accept closure knows the listener's bound:
/// a drained listener or an accept budget). The wrapper's
/// establishment timeout is the backstop above that.
///
/// `pop` returns `None` when the queue is closed-and-empty (the
/// listener is gone — the assembly layer calls [`AcceptQueue::close`]
/// listener is gone; the assembly layer calls [`AcceptQueue::close`]
/// on teardown). FIFO order: every popped handle was accepted BEFORE
/// that pop began (always-before-take ordering — push enqueues under
/// that pop began (always-before-take ordering: push enqueues under
/// the same lock that pops dequeue under, and `pop` re-checks the
/// queue after every wake).
#[derive(Clone, Default)]
@@ -414,7 +414,7 @@ impl AcceptQueue {
/// is created BEFORE the state check on every iteration. A `push`
/// landing in the thread-preemption window between the check and
/// the future's first poll is captured by tokio's notify-waiters
/// counter comparison at that first poll — the future resolves
/// counter comparison at that first poll; the future resolves
/// immediately; a later `push` finds the waiter registered and
/// wakes it. A future created after the check (the naive shape)
/// loses that push, parking the pop until the next one.
@@ -443,10 +443,10 @@ impl AcceptQueue {
/// The pump handler: accept the channel's yield-once `BiStream`,
/// downcast the plan to `TargetHandle`, and await `pump_bidi` inline
/// (R-02 — the returned `JoinHandle` tracks the data-plane lifetime;
/// (R-02: the returned `JoinHandle` tracks the data-plane lifetime;
/// an early return is teardown-at-birth, and the birth-teardown
/// telemetry diagnoses the spawn-and-forget shape at runtime). The
/// handler is substrate-agnostic by construction — it sees boxed
/// handler is substrate-agnostic by construction: it sees boxed
/// halves, never a socket type.
pub fn make_tunnel_pump_handler() -> OpenHandler {
Arc::new(
@@ -505,7 +505,7 @@ pub fn register_tunnel_openable(
/// base op (the direct op's plan payload is a [`TargetHandle`] exactly
/// like the base op's; plan-flow is unchanged), via
/// `register_openable_with_establisher` (`timeout: None` = the 10s
/// default bound). `dial` is the SAME [`DialFn`] the base op uses —
/// default bound). `dial` is the SAME [`DialFn`] the base op uses;
/// no new substrate code. `identity_witness` is the optional CF-006
/// probe seam (the establisher records the per-call opener identity
/// it saw).
@@ -529,8 +529,8 @@ pub fn register_tunnel_direct_openable(
/// Register the tunnel open op with a LISTEN establisher (shape 2):
/// the same spec + pump handler as [`register_tunnel_openable`]; the
/// establisher pops accepted handles from the injected [`AcceptFn`]
/// instead of dialing. One registration per operation id per registry
/// — a session serves either a dial establisher or a listen
/// instead of dialing. One registration per operation id per registry:
/// a session serves either a dial establisher or a listen
/// establisher for `channels/tunnel/sub`; a producer that does both
/// registers on separate registries (per-connection registries are
/// independent, producer.md §Registration API).
@@ -553,10 +553,10 @@ pub fn register_tunnel_listen_openable(
// --- Bind-first (shape 3, ADR-008) ---------------------------------------
/// The substrate bind function the assembly layer injects (shape 3 —
/// The substrate bind function the assembly layer injects (shape 3,
/// ADR-008 §1): given a substrate + a registry backing (the same
/// backing-string form the dial shape's `DialFn` receives), **bind the
/// listener** and produce it. The protocol never binds (OQ-TN-04) —
/// listener** and produce it. The protocol never binds (OQ-TN-04);
/// this closure is the platform boundary, the same inversion point as
/// `DialFn`. The returned [`BoundListener`] pairs the opaque
/// assembly-typed listener (the accept loop's business) with the
@@ -573,11 +573,11 @@ pub type BindFn = Arc<
/// What the assembly's bind produces (ADR-008 §1): the opaque listener
/// (the accept loop's business) + the OS-chosen bound address (the
/// `bound` reply field's source — the REAL resolved address, port `0`
/// `bound` reply field's source: the REAL resolved address, port `0`
/// resolved; informational surface, never addressing).
pub struct BoundListener {
/// The assembly-typed listener (e.g. the `TcpListener` wrapper the
/// assembly chose). Opaque to the protocol — rides the plan's
/// assembly chose). Opaque to the protocol: rides the plan's
/// `Any`, downcast only inside the assembly's accept closure.
pub listener: Arc<dyn Any + Send + Sync>,
/// The real OS-chosen bound address (the `bound` reply field's
@@ -587,15 +587,15 @@ pub struct BoundListener {
/// The token mint (the discipline pinned on [`BindFirstPlan`]): a
/// non-zero-seeded `AtomicU32` counter held in the establisher's
/// closure state — fresh per open, unique per producer, wraps
/// closure state, fresh per open, unique per producer, wraps
/// acceptable (a wrap collision requires 2^32 concurrent opens on one
/// producer — and that bound is loose: the token is
/// producer, and that bound is loose: the token is
/// per-producer-CONNECTION and each open rides one connection, so the
/// alkcall 256-channels-per-connection cap makes a live-collision
/// window unreachable by a wide margin, review 002 N-5). The counter
/// seeds at 1: `fetch_add` returns the
/// PREVIOUS value, so the first mint is 1 and the minted stream is
/// 1, 2, 3, … — token 0 (an illegal wire value; the forwarded params'
/// 1, 2, 3, …; token 0 (an illegal wire value; the forwarded params'
/// `listen` pin rejects it) is never minted, and a u32 wrap that lands
/// on 0 re-increments (the loop skips it; the 2^32 collision window
/// makes wrap uniqueness a non-issue at the discipline's own terms).
@@ -609,14 +609,14 @@ fn mint_token(counter: &std::sync::atomic::AtomicU32) -> u32 {
}
}
/// The bind-first establisher (shape 3 — producer.md §The Establisher,
/// The bind-first establisher (shape 3, producer.md §The Establisher,
/// ADR-008 §1): the same open op, same params, same typed-error table;
/// the establisher **binds** (via the injected [`BindFn`]) instead of
/// dialing or popping an accept, mints the correlation token
/// (per-open-fresh, per-producer-unique — the discipline pinned on
/// (per-open-fresh, per-producer-unique, the discipline pinned on
/// [`BindFirstPlan`]), contributes
/// the `bound` + `listen` reply fields (the establisher → reply
/// projection, ADR-049 amendment 3 — the wrapper merges them into the
/// projection, ADR-049 amendment 3; the wrapper merges them into the
/// success output after its reserved `channel_id` key), and returns
/// the [`BindFirstPlan`] as the plan payload.
///
@@ -624,7 +624,7 @@ fn mint_token(counter: &std::sync::atomic::AtomicU32) -> u32 {
/// [`listen_establisher`]): the bind-first producer's "backing" is the
/// [`BindFn`], not a dial target; a resource the producer does not
/// produce is still `unknown_resource`. The resolved backing string
/// rides the same signature the dial shape's `DialFn` takes — the bind
/// rides the same signature the dial shape's `DialFn` takes; the bind
/// closure sees the same `(substrate, backing)` form.
///
/// Error mapping (producer.md's shape-3 table): unbindable/held port →
@@ -633,11 +633,11 @@ fn mint_token(counter: &std::sync::atomic::AtomicU32) -> u32 {
/// `unknown_resource`; internal → `handler_error`. The `BindFn`
/// returns the typed errors; the establisher passes them through.
///
/// The token lives in BOTH the reply (`listen` — the opener's
/// The token lives in BOTH the reply (`listen`, the opener's
/// correlation key) and the plan (the accept dispatcher stamps it into
/// every forwarded open). Two opens mint distinct tokens (the mint is
/// per-open); each dispatcher stamps ITS OWN plan's token (the plan
/// flows per-open — R-01).
/// flows per-open, R-01).
pub fn bindfirst_establisher(registry: ResourceRegistry, bind: BindFn) -> OpenEstablisher {
use std::sync::atomic::AtomicU32;
let counter = Arc::new(AtomicU32::new(1));
@@ -673,10 +673,10 @@ pub fn bindfirst_establisher(registry: ResourceRegistry, bind: BindFn) -> OpenEs
}
/// The accepted-connection source the assembly layer injects for the
/// BIND-FIRST producer (shape 3 — ADR-008 §1): pops one accepted
/// BIND-FIRST producer (shape 3, ADR-008 §1): pops one accepted
/// connection from the plan's opaque listener and yields
/// `(SubstrateAddr /* peer */, TargetHandle /* accepted halves */)`.
/// The closure receives the listener as the opaque `Any` — the
/// The closure receives the listener as the opaque `Any`; the
/// assembly downcasts it to ITS concrete listener type inside the
/// closure (the same type-erased injection pattern as
/// `DialFn`/`AcceptFn`; substrate code lives behind the closure,
@@ -685,10 +685,10 @@ pub fn bindfirst_establisher(registry: ResourceRegistry, bind: BindFn) -> OpenEs
///
/// A returned `Err` is a LOOP-CRITICAL condition: the accept loop's
/// substrate source is broken (the listener closed, accept errored
/// unrecoverably) — the dispatcher ends the loop, tears the listener
/// unrecoverably); the dispatcher ends the loop, tears the listener
/// down with the plan, and lets the handler task finish (the channel
/// reaps). Per-ACCEPT failures (forwarded-open failures) do NOT come
/// from this closure — see `make_bindfirst_accept_handler`'s error
/// from this closure; see `make_bindfirst_accept_handler`'s error
/// posture.
pub type BindFirstAcceptLoop = Arc<
dyn Fn(
@@ -706,12 +706,12 @@ pub type BindFirstAcceptLoop = Arc<
/// opener, adopts the opener-allocated ID on the producer-side
/// manager, and returns the adopted channel's halves (the pump side).
///
/// **This closure is the call plane** (the design pin — producer.md
/// **This closure is the call plane** (the design pin: producer.md
/// §The Pump Handler item 5): the per-channel `Connection` the
/// dispatcher receives is stream-less (`open_bi` is `StreamClosed` —
/// dispatcher receives is stream-less (`open_bi` is `StreamClosed`,
/// ADR-035) and carries no call surface, so the registration closes
/// over the serving session's `Arc<CallConnection>` (the assembly has
/// it — it constructed the serving session). The shape is the reverse
/// it; it constructed the serving session). The shape is the reverse
/// POC's `open_reverse_channel`: `call_with_payload` on channel 0 →
/// extract `channel_id` from the reply → `manager.adopt_channel`. The
/// accepted halves pump against the adopted channel's halves.
@@ -731,41 +731,41 @@ pub type ForwardedOpener = Arc<
+ Sync,
>;
/// The bind-first pump handler — the ACCEPT DISPATCHER (ADR-008 §1).
/// The bind-first pump handler: the ACCEPT DISPATCHER (ADR-008 §1).
///
/// Handler contract (producer.md §The Pump Handler item 5 + R-02):
///
/// 1. `conn.accept_bi()` FIRST (the yield-once contract — the
/// 1. `conn.accept_bi()` FIRST (the yield-once contract: the
/// wrapper's accepted-flag telemetry requires it), downcast the
/// plan to [`BindFirstPlan`].
/// 2. The listen channel's own `BiStream` stays idle (no pumps on
/// it); **its EOF cancels the loop and tears the listener down** —
/// a 1-byte `read` on the idle stream observes the opener dropping
/// it); **its EOF cancels the loop and tears the listener down**: a
/// 1-byte `read` on the idle stream observes the opener dropping
/// the listener session (the zero-byte EOF sentinel; ADR-050's
/// teardown contract). `select!` races the EOF watch against the
/// accept loop.
/// 3. Per accept: open the forwarded channel ([`ForwardedOpener`] —
/// 3. Per accept: open the forwarded channel ([`ForwardedOpener`],
/// the call plane, `{listen: token, substrate, peer}` params),
/// then `pump_bidi` the accepted halves against the adopted
/// channel inside a spawned task tracked in the handler's handle
/// set. A forwarded-open failure is per-accept isolated: log and
/// drop that accepted connection — the loop continues (the
/// drop that accepted connection; the loop continues (the
/// listener's overall health is not hostage to one failed accept;
/// v1: no retry/backoff). A loop-critical accept error ([`BindFirstAcceptLoop`]'s
/// contract) ends the loop.
/// 4. **Lifetime (R-02):** the handler's returned `JoinHandle` tracks
/// the whole accept loop + every per-accept pump it spawned — the
/// the whole accept loop + every per-accept pump it spawned; the
/// handler task awaits the pump handles on loop exit (a forwarded
/// pump outliving its listener is a leak). Handler exit → wrapper
/// teardown (the listen channel reaps) → the far listener tears
/// down (the plan's listener drops with the loop).
///
/// The substrate discriminator for the forwarded opens rides the
/// plan ([`BindFirstPlan`].substrate — stamped at establisher time
/// plan ([`BindFirstPlan`].substrate, stamped at establisher time
/// from the one successful `TunnelParams` parse; the listen tunnel's
/// substrate; ADR-008 §3 "the listen tunnel's substrate shapes the
/// forwarded channel's data-plane framing"). The dispatcher never
/// re-parses wire input — no fallback branch exists.
/// re-parses wire input: no fallback branch exists.
pub fn make_bindfirst_accept_handler(
accept_loop: BindFirstAcceptLoop,
open_forwarded: ForwardedOpener,
@@ -784,14 +784,14 @@ pub fn make_bindfirst_accept_handler(
return;
};
// The substrate rides the plan (stamped at establisher
// time — one parse, one source of truth): the
// time, one parse, one source of truth): the
// dispatcher must not re-parse wire input (a parse
// failure here would have nowhere loud to go; review
// 002 M-1 removed the silent `Tcp` fallback instead).
let substrate = plan.substrate;
let (mut idle_read, _idle_write) = tokio::io::split(bidi);
// The per-accept pump task set — tracked by the
// The per-accept pump task set, tracked by the
// handler's JoinHandle (R-02: every pump the handler
// spawned is awaited on loop exit).
let mut pumps: Vec<tokio::task::JoinHandle<(u64, u64)>> = Vec::new();
@@ -799,15 +799,15 @@ pub fn make_bindfirst_accept_handler(
let loop_result = loop {
// The idle-stream EOF watch vs the accept loop
// (select!): the opener dropping the listener
// session EOFs the listen channel's stream — the
// zero-byte sentinel — and cancels the loop.
// session EOFs the listen channel's stream (the
// zero-byte sentinel) and cancels the loop.
let eof_watch = async {
// The 1-byte read on the idle stream: `Ok(0)`
// (or an error) = the opener dropped the
// session; `Ok(1..)` never happens on a well-
// behaved opener (the stream is idle — a peer
// behaved opener (the stream is idle: a peer
// that writes is misbehaving; treat any
// non-empty read as an EOF trigger too — the
// non-empty read as an EOF trigger too; the
// channel is not a data plane).
let mut byte = [0u8; 1];
match idle_read.read(&mut byte).await {
@@ -839,8 +839,13 @@ pub fn make_bindfirst_accept_handler(
// Per-accept isolation: the
// forwarded open failed (scope-
// less opener → FORBIDDEN; old
// opener → NOT_FOUND; correlation
// miss → handler_error-class) —
// opener → NOT_FOUND; a stale/
// missing token → the typed
// `unknown_resource` open_failed
// under the serving side's
// establisher variant, ADR-008
// Amendment 2; other establisher
// failures → handler_error-class):
// drop this accepted connection,
// keep serving. v1: no retry/
// backoff (documented posture).
@@ -856,7 +861,7 @@ pub fn make_bindfirst_accept_handler(
Err(e) => {
// Loop-critical (the listener's
// substrate source broke): end the
// loop — the listener tears down with
// loop; the listener tears down with
// the plan.
tracing::warn!(
listen = plan.token,
@@ -873,11 +878,11 @@ pub fn make_bindfirst_accept_handler(
reason = loop_result,
"tunnel bind-first: accept loop exited"
);
// The listener drops with the plan (the last Arc) — the
// The listener drops with the plan (the last Arc); the
// far listener tears down.
drop(plan);
// R-02: the handler's JoinHandle tracks every per-accept
// pump it spawned — await them on loop exit (a forwarded
// pump it spawned; await them on loop exit (a forwarded
// pump outliving its listener is a leak).
for pump in pumps.drain(..) {
let _ = pump.await;
@@ -889,16 +894,16 @@ pub fn make_bindfirst_accept_handler(
/// The bind-first registration's injected seams (the assembly layer
/// owns all four; bundling keeps the registration's signature to the
/// core pair + the bundle — the seams are one coherent injection
/// core pair + the bundle; the seams are one coherent injection
/// point, the same posture as `DialFn` riding the dial registration):
/// - `bind` — the substrate bind function ([`BindFn`]; the protocol
/// - `bind`: the substrate bind function ([`BindFn`]; the protocol
/// never binds, OQ-TN-04).
/// - `accept_loop` — the per-listener accept loop
/// - `accept_loop`: the per-listener accept loop
/// ([`BindFirstAcceptLoop`]; receives the opaque listener, yields
/// `(peer, halves)` per accept).
/// - `open_forwarded` — the forwarded-open caller ([`ForwardedOpener`];
/// - `open_forwarded`: the forwarded-open caller ([`ForwardedOpener`];
/// the call plane: the serving session's `Arc<CallConnection>` +
/// the producer-side `ChannelManager` for the adopt — the assembly
/// the producer-side `ChannelManager` for the adopt; the assembly
/// has both, it constructed the serving session; the reverse POC's
/// `open_reverse_channel` shape).
pub struct BindFirstInjections {
@@ -911,9 +916,9 @@ pub struct BindFirstInjections {
}
/// Register the bind-first openable (shape 3, ADR-008 §1): the SAME
/// spec + scope gate (`tunnel:open`) as the dial/listen variants — the
/// spec + scope gate (`tunnel:open`) as the dial/listen variants; the
/// bind-first shape is a per-registration establisher choice over the
/// same op (ADR-008 §5's per-registration posture) — with
/// same op (ADR-008 §5's per-registration posture), with
/// [`bindfirst_establisher`] as the establisher and
/// [`make_bindfirst_accept_handler`] as the pump. One establisher per
/// op id per registry (same note as
@@ -972,7 +977,7 @@ fn witness_establisher(
/// The correlation material the opener-side handler yields per
/// forwarded open: the parsed params + the channel's `BiStream`. The
/// ASSEMBLY correlates (token → its waiting listener session
/// bookkeeping) and owns the pump — it awaits the returned
/// bookkeeping) and owns the pump; it awaits the returned
/// `JoinHandle` inline (R-02: the forwarded channel's lifetime tracks
/// it; early return = teardown-at-birth reaps the channel), because
/// the local halves are assembly state (the SOCKS5 BIND client's
@@ -983,10 +988,11 @@ pub type ForwardedHandler =
Arc<dyn Fn(TunnelForwardedParams, BiStream) -> tokio::task::JoinHandle<()> + Send + Sync>;
/// The forwarded op's opener-side pump handler (ADR-008 §3, the
/// no-establisher serving shape): parse `TunnelForwardedParams`
/// (`deny_unknown_fields` + the positive-`listen` serde pin — a
/// serving-shape wrapper both registrations share): parse
/// `TunnelForwardedParams`
/// (`deny_unknown_fields` + the positive-`listen` serde pin: a
/// malformed open fails the parse LOUD; the wrapper then returns early
/// and the channel tears down at birth — no phantom channel, the
/// and the channel tears down at birth; no phantom channel, the
/// correct shape for a malformed ingress), accept the channel's
/// `BiStream` (yield-once; accepting BEFORE the assembly handler runs
/// keeps the R-02 accepted-flag telemetry truthful), and hand the
@@ -995,29 +1001,38 @@ pub type ForwardedHandler =
/// **Parse-before-accept ordering (the R-02 telemetry note):** the
/// params parse happens BEFORE `conn.accept_bi()`, so for a
/// malformed-params open the R-02 accepted-flag telemetry reads
/// `false` — by design. A malformed-params open IS a birth-teardown
/// `false`, by design. A malformed-params open IS a birth-teardown
/// shape (the exact case the telemetry's birth-teardown hint exists
/// for); accepting first would hold a stream open for a params set
/// the producer already got wrong. The hint firing here is correct,
/// not noise — future alkcall reviewers: do not flag the flag
/// not noise; future alkcall reviewers: do not flag the flag
/// (review 002 N-3).
///
/// **Pump responsibility split (the honest-shape decision):** the
/// injected handler receives the channel `BiStream` and owns the pump
/// — `pump_bidi(forwarded_bidi, local_read, local_write)` — because
/// (`pump_bidi(forwarded_bidi, local_read, local_write)`) because
/// the local halves are assembly state. The protocol-side built-in
/// handler only bridges wrapper→assembly; it awaits the injected
/// handler's `JoinHandle` inline (R-02 — the forwarded channel's
/// handler's `JoinHandle` inline (R-02: the forwarded channel's
/// lifetime tracks it, so a correlation miss or a dropped assembly
/// handler tears the channel down).
///
/// **Correlation-miss semantics (documented + test-pinned):** a
/// `listen` token with no matching waiting session is the ASSEMBLY's
/// rejection (it holds the map): its handler's error path drops the
/// stream and returns — teardown-at-birth reaps the forwarded channel.
/// The producer's accept loop drops the accepted connection when that
/// open fails (the shapes mirror — per-accept isolation,
/// `tunnels/bindfirst-producer`'s posture).
/// **Correlation-miss semantics (two-shape posture, ADR-008 Amendment
/// 2 + test-pinned):** under the BASE shape (this registration,
/// establisher-less), a `listen` token with no matching waiting
/// session succeeds at the call plane and the miss is the ASSEMBLY's
/// post-open rejection (it holds the map): its handler's error path
/// drops the stream and returns; teardown-at-birth reaps the
/// forwarded channel (`correlation-miss`, the assembled decline
/// event at `debug`). Under the ESTABLISHER variant
/// ([`register_tunnel_forwarded_openable_with_establisher`], the
/// recommended shape), a no-route token fails at the open as a typed
/// `channel:open_failed` before the channel is born; THIS handler's
/// post-open miss is then only the residual race (a token vanishing
/// between gate pass and handler run); teardown-at-birth remains the
/// fallback shape under both. The producer's accept loop drops the
/// accepted connection when that open fails (the shapes mirror:
/// per-accept isolation, `tunnels/bindfirst-producer`'s posture).
pub fn make_forwarded_op_handler(handler: ForwardedHandler) -> OpenHandler {
Arc::new(
move |input: Value, _plan: Option<ChannelPlan>, conn: Connection, _auth: AuthContext| {
@@ -1044,29 +1059,39 @@ pub fn make_forwarded_op_handler(handler: ForwardedHandler) -> OpenHandler {
)
}
/// Register the forwarded op's opener-side serving openable (ADR-008
/// §3): `ChannelCore::register_openable` — **no establisher** (the
/// forwarded
/// channel needs no establishment; its data plane starts immediately)
/// — scope-gated `tunnel:forwarded` (ingress, a separate grant from
/// `tunnel:open`/`tunnel:direct`, never implied by either — ADR-006
/// Amendment 1), via [`crate::params::tunnel_forwarded_spec`]. The
/// wrapper's
/// Register the forwarded op's opener-side serving openable, the
/// BASE shape (ADR-008 §3 as amended): `ChannelCore::register_openable`;
/// **no establisher** (the forwarded channel needs no establishment;
/// its data plane starts immediately), scope-gated `tunnel:forwarded`
/// (ingress, a separate grant from `tunnel:open`/`tunnel:direct`,
/// never implied by either, ADR-006 Amendment 1), via
/// [`crate::params::tunnel_forwarded_spec`]. The wrapper's
/// `channel_id` allocation IS the reply (the producer adopts it).
///
/// **Two-shape posture (ADR-008 Amendment 2):** this base shape stays
/// byte-for-byte compatible for assemblies that prefer the post-open
/// decline (and as the residual-race fallback posture; see
/// [`make_forwarded_op_handler`]'s correlation-miss block). The
/// ESTABLISHER variant
/// ([`register_tunnel_forwarded_openable_with_establisher`]) is the
/// recommended shape: a stale/missing token fails the open as a typed
/// `channel:open_failed` before the channel is born, instead of
/// succeeding at the call plane and declining post-open. Both ride
/// the same op id, params, scope, and reply surface.
///
/// Identity (CF-005 precedence, upstream machinery): the opener-side
/// gate sees the PRODUCER's per-call identity (the producer may carry
/// an `auth_token` when it is itself forwarding); the `peer` param is
/// producer-asserted metadata, not transport truth — the ACL trusts
/// producer-asserted metadata, not transport truth: the ACL trusts
/// the grant, the application treats `peer` as metadata. Nothing to
/// implement here (upstream's per-call overlay handles it); the test
/// suite pins it.
///
/// Post-hoc registration is supported (W2) — the dispatcher reads
/// Post-hoc registration is supported (W2): the dispatcher reads
/// through the shared `Arc<OperationRegistry>` per dispatch, the same
/// posture as [`register_tunnel_openable`]. One registration per op id
/// per registry; the op id is `channels/tunnel/forwarded` (distinct
/// from the base op — a session may serve both).
/// from the base op; a session may serve both).
pub fn register_tunnel_forwarded_openable(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
@@ -1081,13 +1106,131 @@ pub fn register_tunnel_forwarded_openable(
)
}
/// The forwarded op's establisher plan payload (ADR-008 Amendment 2):
/// an empty marker: a gate pass establishes nothing (the correlation
/// map is assembly state and the handler needs nothing it does not
/// re-derive), the same typed-opaque [`ChannelPlan`] posture as ADR-049
/// amendment 2 with the payload choice pinned at "marker". Its `Some`
/// presence in the handler's `plan` parameter signals the gate passed;
/// the handler is free to ignore it.
pub struct ForwardedPlan;
/// The injected correlation gate (ADR-008 Amendment 2, plan 003 W1):
/// the assembly's one-shot correlation check for the forwarded op's
/// serving half. `Err` at the gate → a typed `channel:open_failed`
/// BEFORE the channel is born (no phantom channel; ADR-049's posture
/// extended to the forwarded op's serving side).
///
/// The gate receives the PARSED [`TunnelForwardedParams`] and runs
/// synchronously; it must be an in-memory map lookup (an await there
/// would extend the open's call-plane latency; the lookup shape is
/// what every assembly's correlation registry provides).
///
/// **Gate error convention (the pin):** a stale/missing `listen`
/// token SHOULD return [`TunnelEstablishError::UnknownResource`]:
/// the waiting session IS a resource that is gone, and that maps to
/// `details.reason == "unknown_resource"` on the wire (the canonical
/// reason ADR-008 Amendment 2 pins; the wire vocabulary is NOT
/// extended). The crate passes through whatever the gate returns;
/// the gate owns its rejection vocabulary, within the existing
/// [`TunnelEstablishError`] table.
pub type ForwardedGate =
Arc<dyn Fn(&TunnelForwardedParams) -> Result<(), TunnelEstablishError> + Send + Sync>;
/// The gate establisher (ADR-008 Amendment 2: the correlation gate
/// wrapped into the alkcall [`OpenEstablisher`] dance; assemblies do
/// not hand-roll the plumbing, the crate-owned registration surface
/// below is the only supported path).
///
/// **Order (pinned, ADR-008 Amendment 2 decision 3):** the registry
/// runs the `tunnel:forwarded` ACL BEFORE the establishment phase
/// (alkcall's open-op wrapper order: schema → ACL → allocate →
/// establisher → handler), so the gate never sees an unauthorized
/// open; a scope-less producer is `FORBIDDEN` before any gate
/// lookup. The establisher then parses the input and runs the gate;
/// the base shape's handler-side parse stays (see below), so
/// `deny_unknown_fields` + the positive-`listen` serde pin gate the
/// ingress at BOTH layers.
///
/// Error mapping (pinned):
/// - Establisher parse failure →
/// [`TunnelEstablishError::HandlerError`] → the existing
/// `From<TunnelEstablishError> for EstablishmentError` table →
/// `channel:open_failed` with `details.reason == "handler_error"`.
/// This is deliberate: a malformed ingress becomes a typed
/// call-plane failure instead of the base shape's silent
/// birth-teardown (an improvement ADR-008 Amendment 2 pins; do
/// not drift it).
/// - Gate `Err(TunnelEstablishError)` → the same existing table, 1:1
/// (a stale token: `UnknownResource` → `details.reason ==
/// "unknown_resource"`). No new variants are minted.
/// - A gate pass produces NO resource handle: the plan payload is the
/// empty [`ForwardedPlan`] marker, and there is NO reply-field
/// contribution (the op's reply stays the wrapper's reserved
/// `channel_id`).
///
/// `timeout: None` (the alkcall 10s default, as the other
/// registrations); the gate is an in-memory lookup; the bound is
/// belt-and-braces, not workload.
fn make_forwarded_gate_establisher(gate: ForwardedGate) -> OpenEstablisher {
Arc::new(move |input: Value, _auth: AuthContext| {
let gate = Arc::clone(&gate);
Box::pin(async move {
let params: TunnelForwardedParams = serde_json::from_value(input.clone())
.map_err(|e| TunnelEstablishError::HandlerError(format!("invalid params: {e}")))?;
gate(&params)?;
Ok(Establishment::new(Arc::new(ForwardedPlan) as ChannelPlan))
})
})
}
/// Register the forwarded op's opener-side serving openable WITH the
/// correlation-gate establisher (ADR-008 Amendment 2, the
/// recommended shape): `ChannelCore::register_openable_with_establisher`
/// with the crate-owned gate establisher (private
/// `make_forwarded_gate_establisher`) wrapping the injected
/// [`ForwardedGate`]. A stale/missing `listen` token fails the open
/// as a typed `channel:open_failed` BEFORE the channel's data plane
/// is served; the phantom-channel shape (succeed at the call plane,
/// decline post-open, pump EOFs silently) is closed at the open.
///
/// The same op, params, scope (`tunnel:forwarded`, run before the
/// establisher; see the
/// `make_forwarded_gate_establisher` doc) and reply
/// surface as [`register_tunnel_forwarded_openable`] ride; the wire
/// delta of Amendment 2 is empty (additive registration only).
///
/// The base shape (establisher-less) stays for compatibility + as the
/// residual-race fallback posture: a token that vanishes between
/// gate pass and handler run is post-open; the handler's
/// teardown-at-birth semantics (see [`make_forwarded_op_handler`]'s
/// correlation-miss block) are the only thing that can reap it, the
/// same under both shapes. New assemblies take this shape by default
/// (ADR-008 Amendment 2 §Additive-only surface).
pub fn register_tunnel_forwarded_openable_with_establisher(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
gate: ForwardedGate,
handler: ForwardedHandler,
) -> Result<(), String> {
core.register_openable_with_establisher(
crate::params::tunnel_forwarded_spec(),
Some(make_forwarded_gate_establisher(gate)),
make_forwarded_op_handler(handler),
on_registry,
auth,
None,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The plan-payload contract: `BindFirstPlan` rides the
/// typed-opaque `ChannelPlan` (`Arc<dyn Any + Send + Sync>`) and
/// the accept dispatcher recovers it by downcast — the same
/// the accept dispatcher recovers it by downcast: the same
/// establisher/handler agreement `TargetHandle` pins. The opaque
/// listener half downcasts to the assembly's concrete type.
#[test]
@@ -1125,7 +1268,7 @@ mod tests {
/// The mint discipline: the counter seeds at 1, the first mint is
/// 1, the stream is per-open fresh + per-producer unique, and
/// token 0 is never minted (the loop skips it — a wrap that lands
/// token 0 is never minted (the loop skips it: a wrap that lands
/// on 0 re-increments).
#[test]
fn mint_token_seeds_nonzero_and_increments() {
@@ -1142,7 +1285,7 @@ mod tests {
/// re-increments (the loop skips the illegal wire value).
#[test]
fn mint_token_never_yields_zero() {
// Force the wrap shape: seed the counter at u32::MAX — the
// Force the wrap shape: seed the counter at u32::MAX; the
// next fetch_add returns u32::MAX (legal), then wraps to 0,
// which the loop must skip (the mint after the wrap is 1).
let counter = std::sync::atomic::AtomicU32::new(u32::MAX);
@@ -1250,7 +1393,7 @@ mod tests {
assert_ne!(t1, t2, "two opens mint distinct tokens");
}
/// The registry miss → `unknown_resource` (the namespace gate —
/// The registry miss → `unknown_resource` (the namespace gate,
/// the same posture as the listen establisher).
#[tokio::test]
async fn bindfirst_establisher_registry_miss_is_unknown_resource() {
+13 -13
View File
@@ -4,14 +4,14 @@
//! carries its BAST contract at `docs/architecture/bast.md`.
//!
//! The channels layer already length-prefixes every chunk (8-byte
//! header), so stream substrates need zero tunnel-level framing — the
//! header), so stream substrates need zero tunnel-level framing: the
//! halves are the tunnel, and a zero-length read is genuinely EOF
//! (stream semantics). UDP needs boundary re-framing only: datagram
//! boundaries do not survive the chunk stream, and `u16` suffices
//! (UDP's max payload 65507 < 65535).
//!
//! Sentinel layering (the F-2 invariant, load-bearing): `len == 0` is
//! a legal *empty datagram* — a real datagram, never an EOF signal.
//! a legal *empty datagram* (a real datagram, never an EOF signal).
//! EOF is the channels-level sentinel on the `BiStream` and lives at
//! a different layer; the codec never emits zero-length reads, so the
//! layers never collide. This is why raw pass-through is structurally
@@ -20,31 +20,31 @@
//!
//! No chunk-type byte, no 5-byte header: a tunnel has exactly one data
//! stream per direction (the channel's read/write halves), so there is
//! no sub-demux key — the structural difference from alktty's 5-byte
//! no sub-demux key: the structural difference from alktty's 5-byte
//! `[stream_type][len]` header.
use bytes::{Buf, BufMut, Bytes, BytesMut};
use thiserror::Error;
/// The u16 length-field bound: datagrams larger than this are
/// rejected at frame time (ADR-003 — never a wire overflow).
/// rejected at frame time (ADR-003; never a wire overflow).
pub const MAX_DATAGRAM_LEN: usize = u16::MAX as usize;
const DATAGRAM_LEN_FIELD: usize = 2;
/// Codec errors: oversize at frame time. The truncated-stream case is
/// not a codec error — the decoder buffers partial frames indefinitely
/// not a codec error: the decoder buffers partial frames indefinitely
/// (`is_mid_datagram` is the diagnostic) and the SESSION surfaces a
/// stream end mid-datagram as `TunnelIoError::TruncatedDatagram` (the
/// fail-loud posture, OQ-TN-13 — never a silent partial datagram).
/// fail-loud posture, OQ-TN-13: never a silent partial datagram).
///
/// The decode direction (`DatagramReader::feed`) is infallible: with
/// the oversize bound enforced at frame time, a declared length can
/// never exceed what the frame itself carries, so the incremental
/// decode cannot fail — only buffer and complete.
/// decode cannot fail, only buffer and complete.
#[derive(Debug, Error)]
pub enum DatagramCodecError {
/// A datagram larger than [`MAX_DATAGRAM_LEN`] was framed — the
/// A datagram larger than [`MAX_DATAGRAM_LEN`] was framed; the
/// u16 length field would wrap. Rejected at frame time.
#[error("datagram too large for u16 length prefix: {0} bytes (max {1})")]
Oversize(usize, usize),
@@ -68,7 +68,7 @@ pub fn frame_datagram(payload: &[u8]) -> Result<Bytes, DatagramCodecError> {
/// Incremental datagram decoder over the channel's read half. Feed it
/// whatever `read()` yields; it reassembles `[len: u16 BE][payload]`
/// frames across chunk boundaries — a datagram may be split across
/// frames across chunk boundaries: a datagram may be split across
/// two chunks, two datagrams may share one chunk, and partial headers
/// wait for more bytes. This is the only consumer-visible decode path
/// (ADR-003; chunk splitting/batching is transparent).
@@ -93,10 +93,10 @@ impl DatagramReader {
}
/// Feed raw bytes from the read half. Returns every datagram that
/// completed with this feed, in order — zero or more per chunk.
/// completed with this feed, in order (zero or more per chunk).
/// Buffers partial frames across chunk boundaries. Infallible: the
/// decode direction cannot fail (see the [`DatagramCodecError`]
/// docs — the oversize bound is enforced at frame time).
/// docs: the oversize bound is enforced at frame time).
pub fn feed(&mut self, chunk: &[u8]) -> Vec<Bytes> {
self.buf.extend_from_slice(chunk);
let mut out = Vec::new();
@@ -134,7 +134,7 @@ impl DatagramReader {
}
/// `true` while a partial datagram is in flight (for teardown
/// diagnostics — a clean peer never ends the stream mid-datagram;
/// diagnostics: a clean peer never ends the stream mid-datagram;
/// an end while this is set is the session's
/// `TruncatedDatagram` condition).
pub fn is_mid_datagram(&self) -> bool {
@@ -144,7 +144,7 @@ impl DatagramReader {
/// Bytes currently retained by the decoder state: the leftover
/// buffer plus the in-flight partial datagram's accumulated
/// payload. Post-feed this is bounded by one frame
/// (≤ [`MAX_DATAGRAM_LEN`] + 2) — the decoder never grows its
/// (≤ [`MAX_DATAGRAM_LEN`] + 2); the decoder never grows its
/// state with fed bytes beyond the frame in progress (pinned by
/// the fuzz harness, `docs/research/fuzzing.md` §3.1).
pub fn retained_bytes(&self) -> usize {
+10 -10
View File
@@ -16,26 +16,26 @@ Phase 1 promoted the Phase 0 OQ ledger into `docs/architecture/open-questions.md
(OQ-TN-01..14) and split OQ-TN-11/13 into resolved halves (ADR-001/ADR-003).
The older ledgers still describe pre-Phase-1 states in two places:
1. `docs/research/phase-0-findings.md` — the OQ sections (OQ-TN-01..10) should
1. `docs/research/phase-0-findings.md`: the OQ sections (OQ-TN-01..10) should
carry one-line "promoted to `docs/architecture/open-questions.md` as OQ-TN-NN"
pointers so a reader landing in the research doc finds the live tracker (the
convergence checklist's last unchecked item says "promoted ... with statuses" —
the promotion exists; the back-pointer is the missing half).
2. `AGENTS.md` — the "Architecture Context" section says `docs/architecture/`
convergence checklist's last unchecked item says "promoted ... with statuses";
the promotion exists, and the back-pointer is the missing half).
2. `AGENTS.md`: the "Architecture Context" section says `docs/architecture/`
"will hold the authoritative spec (Phase 1 of the SDD process)... It does not
exist yet". That is now stale: the spec exists (ADR-001..006 + 4 spec docs +
BAST + OQ tracker). Update the paragraph to point at the real directory and
adjust the "read it before non-trivial changes once it exists" line. Three
adjacent stale references in the same file fold in (same staleness class,
one pass — found during task review 2026-09-08):
one pass; found during task review 2026-09-08):
- Convention 16 (module structure): "expected shape (to be finalized in
the architecture docs)" — the docs now exist and pin the module shape;
the architecture docs)"; the docs now exist and pin the module shape;
state it as decided (producer half / consumer half / shared wire).
- Convention 11: "alkcall is v0.5.x" — Cargo.toml pins `0.7.0` and the
- Convention 11: "alkcall is v0.5.x". Cargo.toml pins `0.7.0`, and the
convention's own later text describes the 0.6.0/0.7.0 surfaces; the
version sentence is self-contradicting.
- Architecture Context alkcall bullet: "alkcall (v0.4.x, crates.io)" —
same version drift.
- Architecture Context alkcall bullet: "alkcall (v0.4.x, crates.io)",
the same version drift.
## Work
@@ -73,7 +73,7 @@ The older ledgers still describe pre-Phase-1 states in two places:
## Notes
- Executed together with `architecture/oq-tn-12-tracker` creation
(process-gap fix found during task review — OQ-TN-12's Blocked-on text
(process-gap fix found during task review; OQ-TN-12's Blocked-on text
now references its tracker task id per `sdd_process.md` §Deferred OQs
and the blocker-task half).
- While rewriting the Architecture Context bullet, a fourth same-class
+4 -3
View File
@@ -13,7 +13,7 @@ tags: [architecture, external-trigger, deferred-oq, hub, producer, consumer]
## Description
OQ-TN-12 is `deferred(scope)` (open-questions.md): the hub re-produce
composition helper — whether a crate that re-produces a tunnel resource it
composition helper: whether a crate that re-produces a tunnel resource it
consumes (a hub proxy: consumer + `register_openable` on the same
connection) needs a composition helper from this crate, or assembles from
the public producer/consumer surface as-is. ADR-004 resolved the substrate
@@ -25,7 +25,8 @@ This task is the machine-readable half of the deferral (per
whether the unblocking condition has arrived. It is not implementation
work and blocks nothing.
The unblocking condition: a concrete hub-proxy consumer — a crate that
The unblocking condition: a concrete hub-proxy consumer, i.e. a
crate that
re-produces a consumed tunnel resource (the reverse POC's hub is the seed
shape minus re-export: consumer + `register_openable` on the same
connection, without exercising re-export).
@@ -39,7 +40,7 @@ connection, without exercising re-export).
## Verification
- [ ] Tracked (this file existing and being reviewed at triage time IS
the tracking mechanism — no code or doc change is expected unless
the tracking mechanism; no code or doc change is expected unless
the unblocking condition above arrives)
## Out of scope
+5 -5
View File
@@ -14,21 +14,21 @@ tags: [architecture, external-trigger, unix, stdio, alktty]
OQ-TN-14 is resolved (open-questions.md, 2026-09-07): unix ships with
the `local` feature v1 (`tunnels/local-socket-halves`); stdio is OUT of
scope — a spawned process's stdio is alktty's pipe mode (exit codes +
scope. A spawned process's stdio is alktty's pipe mode (exit codes +
signals are tty semantics, not tunnel semantics); remote command
execution composes via alktty.
This task stays open as the boundary-maintenance tracker (research
level — not implementation work). It re-opens the question only if a
level, not implementation work). It re-opens the question only if a
concrete consumer demonstrates the resolved posture is wrong:
1. A consumer needs stdio tunneling with NO process semantics (no exit
code, no signals) — e.g. piping an already-running long-lived
code, no signals), e.g. piping an already-running long-lived
process's existing stdio. That is a different resource shape (the
pipe IS the resource, no dial); it would need its own ADR, not a
silent addition to `local`.
2. A consumer reports confusion about when to use alktunnels vs alktty
for a stdio-like flow — the composition boundary in OQ-TN-14 needs
for a stdio-like flow; the composition boundary in OQ-TN-14 needs
sharpening in the docs, not code.
If neither arrives, this task closes with the implementation phase.
@@ -42,7 +42,7 @@ If neither arrives, this task closes with the implementation phase.
## Verification
- [ ] Tracked (this file existing and being reviewed at triage time IS
the tracking mechanism — no code or doc change is expected unless
the tracking mechanism; no code or doc change is expected unless
a boundary condition above arrives)
## Out of scope
+51 -48
View File
@@ -1,6 +1,6 @@
---
id: tunnels/bindfirst-params
name: Bind-first params — ListenParams, the forwarded-op spec, the bind-first plan type (ADR-008 as amended)
name: Bind-first params; ListenParams, the forwarded-op spec, the bind-first plan type (ADR-008 as amended)
status: completed
depends_on: []
scope: narrow
@@ -13,14 +13,14 @@ tags: [wire, params, bind-first, forwarded-op, correlation-token]
## Description
Implement the bind-first wire surface's params half (ADR-008, as
amended 2026-09-18 — Amendment 1): the listen-open reply fields
amended 2026-09-18, Amendment 1): the listen-open reply fields
(`bound` + `listen`), the forwarded-op params + spec, and the
bind-first plan payload type. Split from the establisher/pump task
(`tunnels/bindfirst-producer`) so the wire-stable types land and get
pinned first — the same ordering `tunnels/params` used for the base
pinned first; the same ordering `tunnels/params` used for the base
op.
### params.rs — constants + types
### params.rs: constants + types
```rust
pub const OP_TUNNEL_FORWARDED: &str = "channels/tunnel/forwarded";
@@ -50,28 +50,28 @@ pub struct TunnelForwardedParams {
pub fn tunnel_forwarded_spec() -> OperationSpec
```
- **The correlation token (Amendment 1):** `listen` (u32) — minted by
the bind-first establisher, returned in the reply as the `listen`
field, stamped into each forwarded open's `listen` param. NOT the
listen channel's ID (the pre-amendment key is superseded — the
- **The correlation token (Amendment 1):** `listen` (u32), minted by
the bind-first establisher and returned in the reply as the
`listen` field, stamped into each forwarded open's `listen` param.
NOT the listen channel's ID (the pre-amendment key is superseded: the
serving-side handler never sees its own channel ID, and generic
relays don't rewrite params; ADR-008 Amendment 1 has the full
rationale). `SubstrateAddr` is defined by `tunnels/direct-op` —
rationale). `SubstrateAddr` is defined by `tunnels/direct-op`, the
same value shape (`{host, port}` / `{path}`); if that task has not
landed, define it here and let `tunnels/direct-op` consume it
(either order works; the type lands once).
- `tunnel_forwarded_spec()` per wire.md §The Forwarded Op:
`Sub`-typed, `Visibility::External`, `ChannelOpenSpec::new(TUNNEL_ALPN)`,
scope `["tunnel:forwarded"]` (ADR-006 Amendment 1 — ingress, a
scope `["tunnel:forwarded"]` (ADR-006 Amendment 1: ingress, a
separate grant), input schema pinning all three fields required
(`listen` integer > 0; `peer` an object with the per-substrate
shape), output schema `{channel_id: integer > 0}`, description
carried. **No establisher** on the forwarded op — the data plane
starts immediately (ADR-008 §3).
- `TunnelListenReply` is the consumer-side reader (the wire itself is
plain JSON reply fields — additive, unknown fields ignored by old
consumers; the struct is the typed view, not a deny-unknown wire
gate on the reply — NOTE: the reply arrives as
carried. **No establisher** on the forwarded op, because the data
plane starts immediately (ADR-008 §3).
- `TunnelListenReply` is the consumer-side reader (the wire itself
is plain JSON reply fields: additive, with unknown fields ignored
by old consumers; the struct is the typed view, not a deny-unknown
wire gate on the reply). NOTE: the reply arrives as
`{channel_id, bound?, listen?}`; parse it with a dedicated
reply-viewing function that extracts the optional fields from the
full output Value, not with `deny_unknown_fields` deserialization
@@ -84,29 +84,30 @@ pub fn tunnel_forwarded_spec() -> OperationSpec
/// The bind-first establisher's plan payload: the listener handle
/// (typed-opaque to the pump via downcast) + the correlation token
/// the accept dispatcher stamps into forwarded opens (ADR-008
/// Amendment 1 — the handler cannot learn its own channel ID, so the
/// Amendment 1: the handler cannot learn its own channel ID, so the
/// establisher-minted token is the correlation key).
pub struct BindFirstPlan {
pub listener: TargetHandle, // the listener's halves? — NO: see the shape note
pub listener: TargetHandle, // the listener's halves? NO; see the shape note
pub token: u32,
}
```
- **Shape note (decide during implementation, document the choice):**
the listener is NOT `TargetHandle` halves — a listener is accepted
FROM, not pumped. The plan carries whatever the assembly's `BindFn`
produced (opaque to the protocol), plus the token. Honest shape:
`pub struct BindFirstPlan { pub listener: Arc<dyn Any + Send + Sync>, pub token: u32 }`
— the accept dispatcher downcasts the `Any` to the assembly's
the listener is NOT `TargetHandle` halves, since a listener is
accepted FROM, not pumped. The plan carries whatever the assembly's
`BindFn` produced (opaque to the protocol), plus the token. Honest
shape:
`pub struct BindFirstPlan { pub listener: Arc<dyn Any + Send + Sync>, pub token: u32 }`.
The accept dispatcher downcasts the `Any` to the assembly's
listener type via a type-erased accept closure (the same injection
pattern as `DialFn`/`AcceptFn`). The ADR's "listener handle" in the
plan is assembly-typed; the protocol sees `BindFirstPlan { inner,
token }`.
- The token mint: `std::sync::atomic::AtomicU32` counter seeded
non-zero (e.g. 1) inside the establisher closure state; wraps are
acceptable (u32, per-producer uniqueness is all that's required —
ADR-008 Amendment 1's token-space note; a wrap collision requires
2^32 concurrent opens on one producer).
acceptable (u32; per-producer uniqueness is all that's required,
per ADR-008 Amendment 1's token-space note; a wrap collision
requires 2^32 concurrent opens on one producer).
- Wire-stability note for the implementer: `listen`'s u32 type and
field name, the forwarded params field names, and the op id are
one-way doors (ADR-008 Consequences). `deny_unknown_fields` on
@@ -120,7 +121,7 @@ pub struct BindFirstPlan {
- `TunnelListenReply` projected parse: full reply
`{channel_id: 7, bound: {...}, listen: 918}` → both fields; bare
`{channel_id: 7}` → both `None` (accept-first/dial shape); unknown
extra reply fields tolerated (the additive posture — the reply
extra reply fields tolerated (the additive posture; the reply
viewer must not deny unknown fields).
- Spec-shape conformance: op id, Sub type, scope, schema structure
(all three required; peer object), ALPN marker, description.
@@ -141,45 +142,46 @@ pub struct BindFirstPlan {
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§2 as amended — the `listen` reply field; §3 as amended — the
forwarded params; Amendment 1 — the token rationale)
(§2 as amended, the `listen` reply field; §3 as amended, the
forwarded params; Amendment 1, the token rationale)
- docs/architecture/wire.md §The Listen Open Op's Reply +
§The Forwarded Op
- tasks/tunnels/params.md (the ordering precedent: wire types first)
- tasks/tunnels/direct-op.md (`SubstrateAddr` — shared type)
- tasks/tunnels/direct-op.md (`SubstrateAddr`, the shared type)
## Notes
- `SubstrateAddr` had already landed (via `tunnels/direct-op`) —
consumed, not redefined (the "either order works" branch resolved
to the consuming side).
- `SubstrateAddr` had already landed (via `tunnels/direct-op`), so
it is consumed, not redefined (the "either order works" branch
resolved to the consuming side).
- The reply viewer is `parse_listen_reply(&Value) ->
Result<TunnelListenReply, TunnelError>`: parses the FULL success
output (the `open_channel_with_reply` shape), tolerates unknown
extra reply fields (the additive posture — no deny-unknown gate on
the reply), requires a positive `channel_id` (its absence/zero =
not an open reply — loud), and fails loud on malformed `bound`
(not substrate-shaped) or `listen` (not a positive u32) via the
new `TunnelError::MalformedReply` variant (error.rs). `null`
fields parse as `None` (defensive; a well-formed reply omits them).
extra reply fields (the additive posture: no deny-unknown gate on
the reply), requires a positive `channel_id` (its absence/zero is
not an open reply and fails loud), and fails loud on malformed
`bound` (not substrate-shaped) or `listen` (not a positive u32)
via the new `TunnelError::MalformedReply` variant (error.rs).
`null` fields parse as `None` (defensive; a well-formed reply
omits them).
- `TunnelForwardedParams.listen` carries a serde-level
`positive_u32` pin behind the schema's `minimum: 1` (the task's
"listen: 0 rejected (schema minimum 1 + serde-level pin)" — both
"listen: 0 rejected (schema minimum 1 + serde-level pin)": both
gates, test-pinned).
- The `peer` sub-schema follows the direct op's if/then precedent
(tcp/udp → `{host, port}` with `additionalProperties: false` +
`maximum: 65535`; unix → `{path}`), structural assertions per the
no-jsonschema-dep precedent. The untagged-peer lenient-extra-fields
asymmetry carries over from the direct op (schema = the shape gate;
serde = the backstop) — documented in the test.
serde = the backstop), documented in the test.
- `BindFirstPlan` lives in `producer.rs` (the plan-payload family,
next to `TargetHandle`): `{ listener: Arc<dyn Any + Send + Sync>,
token: u32 }` per the honest shape — the listener is NOT
token: u32 }` per the honest shape, with the listener NOT
`TargetHandle` halves (a listener is accepted FROM, not pumped);
the accept dispatcher downcasts via a type-erased accept closure.
The token-mint discipline (AtomicU32 seeded non-zero, per-open
fresh, per-producer unique, wraps acceptable) is pinned in the
type's doc — the establisher that implements it lands with
type's doc; the establisher that implements it lands with
`tunnels/bindfirst-producer`.
- Custom `Debug` impls (matching `TargetHandle`'s precedent): the
listener renders `<opaque>`, the token renders plainly.
@@ -194,17 +196,18 @@ pub struct BindFirstPlan {
## Summary
Implemented the bind-first wire surface's params half per ADR-008 as
amended: `params.rs` — `OP_TUNNEL_FORWARDED` / `TUNNEL_FORWARDED_
SCOPE` constants; `TunnelForwardedParams {listen, substrate, peer}`
amended: `params.rs` gained the `OP_TUNNEL_FORWARDED` /
`TUNNEL_FORWARDED_SCOPE` constants; `TunnelForwardedParams {listen,
substrate, peer}`
(deny-unknown, `listen` positive via schema `minimum: 1` + the serde
pin); `tunnel_forwarded_spec()` (`Sub`-typed, External,
`ChannelOpenSpec::new(TUNNEL_ALPN)`, scope `["tunnel:forwarded"]`,
per-substrate if/then `peer` sub-schema, output
`{channel_id: integer > 0}`, description, no establisher);
`TunnelListenReply` + `parse_listen_reply` (the projected reply
viewer — unknown extra fields tolerated, `channel_id` presence
viewer: unknown extra fields tolerated, `channel_id` presence
required, malformed fields fail loud via `TunnelError::
MalformedReply`). `producer.rs` — `BindFirstPlan {listener:
MalformedReply`). `producer.rs` defines `BindFirstPlan {listener:
Arc<dyn Any + Send + Sync>, token: u32}` with the mint discipline
pinned in docs (non-zero-seeded AtomicU32, per-open fresh,
per-producer unique, wraps acceptable). Re-exported through
@@ -214,5 +217,5 @@ types land with `tunnels/bindfirst-producer`.
Verified: `cargo test` green (105 default; 125 with `--all-
features`), clippy `--all-targets -D warnings` clean (native +
wasm32), fmt clean, `wasm32-unknown-unknown` check passes (pure
protocol types — the default crate stays wasm-clean), `cargo doc
protocol types, so the default crate stays wasm-clean), `cargo doc
--no-deps` clean. CHANGELOG updated (Unreleased → Added).
+52 -52
View File
@@ -13,16 +13,16 @@ tags: [producer, establisher, bind-first, accept-dispatcher, forwarded-op]
## Description
Implement the bind-first establisher (producer.md §The Establisher,
shape 3 — ADR-008 §1): the establisher binds (via the injected
shape 3; ADR-008 §1): the establisher binds (via the injected
`BindFn`), mints the correlation token, contributes `bound` + `listen`
to the reply, and returns the plan; the pump handler is the **accept
dispatcher** — it accepts the channel's `BiStream`, runs the
dispatcher**: it accepts the channel's `BiStream`, runs the
assembly-provided accept loop, and per accept opens
`channels/tunnel/forwarded` back toward the listen opener and pumps
the accepted halves against the adopted channel. Plus
`register_tunnel_bindfirst_openable` wiring it.
### The establisher (shape 3 — bind-first)
### The establisher (shape 3, bind-first)
```rust
pub type BindFn = Arc<
@@ -45,7 +45,7 @@ pub fn bindfirst_establisher(
```
- Flow: parse `TunnelParams` → registry namespace gate
(`unknown_resource` on miss — same posture as
(`unknown_resource` on miss, the same posture as
`listen_establisher`) → mint the correlation token
(per-open-fresh, per-producer-unique; `tunnels/bindfirst-params`)
→ `bind(substrate, backing)` → contribute reply fields
@@ -68,51 +68,51 @@ pub fn make_bindfirst_accept_handler(
) -> OpenHandler
```
- **Injection seams (the design pin — decide the exact closure
- **Injection seams (the design pin: decide the exact closure
signatures during implementation, document in Notes):** the
dispatcher needs two assembly-provided capabilities the protocol
cannot own:
1. **The accept loop** — pops accepted connections from the
1. **The accept loop**, which pops accepted connections from the
`BindFirstPlan`'s opaque listener and yields
`(SubstrateAddr /* peer */, TargetHandle /* accepted halves */)`.
Substrate code lives behind the closure (ADR-004).
2. **The forwarded-open caller** — opens `OP_TUNNEL_FORWARDED`
2. **The forwarded-open caller**: opens `OP_TUNNEL_FORWARDED`
with `TunnelForwardedParams { listen: token, substrate, peer }`
on the channel-0 call plane toward the listen opener, adopts the
opener-allocated ID (`adopt_channel` on the producer-side
manager), and returns the streams. **This needs the call
plane**: the per-channel `Connection` the handler receives is
stream-less (`open_bi` is `StreamClosed` — ADR-035) and carries
stream-less (`open_bi` is `StreamClosed`; ADR-035) and carries
no call surface, so the registration must close over the
serving session's `Arc<CallConnection>` (the reverse POC's
`open_reverse_channel` shape: `call_with_payload` on channel 0 →
extract `channel_id` from the reply → `manager.adopt_channel`).
The registration function takes the call-plane handle (the
assembly has it — it constructed the serving session); document
assembly has it: it constructed the serving session); document
the wiring.
- Handler contract (producer.md §The Pump Handler item 5 + R-02):
`conn.accept_bi()` FIRST (the yield-once contract — the wrapper's
`conn.accept_bi()` FIRST (the yield-once contract; the wrapper's
accepted-flag telemetry requires it), then run the accept loop
concurrently with holding the channel's stream. The listen
channel's own `BiStream` stays idle (no pumps on it); **its EOF
cancels the loop and tears the listener down** — `select!` on the
cancels the loop and tears the listener down** via `select!` on the
idle stream's EOF vs the accept loop (read from the stream and
observe EOF; the zero-byte sentinel is the opener dropping the
listener session). Per-accept data planes are `pump_bidi`
(convention 8 — never a hand-rolled loop over one stream): each
(convention 8, never a hand-rolled loop over one stream): each
accepted handle pumps against its adopted forwarded channel inside
a spawned task tracked by the handler's JoinHandle set.
- **Lifetime (R-02):** the handler's returned JoinHandle tracks the
whole accept loop + every per-accept pump it spawned (collect the
pump handles; the handler task awaits them on loop exit — a
pump handles; the handler task awaits them on loop exit, since a
forwarded pump outliving its listener is a leak). Handler exit →
wrapper teardown (the listen channel reaps) → the far listener
tears down (the `BindFn`'s listener drops with the plan/loop).
- Error posture per accept: a forwarded-open failure (opener without
the scope → FORBIDDEN; old opener → NOT_FOUND; correlation miss →
handler_error-class) logs and drops that accepted connection — the
loop continues; the listener's overall health is not hostage to one
failed accept. (Decide: retry/backoff — v1 says no; document.)
handler_error-class) logs and drops that accepted connection; the
loop continues, and the listener's overall health is not hostage to
one failed accept. (Decide: retry/backoff. v1 says no; document.)
### Registration
@@ -131,16 +131,16 @@ pub fn register_tunnel_bindfirst_openable(
```
- Same spec (`tunnel_open_spec()`) + scope gate (`tunnel:open`) as the
dial/listen variants — the bind-first shape is a per-registration
dial/listen variants; the bind-first shape is a per-registration
establisher choice over the same op (ADR-008 §5's per-registration
posture). One establisher per op id per registry — same note as
`register_tunnel_listen_openable`.
posture). One establisher per op id per registry (same note as
`register_tunnel_listen_openable`).
- The forwarded-op SERVING side (opener side) is a separate
registration shape (`register_openable` — no establisher) and lands
registration shape (`register_openable`, with no establisher) and lands
with `tunnels/forwarded-receipt`; this task's tests still need an
opener-side stub (see Tests).
### Tests (integration — the duplex harness, listen-topology extension)
### Tests (integration: the duplex harness, listen-topology extension)
- **The full bind-first flow e2e:** opener opens
`channels/tunnel/sub` with the bind-first producer registered →
@@ -177,7 +177,7 @@ pub fn register_tunnel_bindfirst_openable(
idle-stream EOF cancels the loop + tears the listener down,
per-accept `pump_bidi` tasks tracked by the handler JoinHandle
- [x] The forwarded-open caller rides the call plane
(`call_with_payload` + adopt — the reverse POC's shape); the
(`call_with_payload` + adopt, the reverse POC's shape); the
registration wiring documents where the call plane comes from
- [x] `register_tunnel_bindfirst_openable` wires spec + establisher +
dispatcher + injections
@@ -192,7 +192,7 @@ pub fn register_tunnel_bindfirst_openable(
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§1 shape 3, §2 the reply fields, §3 the forwarded op, Amendment 1
— the token; §6 the hub policy sentence)
on the token; §6 the hub policy sentence)
- docs/architecture/producer.md §The Establisher (shape 3) + §The
Pump Handler (item 5) + §The Forwarded Op
- tasks/tunnels/bindfirst-params.md (the types this consumes)
@@ -200,8 +200,8 @@ pub fn register_tunnel_bindfirst_openable(
harness + registration shapes this extends)
- alkcall surface: `Establishment::with_reply_field` (ADR-049
amendment 3, landed 0.8.0), the `OpenHandler` signature (no channel
ID — Amendment 1's premise), `ChannelRelay` reply-field pass-through
(ADR-051 — the relay-transparency property the tests should pin)
ID; Amendment 1's premise), `ChannelRelay` reply-field pass-through
(ADR-051, the relay-transparency property the tests should pin)
## Notes
@@ -209,27 +209,27 @@ pub fn register_tunnel_bindfirst_openable(
`BindFirstAcceptLoop` / `ForwardedOpener` follow the type-erased
closure precedent:
- `BindFn = Arc<dyn Fn(Substrate, &str) -> BoxFuture<'static,
Result<BoundListener, TunnelEstablishError>> + Send + Sync>` —
Result<BoundListener, TunnelEstablishError>> + Send + Sync>`,
same `(substrate, backing)` form as `DialFn` (the registry
backing string rides the same signature; the bind closure stays
substrate-agnostic from the protocol's view).
- `BindFirstAcceptLoop = Arc<dyn Fn(Arc<dyn Any + Send + Sync>) ->
BoxFuture<'static, Result<(SubstrateAddr, TargetHandle),
TunnelEstablishError>> + Send + Sync>` — the closure receives the
plan's OPAQUE listener (`Arc<dyn Any + Send + Sync>`, no
TunnelEstablishError>> + Send + Sync>`, and the closure receives
the plan's OPAQUE listener (`Arc<dyn Any + Send + Sync>`, no
downcast in the protocol: the assembly downcasts to ITS concrete
type inside the closure; the same injection pattern as
`DialFn`/`AcceptFn`). A returned `Err` is LOOP-CRITICAL (the
substrate accept source broke) — the dispatcher ends the loop;
substrate accept source broke), so the dispatcher ends the loop;
per-accept forwarded-open failures never come from this closure.
- `ForwardedOpener = Arc<dyn Fn(TunnelForwardedParams) ->
BoxFuture<'static, Result<(MpscSendStream, MpscRecvStream),
TunnelEstablishError>> + Send + Sync>` — closes over the serving
session's `Arc<CallConnection>` + the producer-side
TunnelEstablishError>> + Send + Sync>`, which closes over the
serving session's `Arc<CallConnection>` + the producer-side
`ChannelManager` (the reverse POC's `open_reverse_channel`
shape: `call_with_payload` on channel 0 → extract `channel_id` →
`manager.adopt_channel`). **Where the call plane comes from:** the
assembly layer holds it — it constructed the serving session
assembly layer holds it, since it constructed the serving session
(the harness's `wire_bindfirst` shows the wiring: the
`ForwardedOpener` closure closes over the `Arc<ChannelClient>`
and calls `call_open_op` + `adopt_channel`; in production the
@@ -237,42 +237,42 @@ pub fn register_tunnel_bindfirst_openable(
- **Registration signature deviation (documented):** the task sketch
had 8 parameters (clippy's `too_many_arguments`); the injected
seams bundle into `BindFirstInjections {bind, accept_loop,
open_forwarded}` — one coherent injection point passed beside the
open_forwarded}`, one coherent injection point passed beside the
core pair + the optional witness. Same posture as `DialFn` riding
the dial registration. The `identity_witness` seam is wired via a
small `witness_establisher` wrapper (records the per-call opener
identity, then delegates — the same CF-006 seam the dial/direct
identity, then delegates; the same CF-006 seam the dial/direct
variants inline).
- **The token mint** (`mint_token`, private): `AtomicU32` counter
seeded at 1 inside the establisher's closure state; `fetch_add`
returns the PREVIOUS value, so the minted stream is 1, 2, 3, … —
returns the PREVIOUS value, so the minted stream is 1, 2, 3, …;
token 0 (an illegal wire value) is never minted, and a u32 wrap
that lands on 0 re-increments (the loop skips it). Per-open fresh,
per-producer unique; unit-pinned including the wrap shape.
- **The idle-stream EOF watch:** a 1-byte `read` on the split idle
half inside a `select!` against the accept loop — `Ok(0)` (the
half inside a `select!` against the accept loop: `Ok(0)` (the
zero-byte EOF sentinel), a read error, or (defensively) any
non-empty read on an idle stream all cancel the loop. The listen
channel's stream is never pumped (no `pump_bidi` on it).
- **Per-accept failure posture (v1, documented):** a forwarded-open
failure (scope-less opener → FORBIDDEN; old opener → NOT_FOUND;
correlation miss → handler_error-class) logs a warning and drops
that accepted connection — the loop continues; NO retry/backoff
that accepted connection; the loop continues, with NO retry/backoff
(v1 posture per the task).
- **Lifetime (R-02):** the handler collects every per-accept
`pump_bidi` `JoinHandle` in a `Vec` and awaits them all on loop
exit (before the handler task returns) — a forwarded pump never
exit (before the handler task returns); a forwarded pump never
outlives its listener. Handler exit → wrapper teardown → the listen
channel reaps → the plan's listener drops with the loop.
- **Harness changes (test infrastructure):** `wire_bindfirst`
(RegistrationMode::BindFirst); the consumer's install hook gained a
shared serving registry + the accept-side identity propagation
(the transport identity attaches to channel 0 — the same posture
(the transport identity attaches to channel 0, the same posture
`wire_forward` models; needed because the forwarded opens arrive
ON the consumer's serving path); `Topology.consumer_registry`
exposed; the forwarded-op serving stub
(`register_forwarded_stub_on_consumer` /
`make_forwarded_stub_handler` — correlate by `listen` token →
`make_forwarded_stub_handler`: correlate by `listen` token →
accept the `BiStream` → `pump_bidi` against the correlated halves;
the opener-side registration shape lands for real with
`tunnels/forwarded-receipt`).
@@ -281,9 +281,9 @@ pub fn register_tunnel_bindfirst_openable(
of a raw duplex; the stub's pump bridges forwarded channel ↔ those
halves; the accepted side's echo task closes the loop). The
forwarded channel on the opener side is wrapper-managed (the
serving wrapper allocated it — `adopt_channel` on it a second time
fails `ChannelExists`), so the test drives the client end, not a
re-adopt.
serving wrapper allocated it, so `adopt_channel` on it a second
time fails `ChannelExists`), so the test drives the client end, not
a re-adopt.
- **EOF-teardown observability:** the test pins the listen channel
reaping on the producer side after the opener drops its session
(`has_channel` false) and the accept loop's death via the queue
@@ -292,28 +292,28 @@ pub fn register_tunnel_bindfirst_openable(
## Summary
Implemented the bind-first producer (shape 3, ADR-008 as amended) per
the task: `src/producer.rs` — `BindFn` + `BoundListener` (the
the task. `src/producer.rs`: `BindFn` + `BoundListener` (the
platform-boundary bind seam); the token mint (non-zero-seeded
`AtomicU32`, per-open fresh, per-producer unique, token 0 never
minted — the wrap shape unit-pinned); `bindfirst_establisher`
minted, the wrap shape unit-pinned); `bindfirst_establisher`
(registry namespace gate → mint → bind → `bound` + `listen` reply
fields → the `BindFirstPlan` plan; the typed errors pass through —
fields → the `BindFirstPlan` plan; the typed errors pass through:
bind failure `dial_failed`, exhaustion `resource_shortage`, registry
miss `unknown_resource`); `BindFirstAcceptLoop` (the type-erased
accept closure receiving the opaque listener); `ForwardedOpener` (the
call-plane forwarded-open caller: call + adopt);
call-plane forwarded-open caller: call + adopt);
`make_bindfirst_accept_handler` (the accept dispatcher: `accept_bi`
first, the idle-stream EOF sentinel cancels the loop via `select!`,
per-accept `pump_bidi` in spawned tasks tracked by the handler's
`JoinHandle`, per-accept failure isolation, loop-critical accept
errors end the loop); `register_tunnel_bindfirst_openable` (same
spec + scope gate, `BindFirstInjections` bundle + witness seam) —
re-exported through `lib.rs`. `tests/harness.rs` —
spec + scope gate, `BindFirstInjections` bundle + witness seam),
re-exported through `lib.rs`. `tests/harness.rs`:
`RegistrationMode::BindFirst` + `wire_bindfirst` (the consumer hook
gained the accept-side identity propagation + a shared serving
registry, `Topology.consumer_registry` exposed) + the forwarded-op
serving stub (`make_forwarded_stub_handler`). `tests/
producer_bindfirst.rs` — 6 integration tests: the full flow e2e (the
producer_bindfirst.rs` has 6 integration tests: the full flow e2e (the
acceptance gate: reply projection → token correlation → forwarded
open → byte round-trip through the whole path), the dial-open
neither-field projection, EOF teardown (listen reaped, loop gone),
@@ -321,8 +321,8 @@ per-accept isolation (scope-less opener; the loop serves the next
accept), typed establishment errors, and the token discipline (two
opens distinct tokens; each dispatcher stamps its own plan's token).
Plus 6 unit tests in producer.rs (the mint discipline incl. the wrap
shape, the establisher flow at the `Establishment` surface — plan
token == reply token — distinct tokens per open, registry miss,
shape, the establisher flow at the `Establishment` surface (plan
token == reply token), distinct tokens per open, registry miss,
bind-error passthrough, the witness wrapper).
Verified: `cargo test` green (118 default; 138 `--all-features`),
+31 -29
View File
@@ -1,6 +1,6 @@
---
id: tunnels/consumer-session
name: Consumer half — TunnelSession (open/adopt, data planes, teardown)
name: Consumer half, TunnelSession (open/adopt, data planes, teardown)
status: completed
depends_on: [tunnels/params, tunnels/wire-codec, tunnels/producer-open-op]
scope: broad
@@ -12,26 +12,26 @@ tags: [consumer, session, teardown]
## Description
Implement `src/consumer.rs` per consumer.md + ADR-005: `TunnelSession` —
Implement `src/consumer.rs` per consumer.md + ADR-005: `TunnelSession`,
the typed client, both construction paths, the substrate-shaped data
plane, and teardown ownership. The reverse POC's consumer
(`/workspace/alktunnels-reverse-poc/src/consumer.rs` — `ReverseTunnel`)
(`/workspace/alktunnels-reverse-poc/src/consumer.rs`, `ReverseTunnel`)
is the seed; this task generalizes it to the spec's session type.
### Construction
- **Forward path:** `TunnelSession::open(client:
&ChannelClient, params: TunnelParams) -> Result<Self,
TunnelOpenError>` — `client.open_channel(OP_TUNNEL_OPEN, params,
TunnelOpenError>` via `client.open_channel(OP_TUNNEL_OPEN, params,
TUNNEL_ALPN)` → adopt → split the channel `BiStream` → data plane by
substrate. Typed errors (ADR-049 §4; never a phantom session).
- **Reverse path:** `TunnelSession::adopt(manager: &ChannelManager,
channel_id: u32, alpn: impl Into<String>) -> Result<Self,
TunnelOpenError>` — adopt a worker-allocated ID (early arrivals
TunnelOpenError>`: adopt a worker-allocated ID (early arrivals
parked), install the data plane from the adopted halves.
`open_reverse_channel(hub_call, params, auth_token) -> Result<u32,
ReverseOpenError>` as the free function the assembly layer calls
before `adopt` (the POC's shape — the hub's call surface is a
before `adopt` (the POC's shape: the hub's call surface is a
`CallConnection`, not a `ChannelClient`, so the two-step is the
honest API).
@@ -40,30 +40,30 @@ is the seed; this task generalizes it to the spec's session type.
- **Stream variant:** `stream_halves()` →
`(&mut dyn AsyncRead, &mut dyn AsyncWrite)` (borrowed access);
`take_halves(self)` → owned boxed halves (the session's halves ARE
the tunnel — raw pass-through).
the tunnel, i.e. raw pass-through).
- **Datagram variant:** `send_datagram(&[u8]) -> Result<(),
TunnelIoError>` (frame → write → flush; `Oversize` >65535);
`recv_datagram() -> Result<Option<Bytes>, TunnelIoError>` —
`recv_datagram() -> Result<Option<Bytes>, TunnelIoError>`:
`Some(bytes)` per datagram (possibly empty, `len=0` legal), `None`
only on stream EOF (the F-2 layering; the POC's
`read_one_datagram` incremental loop).
- Wrong-substrate operations are `WrongSubstrate` errors (the POC's
shape).
### Pump ownership + teardown (ADR-005 — the point)
### Pump ownership + teardown (ADR-005, the point)
- `pump_against(self, accepted: impl AsyncRead + AsyncWrite + ...)` —
- `pump_against(self, accepted: impl AsyncRead + AsyncWrite + ...)`,
for the reverse path: spawn `pump_bidi(channel_bistream,
accepted_read, accepted_write)`, hold the returned handle. Returns
the session (builder-style) or takes self and returns the handle —
pick the shape that makes holding easy; document it.
- `close(self) -> bool` — abort the pump (if session-owned) +
the session (builder-style) or takes self and returns the handle.
Pick the shape that makes holding easy; document it.
- `close(self) -> bool`: abort the pump (if session-owned) +
`teardown_channel` (ungraceful path).
- `join(self) -> (u64, u64, bool)` — await pump completion, then reap;
- `join(self) -> (u64, u64, bool)`: await pump completion, then reap;
copy counts for observability. **Pump-less sessions** (after
`take_halves`): completes immediately, reaps only, `(0, 0, reaped)`
(consumer.md's pinned semantics).
- `Drop` — abort + sync `teardown_channel` (best-effort; never leak
- `Drop`: abort + sync `teardown_channel` (best-effort; never leak
the entry). No `Clone`.
### Tests
@@ -73,8 +73,8 @@ halves drive a duplex), reverse open+adopt+pump_against (the POC's
`ReverseTunnel` tests: round-trip, half-close W4, join_and_reap copy
counts, out-of-band close + self-reaping, pump-less join), datagram
variant (round-trip incl. empty datagram via the codec), teardown
matrix (close/join/Drop paths — no leaked channel entries asserted via
`channel_ids()`).
matrix (close/join/Drop paths, with no leaked channel entries asserted
via `channel_ids()`).
## Acceptance Criteria
@@ -102,11 +102,13 @@ matrix (close/join/Drop paths — no leaked channel entries asserted via
- **`adopt` takes `substrate: Substrate`** (a deviation from the task
sketch's `adopt(manager, channel_id, alpn)`): the substrate shapes
the data plane (raw halves vs the UDP codec) and cannot be inferred
from the channel ID. The ALPN is observability-only (alkcall
from the channel ID, so passing it to `adopt` keeps the two calls
consistent.
The ALPN is observability-only (alkcall
manager semantics); the substrate rode the open call that produced
the ID — passing it to `adopt` keeps the two calls consistent.
the ID.
- **`pump_against` takes the whole accepted stream** (`S: AsyncRead +
AsyncWrite`) and splits internally — `tokio::io::split` at the
AsyncWrite`) and splits internally with `tokio::io::split` at the
pump boundary, matching the POC's `pump_against(accepted)`. Returns
the session (builder-style); the pump handle is session-owned and
private (close/join/Drop are the only handles needed).
@@ -114,24 +116,24 @@ matrix (close/join/Drop paths — no leaked channel entries asserted via
}`** (not bare halves): the session must stay alive as the
channel-entry owner (reap via `close`/`join`/`Drop`); returning
bare halves would strand the entry. `join` on the held session
completes immediately (`(0, 0, reaped)` — pump-less).
- **`open_reverse_channel`'s hub_call is `&CallConnection`** (not
`&Arc<CallConnection>`) — borrowing is the honest shape; callers
hold the Arc.
completes immediately (`(0, 0, reaped)`, because pump-less).
- **`open_reverse_channel`'s hub_call is `&CallConnection`**
(not `&Arc<CallConnection>`); borrowing is the honest shape;
callers hold the Arc.
- **Fields are `Option` + idempotent `Drop`:** a `Drop`-impl type
cannot be destructured, so `take_halves`/`pump_against`/`close`/
`join` take fields out via `.take()`; an emptied session drops as
a no-op. Teardown is idempotent (`teardown_channel` on a reaped
entry is `Err(UnknownChannel)`, ignored).
- **Bogus-ID adopt succeeds by design** (the manager parks early
arrivals for any not-yet-seen ID — the adoption-race cover,
arrivals for any not-yet-seen ID, the adoption-race cover,
alkcall ADR-047 §5); the honest `AdoptFailed` probe is the
ID-collision path (same ID adopted twice), pinned in the tests.
- **`recv_datagram` keeps a pending queue**: one chunk may batch
several frames; `DatagramReader::feed` decodes all completions per
feed, so frames beyond the first queue for subsequent
`recv_datagram` calls (dropped frames were a bug the tests caught).
- **Harness gained `wire_forward`** (`ForwardTopology`): the forward
- **Harness gained `wire_forward`, a `ForwardTopology`:** the forward
path's consumer is the connect side (`from_connection`, pure
consumer) and the producer accepts via the adapter; the accept-side
transport-identity posture attaches the dialer's identity in the
@@ -149,11 +151,11 @@ adopt_channel` + `open_reverse_channel` on the `CallConnection`),
the substrate-shaped data planes (raw halves for stream; the ADR-003
codec with a pending frame queue for UDP), pump ownership
(`pump_against` spawns `pump_bidi` and holds the handle), and the
full teardown matrix — `close` (abort + ungraceful reap), `join`
(await + reap + copy counts; pump-less `(0, 0, reaped)`), `Drop`
full teardown matrix: `close` (abort + ungraceful reap), `join`
(await + reap + copy counts; pump-less `(0, 0, reaped)`), and `Drop`
(abort + sync reap, never leaks). No `Clone` (compile_fail doc-test).
Tests: `tests/consumer_session.rs` — 12 integration tests over the
Tests: `tests/consumer_session.rs` has 12 integration tests over the
two harness topologies: forward round-trip (session halves), take-
halves + pump-less join, datagram round-trip incl. the F-2 empty
datagram, wrong-substrate typed errors, oversize-at-frame-time,
+157
View File
@@ -0,0 +1,157 @@
---
id: tunnels/corrmiss-adr
name: "ADR: the forwarded op's establishment phase + the correlation-miss vocabulary (resolves review 003's decisions)"
status: completed
depends_on: []
scope: narrow
risk: medium
impact: phase
level: implementation
tags: [adr, forwarder, correlation-miss, vocabulary, review-003]
---
## Description
Land the ADR ink for the settled decisions in
`docs/plans/003-correlation-miss-resolve.md` §Decisions (1-4): the
forwarded op's serving side gains an establishment phase; a stale/
missing `listen` token fails the open as a typed `channel:open_failed`
(rewriting plan decision 2: the wire vocabulary already pins
`unknown_resource`, so no wire-vocabulary extension and no
reason-class ADR); the residual post-open decline (the narrow
establisher-pass/handler-miss race) keeps teardown-at-birth and adopts
**`correlation-miss`** as the canonical event name with alkssh's level
posture (assembly-side declines at `debug`; the producer-side generic
arm keeps `warn` now carrying a typed reason instead of a prose one).
Also record the tri-vocabulary history the decision retires:
alkssh `correlation-miss` (debug, `decline_arrival`), alksocks prose
("forwarded open with no waiting session", warn,
`src/composed/mod.rs:497`), alktunnels `handler_error`-class generic
arm (warn, the bind-first accept loop's failed-open branch); review
003's situation section plus the plan's §Verified findings are the
primary sources for it.
Doc-only change; no code in this task.
### Shape to decide while writing (the plan's "ADR-008 amendment
### (or a small successor ADR)" choice)
Recommendation: an **Amendment 2 to ADR-008**. This amends the
forwarded op's serving shape exactly as §3 pins it (that section's
sentence "no establisher: the forwarded channel needs no
establishment; its data plane starts immediately" is the text being
amended), the wire vocabulary is unextended, and the amendment keeps
the whole forwarded-op decision record in one ADR. Take a small
successor ADR instead only if the amendment balloons
(e.g. it forces restating §4/§5 wholesale). Whichever shape: mark the
amended §3 sentence, cross-link the plan and review 003, keep the
existing ADR text's amendment style (the landed Amendment 1 is the
format precedent).
### The invariants the ADR must pin
- The gate is injected: the protocol crate cannot hold the assembly's
correlation map; the establisher's check is an assembly-provided
closure the crate wraps into the alkcall `OpenEstablisher` dance
(the plan's W1 decision; assemblies must not hand-roll the alkcall
establisher plumbing).
- The gate rides `register_openable_with_establisher` (alkcall 0.10.0,
verified): establisher failure → typed `channel:open_failed` with
`details.reason`; "never a phantom channel" is ADR-049's posture. The
forwarded op currently rides the phantom-shaped serving side
(establisher-less everywhere), and this closes it for the miss class.
- `unknown_resource` is reused as-is (the waiting session IS a resource
that is gone), so `details.reason`'s wire vocabulary is NOT
extended.
- The scope gate (`tunnel:forwarded`) precedes the establisher; pin
the order (the registry runs the ACL before the open op's
establishment phase; alkcall's open-op wrapper order).
- The residual post-open race keeps teardown-at-birth (the fallback
shape, which is ADR-008 §3's opener-half posture, unchanged), and
its event
name is `correlation-miss` at `debug` (assembly-side; the protocol
crate's built-in handler holds no map, so the name is the
assemblies' convention this ADR records, not code this crate runs).
- Additive-only surface: the base `register_tunnel_forwarded_openable`
shape stays for compatibility; the establisher variant is the
recommended posture (new assemblies take it by default posture).
## Acceptance Criteria
- [x] Amendment 2 to ADR-008 (or the successor ADR) lands under
`docs/architecture/decisions/`, covering all four §Decisions
and the tri-vocabulary history
- [x] The amended/superseded §3 sentence is marked, not silently
replaced
- [x] Wire-vocabulary non-extension is explicit (`unknown_resource`
reused; `wire.md` §The Open Ops untouched; state that)
- [x] Status block of
`docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md`
is NOT updated here (the doc-sync task owns it; this task is
the ADR itself)
- [x] No code changes; doc build clean (`cargo doc --no-deps` clean is
NOT required for pure docs/ edits; verify md renders coherently
instead)
## References
- docs/plans/003-correlation-miss-resolve.md §Decisions, §Verified
findings, §W1 (this task is the ADR bullet of W1)
- docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md §3
(the serving shape being amended; Amendment 1 is the format
precedent)
- docs/architecture/wire.md §The Open Ops (the pinned
`details.reason` vocabulary, unextended)
- alkcall ADR-049 (the establisher posture being extended to the
forwarded op's serving side); alkcall 0.10.0
`register_openable_with_establisher` (verified present)
## Notes
- Shape chosen: **Amendment 2 to ADR-008** (the recommendation held:
it did not balloon, and §4/§5 needed no restating).
- Before writing, verified the ground against the shipped sources:
alkcall 0.10.1 `EstablishmentError` (variants
`DialFailed`/`UnknownResource`/`ResourceShortage`/`HandlerError`;
`.reason()` maps `UnknownResource → "unknown_resource"`,
operations.rs:592-596) and `register_openable_with_establisher`
(operations.rs:761); the open-op wrapper order
(`policy.check_open`, the ACL, then allocate → establisher →
handler, operations.rs:1085-1103) pins the "scope gate precedes the
establisher" invariant. Cargo.toml pins `alkcall = "0.10.1"`, so
the ADR says "0.10.x, verified present".
- The amended §3 sentence (lines 187-196, the serving-shape bullet)
is marked inline with "**amended by Amendment 2**"; the
pre-amendment posture is recorded, not replaced (the Amendment 1
style).
- Deliberately NOT touched: `wire.md` (the amendment states its
`details.reason` vocabulary is unextended; the one serving-shape
prose line ("no establisher" under §The Forwarded Op) syncs with
the API task per the amendment's note) and review 003's status
block (doc-sync task owns it).
## Summary
Amendment 2 landed at
`docs/architecture/decisions/008-listen-metadata-forwarded-op.md`
(2026-11-03): the forwarded op's serving side gains an establishment
phase: an assembly-provided gate closure (over
`TunnelForwardedParams`) the crate wraps into the alkcall
`OpenEstablisher` dance via `register_openable_with_establisher`,
with failures mapped to `EstablishmentError::UnknownResource` so a
stale/missing `listen` token is a typed
`channel:open_failed`/`unknown_resource` before any channel is born.
`unknown_resource` is reused as-is; the wire vocabulary is unextended
and `wire.md` is untouched. Gate order pinned (`tunnel:forwarded` ACL
precedes the establisher); the residual post-open race keeps
teardown-at-birth with the canonical assembly-side name
`correlation-miss` at `debug` (producer-side generic arm stays `warn`
with the typed reason); the base
`register_tunnel_forwarded_openable` stays, the establisher variant
is the recommended default posture; the wire delta of the amendment
is empty. The tri-vocabulary history (alkssh `correlation-miss`
debug, alksocks prose warn, alktunnels `handler_error`-class generic
warn + the fabric-side silence) is recorded with sources. Doc-only;
no code changes.
+106
View File
@@ -0,0 +1,106 @@
---
id: tunnels/corrmiss-doc-sync
name: "Doc sync: review 003 status + plan 003 checkboxes + plan-level AC close-out (W1)"
status: completed
depends_on: [tunnels/corrmiss-review]
scope: narrow
risk: trivial
impact: component
level: implementation
tags: [docs, review-003, status, bookkeeping]
---
## Description
Close out W1's remaining doc bookkeeping (the plan-level AC's "review
003 status updated to resolved (W1 absorbed; W2/W3 pointed)" clause):
resolve the review's doc trail once the ADR + API + tests + review
checkpoint are landed.
- `docs/reviews/003-…alkssh-input.md`: update the Status block,
resolved for W1 (this repo absorbed the ask via the ADR + the
establisher variant); W2/W3 pointed at the sibling repos
(`docs/plans/003-correlation-miss-resolve.md` is the pointer). Match
the status-block format the other reviews use.
- `docs/plans/003-correlation-miss-resolve.md`: tick the W1 share of
§Acceptance criteria (the first checkbox's clauses that are now
true). Leave the W2/W3 checkboxes and the plan-level "W2: …" /
"W3: …" boxes UNTICKED, since they are sibling-repo sessions,
unticked until those repos land.
- `docs/plans/003-…` status header: planned → W1 landed (keep the
file; it records the tri-vocabulary decision's ground).
- If any doc comment referencing the miss is stale after the API task
(a `grep` for `correlation miss` / `handler_error`-class across
`src/`, `docs/architecture/`), align it. Convention: stale
TODOs/comments pointing at superseded designs get removed, not
annotated.
## Acceptance Criteria
- [x] Review 003's Status block reads resolved-for-W1 with the W2/W3
pointer
- [x] Plan 003's W1 acceptance clauses ticked; W2/W3 left visibly
open
- [x] A stale-comment sweep across `src/` + `docs/architecture/` ran
clean (or the stragglers aligned)
- [x] `cargo doc --no-deps` clean (doc edits touch lib.rs-adjacent
comments only if the sweep found any)
## References
- docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md
(the status block)
- docs/plans/003-correlation-miss-resolve.md §Acceptance criteria
(the plan-level boxes)
- tasks/tunnels/review-002-doc-sync.md (the doc-sync precedent, the
same job shape for review 002)
## Notes
The sweep grep (`correlation miss` / `correlation-miss` /
`handler_error`-class across `src/` + `docs/architecture/`) found the
two-shape posture docs already synced by the earlier trio (the
`make_forwarded_op_handler` doc, both registration docs, the gate
establisher doc, wire.md §The Forwarded Op's registration paragraph,
the ADR amendment itself). Three STALE spots found and aligned:
- `src/producer.rs:838-854` (the bind-first accept loop's failed-open
arm): the in-comment class-distinction still read
"correlation miss → handler_error-class", the exact prose review
003 flagged. Reworded: a stale/missing token is now the typed
`unknown_resource` open_failed under the serving side's establisher
variant (ADR-008 Amendment 2); other establisher failures ride
handler_error-class. (Deliberately a comment-update, not removal,
since the class distinctions are the arm's working notes; convention 1's
removal rule is for pointers at superseded designs, and this one
now points at the current one.)
- `wire.md` §The Forwarded Op "Reply / errors": "a producer-side
accept correlation miss is the typed
`channel:open_failed`/`handler_error`-class error", which was
pre-amendment attribution. Now records the amendment posture: the miss is the
typed `unknown_resource` open_failed (establisher variant,
recommended); under the base shape an in-session miss produces no
call error: the assembly-side post-open decline,
`correlation-miss` at `debug`.
- `producer.md` §The Forwarded Op: "(ADR-008 §3) … no establisher,
the data plane starts immediately" now carries the two-shape
posture with the Amendment 2 pointer; the correlate step notes the gate rides
the establisher with the handler-side correlation as the residual
fallback. `last_updated` header bumped.
## Summary
Review 003's Status block now reads resolved-for-W1: the ask absorbed
via plan 003 §Decisions 1–4 + ADR-008 Amendment 2 (typed
`unknown_resource` open_failed at the gate; `correlation-miss` at
`debug` for the residual decline; `unknown_resource` reused, wire
vocabulary unextended), with W2 (alkssh) / W3 (alksocks) pointed at
the plan's §W2/§W3 and recorded as open. Plan 003's status header is
"W1 landed; W2/W3 open (sibling repos)" and only the W1 plan-level AC
box is ticked, and W2/W3 plus the family-coherence box stay untouched
until those repos land. The stale-comment sweep aligned three
stragglers (the accept-loop arm's comment, wire.md's failure line,
producer.md's forwarded-op section); everything else already carried
the two-shape posture. Verification: `cargo test` (14 suites, 0
failures), clippy `--all-targets -- -D warnings` clean,
`cargo fmt --check` clean, `cargo doc --no-deps` clean.
+187
View File
@@ -0,0 +1,187 @@
---
id: tunnels/corrmiss-forwarded-gate
name: "API: the forwarded-op establisher variant (ForwardedGate + register_tunnel_forwarded_openable_with_establisher)"
status: completed
depends_on: [tunnels/corrmiss-adr]
scope: moderate
risk: medium
impact: component
level: implementation
tags: [api, establisher, forwarded-op, correlation-miss, review-003]
---
## Description
Implement the additive establisher variant for the forwarded op's
serving half (plan 003 W1's API item): an assembly-injected
correlation gate that the crate wraps into the alkcall
`OpenEstablisher` dance, registered via
`register_openable_with_establisher`: a stale `listen` token now
returns a typed `channel:open_failed` before the channel's data plane
is served, instead of succeeding at the call plane and declining
post-open (the phantom-channel shape the review records as
fully silent on the fabric side).
Candidate shape (the plan's W1 design decision to finalize, which this task
finalizes it):
```rust
/// The injected correlation gate: `Err` at the gate → typed
/// `channel:open_failed` before the channel is born.
pub type ForwardedGate = Arc<
dyn Fn(&TunnelForwardedParams) -> Result<(), TunnelEstablishError> + Send + Sync,
>;
pub fn register_tunnel_forwarded_openable_with_establisher(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
gate: ForwardedGate,
handler: ForwardedHandler,
) -> Result<(), String>
```
### The design decisions to finalize (and pin in doc comments)
1. **Gate input vs raw input.** The gate receives the PARSED
`TunnelForwardedParams`; the establisher parses (the params'
serde pins, `deny_unknown_fields`, positive `listen`, are the
loud gates; `parse` failure at the establisher maps through
`EstablishmentError::HandlerError` → a typed `channel:open_failed`,
which is an improvement over the base shape's silent
birth-teardown for a malformed ingress; pin this deliberately, do
not drift it). The handler still receives the raw input and
re-parses as it does today, so keep `make_forwarded_op_handler`
unchanged (the double parse is cheap and preserves deny-unknown at
both layers; the handler must stay usable by the base shape).
2. **The establishment answer.** A gate pass produces no resource
handle (the correlation map is assembly state; the handler needs
nothing it does not re-derive). It returns `Establishment::new(plan)`
with an empty/marker plan payload (same typed-opaque `ChannelPlan`
posture as ADR-049 amendment 2; `()` or an empty marker struct is
the payload choice to pin). No reply-field contribution (the
forwarded op's reply stays the wrapper's `channel_id`).
3. **Error mapping.** A gate `Err(TunnelEstablishError)` maps through
the EXISTING `From<TunnelEstablishError> for EstablishmentError`
table (`src/producer.rs:142-154`); a stale token is
`UnknownResource` → `details.reason == "unknown_resource"` on the
wire. Do not mint new variants. The gate SHOULD document that a
miss is `TunnelEstablishError::UnknownResource` (that is the
canonical reason the ADR pins), but the crate passes whatever the
gate returns through.
4. **No timeout change**: `timeout: None` (the alkcall 10s default)
as the other registrations do; the gate is an in-memory map lookup.
5. **No scope-table change**: the same `tunnel_forwarded_spec()` /
`tunnel:forwarded` gate runs (the registry runs the ACL before the
establishment phase, alkcall's open-op wrapper order; pin the
order in a doc comment + a test, see the tests task).
### Also in this task (the doc half of W1's API item)
- Update the in-code docs that describe the miss as
assembly-side-only: `make_forwarded_op_handler`'s
correlation-miss-semantics block (`src/producer.rs:1014-1020`) and
`register_tunnel_forwarded_openable`'s doc (1047-1056); both gain
the two-shape posture: the establisher variant is the recommended
shape (the miss is a typed failure at the open); the base shape
stays for compatibility and keeps the post-open teardown-at-birth
semantics (still the residual-race fallback under the establisher
variant).
- Re-export the new surface from `lib.rs` (convention 16).
- The base `register_tunnel_forwarded_openable` stays
byte-for-byte compatible (additive-only, per plan decision 4).
NOT in this task: the sibling-repo adoptions (alkssh W2 / alksocks
W3, pointer tasks exist) and the gate-miss tests (the dedicated
tests task; but do not land the API without landing its tests task
after).
## Acceptance Criteria
- [ ] `ForwardedGate` type + `register_tunnel_forwarded_openable_with_establisher`
landed in `src/producer.rs`, re-exported from `lib.rs`
- [ ] The establisher parse-failure → typed `channel:open_failed`
mapping is deliberate + documented (design decision 1)
- [ ] Gate errors pass through the existing
`From<TunnelEstablishError>` table; no new error variants
- [ ] `make_forwarded_op_handler` / the base registration are
unchanged in behavior (byte-for-byte compat)
- [ ] The in-code docs updated: the two-shape posture (establisher =
recommended; base = compatibility + residual-race fallback)
replaces the assembly-side-only framing
- [ ] `cargo test` green (existing suite untouched); clippy
`--all-targets -- -D warnings` clean (native + wasm32);
`cargo fmt --check` clean; default crate stays wasm-clean
- [ ] `--all-features` test run green (the `local` feature surface is
untouched)
## References
- docs/plans/003-correlation-miss-resolve.md §W1 (the API bullet;
the decisions this finalizes)
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md §3
+ the ADR this task's predecessor lands (Amendment 2 or successor;
cite whichever the ADR task landed)
- src/producer.rs:982-1082 (the base serving shape: `ForwardedHandler`,
`make_forwarded_op_handler`, `register_tunnel_forwarded_openable`);
142-154 (the `From<TunnelEstablishError>` table); 641-673
(the `bindfirst_establisher`, the wrap-into-`OpenEstablisher`
closure pattern to mirror, including the `Box::pin` shape)
- alkcall 0.10.0 `ChannelCore::register_openable_with_establisher`
(src/channels/operations.rs:761; the establisher runs AFTER
allocation, BEFORE the reply; failure → typed `channel:open_failed`,
no phantom channel; `timeout: None` = the 10s default)
- AGENTS.md conventions 11 (the ADR-049 establishment surface is
load-bearing; typed-opaque plans), 8 (teardown-at-birth posture),
16 (re-export posture)
## Notes
- `ForwardedGate` (sync closure over the parsed `TunnelForwardedParams`)
wraps into a private `make_forwarded_gate_establisher` →
`register_openable_with_establisher(spec, Some(...), make_forwarded_op_handler(handler), registry, auth, None)`,
so the base handler is reused byte-for-byte and the base
registration stays untouched (additive-only).
- The design decisions were finalized exactly as the task sketched:
(1) gate receives the PARSED params; establisher parse failure maps
`TunnelEstablishError::HandlerError` → typed `channel:open_failed`
(pinned in the establisher doc, an improvement over the base
shape's silent birth-teardown for malformed ingress); the handler
re-parses the raw input (deny-unknown at both layers, handler
unchanged); (2) gate pass → `Establishment::new(plan)` with an
EMPTY MARKER plan payload, pinned as a new pub `ForwardedPlan`
unit struct (chosen over `()`: self-documenting, downcast-able by
assemblies that want to assert the gate ran; the same typed-opaque
posture as ADR-049 amendment 2); no reply-field contribution;
(3) gate errors pass through the existing
`From<TunnelEstablishError>` table, no new variants; the gate doc
pins `UnknownResource` as the canonical stale-token reason;
(4) `timeout: None` (10s default); (5) no scope-table change; the
wrapper order (schema → ACL → allocate → establisher → handler) is
pinned in the establisher doc; the order-pinning test lands with
the tests task.
- Doc updates: `make_forwarded_op_handler`'s correlation-miss block +
`register_tunnel_forwarded_openable`'s doc now carry the two-shape
posture. Two adjacent doc drifts were synced in the same wave per
ADR-008 Amendment 2 ("syncs with the API task's in-code doc
updates"): `wire.md` §The Forwarded Op's "no establisher" prose
line, and `tunnel_forwarded_spec()`'s "No establisher" doc block in
`src/params.rs`.
## Summary
Landed the forwarded-op establisher variant per ADR-008 Amendment 2:
`ForwardedGate`, `ForwardedPlan`, `make_forwarded_gate_establisher`
(private), and `register_tunnel_forwarded_openable_with_establisher`
in `src/producer.rs`, re-exported from `lib.rs`. A gate `Err` (and an
establisher parse failure) now yields a typed `channel:open_failed`
before the channel is born; the base shape stays byte-for-byte
compatible as the compatibility + residual-race-fallback posture.
In-code docs updated (two-shape posture) plus the wire.md/params.rs
doc sync the amendment rides on this task. Verification: `cargo test`
green (existing suite untouched), clippy `--all-targets -D warnings`
clean native + wasm32, `cargo fmt --check` clean, `cargo doc
--no-deps` clean (0 warnings), `--all-features` test run green.
Follow-ups: the dedicated gate-miss tests task
(`tunnels/corrmiss-gate-tests`), then the sibling-repo adoption waves
(plan 003 §W2/§W3; executed in the alkssh and alksocks repos).
+190
View File
@@ -0,0 +1,190 @@
---
id: tunnels/corrmiss-gate-tests
name: "Tests: the forwarded gate: typed miss at the open, gate-pass flow, residual race, scope-gate ordering"
status: completed
depends_on: [tunnels/corrmiss-forwarded-gate]
scope: narrow
risk: low
impact: component
level: implementation
tags: [tests, establisher, forwarded-op, correlation-miss, review-003]
---
## Description
Land W1's test item (plan 003 §W1 "Tests:"): pin the four behaviors
the establisher variant introduces, mirroring the shapes the existing
suite uses (`tests/producer_forwarded.rs`, whose serving-half e2e,
scope-gate, malformed-params, and correlation-miss tests are the
precedents; the harness's `wire_with` + `Topology.consumer_registry`
shape needs no harness changes per the `forwarded-receipt` task's
notes).
The four pins:
1. **Miss at the gate → typed failure, no phantom.** A `listen` token
with no waiting session → the open fails as a
`channel:open_failed` reply with `details.reason ==
"unknown_resource"`; the transiently allocated channel is torn
down by the wrapper (ADR-049 §5: the reply is the typed error,
not a phantom channel); the pump handler NEVER runs. Probe by
opening the forwarded op with a token the gate rejects.
2. **Gate pass → the existing serving flow is unchanged.** A token the
gate accepts → the registration serves exactly like the base
shape: the injected `ForwardedHandler` receives the parsed params +
the `BiStream`, the byte round-trip works end to end (the
`producer_forwarded.rs` round-trip shape, driven through the
establisher-registered variant).
3. **The residual race shape still reaps cleanly.** Gate passes, route
gone at handler time → the injected handler's error path drops the
stream and returns → teardown-at-birth reaps the forwarded channel
(the base shape's pinned semantics; re-pin them under the
establisher variant; the narrow establisher-pass→handler-run window
is the residual `correlation-miss` class).
4. **Scope gate precedes the establisher.** An identity WITHOUT
`tunnel:forwarded` → FORBIDDEN and the gate closure is never
invoked (a witness-style `Arc<AtomicBool>` or captured flag the
gate sets on entry; assert unmoved); the registry runs the ACL
before the open op's establishment phase; pin the order.
If the establisher variant's parse-failure behavior (malformed params
→ typed `channel:open_failed` rather than birth-teardown, the design
decision 1 of the API task) is separately testable through the public
surface, pin it as a fifth test; if the existing malformed-params test
already covers the base shape, note the establisher-shape delta
explicitly (do not silently assume).
## Acceptance Criteria
- [ ] Gate-miss test: typed `channel:open_failed` /
`unknown_resource` / no handler invocation / no phantom channel
- [ ] Gate-pass test: the full round-trip through the establisher
variant (handler receives parsed params + stream, bytes
round-trip)
- [ ] Residual-race test: gate passes → handler-time miss → the
channel reaps (teardown-at-birth re-pinned)
- [ ] Ordering test: missing `tunnel:forwarded` scope → FORBIDDEN and
the gate never invoked
- [ ] The establisher-variant malformed-params delta is pinned or
explicitly noted
- [ ] Full verification green: `cargo test`,
`cargo clippy --all-targets -- -D warnings` (native + wasm32),
`cargo fmt --check`,
`cargo check --target wasm32-unknown-unknown`,
`cargo test --all-features`
## References
- docs/plans/003-correlation-miss-resolve.md §W1 (the Tests bullet;
plan-level AC "W1 ... tests green")
- tests/producer_forwarded.rs (the shapes to mirror: the serving e2e,
the FORBIDDEN gate, the malformed-params gates, the
correlation-miss teardown)
- tasks/tunnels/corrmiss-forwarded-gate.md (the surface being tested;
design decisions 1 + 3 (the parse-failure and error-mapping pins))
- docs/architecture/wire.md §The Open Ops (the
`details.reason == "unknown_resource"` vocabulary; the assertion
target)
- alkcall 0.10.0 `src/channels/operations.rs:761` (the wrapper's
establisher failure → typed open_failed semantics; ADR-049 §5)
## Notes
- New test file `tests/producer_forwarded_gate.rs` with the
establisher-variant probes live in their own file (the base shape's
`tests/producer_forwarded.rs` stays untouched), reusing the
harness's `wire_with` + `Topology.consumer_registry` shape with no
harness changes, exactly as the `forwarded-receipt` task's notes
predicted.
- The gate under test closes over a `std::sync::Mutex<HashSet<u32>>`
of waiting tokens (the gate is a sync closure, with no awaits) and sets
an `Arc<AtomicBool>` witness on entry; the witness is what the
miss/ordering pins assert against. Poisoned-lock handling follows
convention 2 (`unwrap_or_else(|e| e.into_inner())`).
- The five tests:
1. `gate_miss_fails_typed_no_phantom_no_handler` (AC 1: open with
an absent token → `Err(CallError)` with
`code == "channel:open_failed"` and
`details.reason == "unknown_resource"` (the existing
`From<TunnelEstablishError>` table, 1:1); after a settle sleep
the opener-side manager has no channels (the wrapper's transient
allocation was torn down; ADR-049 §5, no phantom); the gate's
witness IS set (the miss is at the gate, not before it) and the
pump handler's witness is unmoved (the handler never ran).
2. `gate_pass_serves_the_unchanged_serving_flow` (AC 2: token in
the map + correlated halves → the open resolves with the
wrapper's `channel_id`; the injected handler is witnessed with
the parsed params verbatim; the full dispatcher-pump dance
(adopt + driving echo) round-trips bytes end to end, i.e. the
`producer_forwarded.rs` e2e shape driven through the
establisher-registered variant.
3. `residual_race_still_reaps_cleanly` (AC 3: the token is in the
map (the gate passes, the open RESOLVES at the call plane) but
the halves vec is empty at handler time → the handler's error
path drops the stream and returns → teardown-at-birth reaps.
Re-pins the base shape's `forwarded_correlation_miss_
tears_channel_down` semantics under the establisher variant, where
the establisher-pass→handler-run window is the residual
`correlation-miss` class.
4. `scope_gate_precedes_the_establisher` (AC 4: an identity
holding only `tunnel:open` → `FORBIDDEN` naming the missing
scope, and the gate's witness stays unmoved even though the
token WOULD have passed the gate. The tie-break is the order
(schema → ACL → allocate → establisher → handler), which pins
that the registry runs the ACL before the establishment phase).
5. `malformed_params_fail_typed_at_the_establisher` (AC 5: the
establisher-shape malformed-params delta IS separately
testable through the public surface, so it is pinned: the
schema gates (`listen: 0`, missing `peer`) fire first exactly
as in the base shape (`INVALID_INPUT`, the test explicitly
re-checks both), but an unknown top-level field passes the
schema's additive posture while failing the establisher's serde
parse, so it becomes a TYPED `channel:open_failed` with
`details.reason == "handler_error"` (vs the base shape's
success-with-channel_id + silent birth-teardown: the improvement
design decision 1 pins). The establisher's parse precedes the
gate (the gate's witness stays unmoved), the wrapper tears the
transient allocation down, and the handler never runs.
- Verification deviation, discovered and characterized, pre-existing:
`cargo clippy --target wasm32-unknown-unknown --all-targets` cannot
compile on this repo's baseline (the wasm clippy's `--all-targets`
pulls the test/dev targets whose `mio`/`socket2` deps do not
compile for wasm); verified by removing the new test file and
re-running: identical failure without the change. The repo's
AGENTS.md verification list specifies wasm clippy WITHOUT
`--all-targets`
(`cargo clippy --target wasm32-unknown-unknown -- -D warnings`),
which is green; the AC's "(native + wasm32)" is read through the
AGENTS.md command list as `--all-targets` native + the no-targets
wasm clippy (same posture the API task's summary pinned: clippy
`--all-targets` native, wasm clippy clean).
## Summary
Landed W1's test item: five tests in the new
`tests/producer_forwarded_gate.rs` pinning the establisher variant's
behaviors: (1) gate miss → typed `channel:open_failed` /
`details.reason == "unknown_resource"` / no phantom channel / handler
never runs; (2) gate pass → the base serving flow unchanged (params
witnessed verbatim + full byte round-trip through the
establisher-registered variant); (3) the residual race (gate pass,
route gone at handler time) reaps cleanly via teardown-at-birth,
re-pinning the base shape's correlation-miss semantics under the
establisher variant; (4) scope-gate ordering: a scope-less identity
is FORBIDDEN and the gate closure is never invoked (witness unmoved);
(5) the establisher-variant malformed-params delta pinned explicitly:
schema gates unchanged (`INVALID_INPUT`) but an unknown top-level
field becomes a typed `channel:open_failed` with
`details.reason == "handler_error"` and the parse precedes the gate.
Verification green: `cargo test` (14 suites, 0 failures),
`cargo clippy --all-targets -- -D warnings` (native) clean,
wasm clippy `cargo clippy --target wasm32-unknown-unknown -- -D
warnings` clean (see Notes for the pre-existing
`--all-targets`-wasm deviation: dev-deps do not target wasm),
`cargo fmt --check` clean,
`cargo check --target wasm32-unknown-unknown` clean (default crate
stays wasm-clean), `cargo test --all-features` green, `cargo doc
--no-deps` clean. Harness unchanged. Follow-ups: the sibling-repo
adoption waves (plan 003 §W2/§W3; executed in the alkssh and alksocks
repos). The
W1 wave's tests are green.
+153
View File
@@ -0,0 +1,153 @@
---
id: tunnels/corrmiss-review
name: "Review: the W1 gate surface before the consumer waves adopt it (the {W2, W3} fork's critical point)"
status: completed
depends_on: [tunnels/corrmiss-gate-tests]
scope: narrow
risk: low
impact: phase
level: review
tags: [review, checkpoint, establisher, review-003]
---
## Description
Review checkpoint before the parallel consumer waves (W2, alkssh,
W3, alksocks) adopt the landed establisher variant: the surface these
sessions build on must be right before two sibling repos burn sessions
on it. Wire-stability context: the REPLY-side semantics (a typed
`channel:open_failed` with the pre-pinned `unknown_resource` reason)
are what the consumers ride: once alkssh ships the gate shape, the
API surface is de-facto wire-stable for them.
Check:
- [x] The API matches the ADR ink (the gate type, the registration
signature, the error-mapping pin, the timeout, the no-scoped
change)
- [x] The doc comments' two-shape posture (establisher = recommended;
base = compatibility + residual-race fallback) is consistent
across `make_forwarded_op_handler`, the base/variant
registrations, and lib.rs's re-export surface
- [x] The tests actually pin the wire-observable contract
(`channel:open_failed` / `unknown_resource`, gate ordering,
residual-race reaping), not just internal shapes
- [x] Patterns are consistent with the existing establisher shapes
(the `bindfirst_establisher` mirror; the
`From<TunnelEstablishError>` table reuse; no new error axes)
- [x] `cargo test`, clippy native + wasm32 `-D warnings`, and fmt are green
(re-confirm, they gate the review)
- [x] CHANGELOG updated (Added: the establisher variant) if the
implementation tasks did not already
Filing guidance: if the surface needs a shape change (naming, gate
arity, plan payload), fix it in this checkpoint; it is the last cheap
moment before the consumers fork.
## Acceptance Criteria
- [x] All checks above pass; any found shape drift fixed and
re-verified before the consumer-wave tasks leave `pending`
- [x] The review outcome recorded in this task's Notes (what was
checked, what moved if anything)
## References
- docs/plans/003-correlation-miss-resolve.md §Ordering (the W1 →
{W2, W3} fork this review gates)
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(the amended ADR, the shape source)
- tasks/tunnels/corrmiss-adr.md, corrmiss-forwarded-gate.md,
corrmiss-gate-tests.md (the W1 trio being reviewed)
- src/producer.rs (the landed surface)
## Notes
Review performed 2026-11-03 (review checkpoint gating the W1 → {W2,
W3} fork). Outcome: **surface approved with no shape change needed; two
doc-level fixes applied and re-verified.**
- **API vs the ADR ink (check 1):** all pins hold.
- The gate type `ForwardedGate = Arc<dyn Fn(&TunnelForwardedParams)
-> Result<(), TunnelEstablishError> + Send + Sync>` matches the
plan's candidate shape verbatim; the registration signature
`(core, on_registry, auth, gate, handler) -> Result<(), String>`
matches.
- Error-mapping pin: gate `Err` passes through the existing
`From<TunnelEstablishError> for EstablishmentError` table
(`src/producer.rs:142`); no new variants are minted, and the gate doc
pins `UnknownResource` as the canonical stale-token reason.
- Establisher parse failure → `HandlerError` → typed
`channel:open_failed`, deliberately pinned in the establisher
doc (an improvement over the base shape's silent birth-teardown),
exactly as corrmiss-forwarded-gate's decision 1 recorded.
- Timeout: `timeout: None` (the 10s default) in the registration.
- No scope-table change: the same `tunnel_forwarded_spec()` /
`tunnel:forwarded` gate runs; the wrapper order pin (schema → ACL
→ allocate → establisher → handler) is in the establisher doc.
- The plan payload is the pinned `ForwardedPlan` marker struct (the
`()`-alternative was rejected in the API task's Notes, as
self-documenting, downcast-able; consistent with the typed-opaque
posture of `TargetHandle`/`BindFirstPlan`).
- **Two-shape posture consistency (check 2):** present and consistent
in `make_forwarded_op_handler`'s correlation-miss block, the base
registration's doc, the variant registration's doc, `lib.rs`'s
re-export surface (base + variant + `ForwardedGate`/`ForwardedPlan`
all re-exported), plus the downstream syncs (`wire.md` §The
Forwarded Op's two-shape prose, `tunnel_forwarded_spec()`'s serving
registration block). ONE drift found and fixed: the handler doc's
HEADER still read "the no-establisher serving shape" while it now
serves both registrations, so it was retitled "the serving-shape
wrapper
both registrations share" (the two-shape block below it already
carried the posture; this aligns the header).
- **Tests vs the wire contract (check 3):** the five tests in
`tests/producer_forwarded_gate.rs` pin wire-observable behavior, not
internal shapes: the miss asserts `err.code == "channel:open_failed"`
and `details["reason"] == "unknown_resource"` plus no-phantom (no
opener-side channels) and handler-not-run; the ordering test asserts
`FORBIDDEN` naming the missing scope with the gate witness unmoved;
the residual race asserts the call-plane RESOLVES then the channel
reaps; the malformed-params delta asserts the typed `handler_error`
reason and parse-before-gate. Each contract assertion is at the
`CallError`/reply surface, and a wire change would break them.
- **Pattern consistency (check 4):** `make_forwarded_gate_establisher`
mirrors the `bindfirst_establisher` closure shape (the
`Arc::clone`-into-`Box::pin` pattern, the same
`Establishment::new(Arc::new(..) as ChannelPlan)` construction); the
error table is reused 1:1; no new error axes; poisoned-lock test
helpers follow convention 2 (`unwrap_or_else(|e| e.into_inner())`);
the private establisher + public registration split matches the
crate-owned-surface rule (assemblies cannot hand-roll the alkcall
plumbing).
- **CHANGELOG (check 6):** the implementation tasks did NOT update it
(Unreleased was empty); added the "[Unreleased] / Added" entry for
the establisher variant per this task's checklist.
- **Verification (check 5), run at this review:** `cargo test` green
(14 suites, 0 failures, which includes the five gate tests), clippy
`--all-targets -- -D warnings` clean (native), wasm clippy
`cargo clippy --target wasm32-unknown-unknown -- -D warnings` clean,
`cargo check --target wasm32-unknown-unknown` clean (default crate
stays wasm-clean), `cargo fmt --check` clean, `cargo test
--all-features` green (14 suites), `cargo doc --no-deps` clean.
The wasm-clippy `--all-targets` deviation recorded in
corrmiss-gate-tests' Notes is pre-existing and unchanged (verified
there; not re-triangulated here).
## Summary
Review checkpoint passed: the W1 gate surface (`ForwardedGate`,
`ForwardedPlan`, `register_tunnel_forwarded_openable_with_establisher`)
matches ADR-008 Amendment 2's ink on every pin (gate shape,
registration signature, error mapping through the existing
`From<TunnelEstablishError>` table, typed parse-failure posture, the
10s default timeout, the unchanged scope table); the two-shape posture
is consistent across all docs and re-exports (one header drift
retitled); the five gate tests assert the wire-observable contract at
the `CallError` surface; patterns mirror `bindfirst_establisher` with
no new error axes. Found and fixed: the stale "no-establisher serving
shape" handler-doc header, and the missing CHANGELOG entry (Added
under [Unreleased]). Re-verification all green (test, clippy native +
wasm, fmt, wasm check, --all-features, doc). No shape change is
needed, and the surface is ready for the consumer waves (W2 alkssh, W3 alksocks)
to adopt.
+40 -39
View File
@@ -1,6 +1,6 @@
---
id: tunnels/coverage-weak-spots
name: "Coverage weak spots — CLOSED (2026-09-09 second pass): the UdpHalf sender-block recipe found, feed infallible, wrappers covered"
name: "Coverage weak spots, CLOSED (2026-09-09 second pass): the UdpHalf sender-block recipe found, feed infallible, wrappers covered"
status: completed
depends_on: [tunnels/review-impl]
scope: single
@@ -18,16 +18,16 @@ The second pass found the sender-block recipe, took **option 1**, and
coverage moved **92.86% → 93.91% lines** (50 → 41 uncovered). Three
commits:
1. **`DatagramReader::feed` is infallible** — the same zombie category
1. **`DatagramReader::feed` is infallible**, the same zombie category
as `4014ba6`'s `InvalidLength`: with the oversize bound enforced at
frame time, the incremental decode cannot fail, so the `Vec<Result>`
shape and `local/mod.rs`'s dead `InvalidData` mapping were removed
(pre-consumer window open per ADR-005's N-3).
2. **The sender-block machinery test** (`udp_half_write_side_blocks_
stashes_and_drains_on_resolve`) — the recipe that reached the
stashes_and_drains_on_resolve`): the recipe that reached the
WouldBlock stash, the readiness-Pending arms, AND the high-water
backpressure in one coherent behavior (option 1).
3. **The thin wrappers** — `connect_udp`'s resolve-failure arm
3. **The thin wrappers**, `connect_udp`'s resolve-failure arm
(`.invalid` NXDOMAIN → `dial_failed`) and the no-witness
`tunnel_establisher` (a harness `DialNoWitness` mode + a full
open round-trip through the wrapper).
@@ -44,16 +44,16 @@ persistently reports `WouldBlock`/`ENOBUFS`. The recipe (validated
unresolved neighbor while charged to `sk_wmem_alloc`; with a
shrunken `SO_SNDBUF` the charge exceeds the buffer → persistent
`EAGAIN`/`ENOBUFS` (the drain reports 105=ENOBUFS alongside
EAGAIN — both mean "the socket cannot take this datagram now",
EAGAIN; both mean "the socket cannot take this datagram now",
which the `pump_queue_to_socket` stash arm now matches too).
- **Jumbo MTU (65535)** on the fixture: a 60 KB datagram crosses as
ONE frame — no IP fragmentation, so no fragment-reassembly races
ONE frame, with no IP fragmentation, so no fragment-reassembly races
at the peer (fragment-ID collisions under the drain burst merged
datagrams on an MTU-1500 veth; the peer reassembled 5-6 datagrams
into single ~43947-byte payloads).
- **The block-state probe runs on a THROWAWAY socket**: an oversized
probe datagram's partially-built skb chain stays corked in
`sk_write_queue` on EAGAIN and flushes on the next successful send —
`sk_write_queue` on EAGAIN and flushes on the next successful send,
poisoning the socket with a stale partial datagram. The test's
real socket is created fresh after the probe.
- **Assigning the peer address + binding the peer** resolves ARP; the
@@ -63,74 +63,75 @@ persistently reports `WouldBlock`/`ENOBUFS`. The recipe (validated
The first pass's kernel findings still hold and are why the loopback
recipes failed there: a non-reading peer's full receive queue DROPS
datagrams silently (loopback/veth/netem — the sender never blocks);
datagrams silently (loopback/veth/netem: the sender never blocks);
netem with `limit` still did not block (2M sends Ok in a probe).
## Negative result: the ICMP ECONNREFUSED arm is unreachable through the poll drive
A connected UDP socket + a closed peer port elicits an ICMP port
unreachable that `ECONNREFUSED`s the next `recvfrom` (a plain std
nonblocking recv surfaces it deterministically — probed rootless).
nonblocking recv surfaces it deterministically; probed rootless).
But **tokio 1.53 structurally masks it out of the poll drive** (the
adapter's shape): the `EPOLLOUT|EPOLLERR` event maps to
`WRITABLE|ERROR|WRITE_CLOSED`; `poll_recv_ready`'s Direction::Read
mask is `READABLE|READ_CLOSED` (ERROR excluded) → Pending forever;
`try_io(READABLE)` intersects empty → WouldBlock. Only the async
`recv()` (`async_io` with `READABLE|ERROR`) surfaces `ECONNREFUSED`
— and `UdpHalf::poll_read` is a poll impl. Verified by probes
`recv()` (`async_io` with `READABLE|ERROR`) surfaces `ECONNREFUSED`,
but `UdpHalf::poll_read` is a poll impl. Verified by probes
(`poll_recv`, `poll_recv_ready`, `try_recv` all Elapsed; the async
`recv` surfaced it exactly once, racing the waiter registration —
nondeterministic). `local/mod.rs:356` therefore joins the defensive
`recv` surfaced it exactly once, racing the waiter registration,
hence nondeterministic). `local/mod.rs:356` therefore joins the defensive
list: no deterministic test can reach it through the pump's drive on
tokio 1.53.
## The final uncovered-line inventory (41 lines, all deliberate)
- `src/local/mod.rs:136-137` — the no-addresses resolve arm: a name
- `src/local/mod.rs:136-137`, the no-addresses resolve arm: a name
that RESOLVES to zero addresses is unreachable via getaddrinfo
(NXDOMAIN errors first — the `.invalid` test hits the error arm).
- `src/local/mod.rs:321-333` — the `Oversize` mapping at the recv
scratch (a >65535-byte datagram cannot exist on a real wire — the
(NXDOMAIN errors first; the `.invalid` test hits the error arm).
- `src/local/mod.rs:321-333`: the `Oversize` mapping at the recv
scratch (a >65535-byte datagram cannot exist on a real wire; the
IP theoretical max is 65507).
- `src/local/mod.rs:351-352,356` — the recv-side readiness-Pending
arms (genuine WouldBlock) + the ICMP error mapping (the negative
- `src/local/mod.rs:351-352,356`, the recv-side readiness-Pending
arms (genuine WouldBlock) plus the ICMP error mapping (the negative
result above).
- `src/local/mod.rs:378-379,383` — the high-water drain's
- `src/local/mod.rs:378-379,383`: the high-water drain's
Ready(Ok)/Ready(Err) sub-arms (the observed over-line poll rides
the Pending sub-arm; the Ok sub-arm needs the drain to succeed
within that single poll — the FAILED-cycle release timing).
- `src/local/mod.rs:476-477` — `pump_queue_to_socket`'s
within that single poll, the FAILED-cycle release timing).
- `src/local/mod.rs:476-477`, `pump_queue_to_socket`'s
`poll_send_ready` Ready(Ok)/Err sub-arms (the Pend sub-arm is the
covered one).
- `src/consumer.rs:91-94,405-406` — `serde_json::to_value` failures
- `src/consumer.rs:91-94,405-406`: `serde_json::to_value` failures
on `TunnelParams` (impossible by construction).
- `src/consumer.rs:205,290` — `unreachable!` guards on spent sessions.
- `src/consumer.rs:285` — the stale-pump abort (defensive only).
- `src/consumer.rs:325-330` — the pump `JoinError` telemetry arm
(needs the spawned pump to panic/abort — not producible through
the session API).
- `src/producer.rs:85-86` — the `HandlerError` mapping (the schema
- `src/consumer.rs:205,290`, `unreachable!` guards on spent sessions.
- `src/consumer.rs:285`, the stale-pump abort (defensive only).
- `src/consumer.rs:325-330`, the pump `JoinError` telemetry arm
(needs the spawned pump to panic/abort, which is not producible
through the session API).
- `src/producer.rs:85-86`: the `HandlerError` mapping (the schema
gate preempts it; pinned by `schema_gate_rejects_missing_substrate_…`).
- `src/producer.rs:338-347` — the pump-handler telemetry early-returns
- `src/producer.rs:338-347`, the pump-handler telemetry early-returns
(`accept_bi` failure needs a dead channel; the plan is always
`TargetHandle`; `Arc::try_unwrap` needs a second clone).
- `src/wire.rs:77-79` — the `Default` impl shim (clippy's
- `src/wire.rs:77-79`: the `Default` impl shim (clippy's
`new_without_default`).
- `src/wire.rs:106` — the `unreachable!` invariant in `feed`'s loop.
- `src/wire.rs:177` — a unit test's own panic line (the negative
- `src/wire.rs:106`, the `unreachable!` invariant in `feed`'s loop.
- `src/wire.rs:177`, a unit test's own panic line (the negative
branch of `datagram_split_across_awkward_chunks_reassembles`).
## Verification (2026-09-09, second pass)
- `cargo llvm-cov --all-features --workspace --summary-only`
— **93.91% lines** (from 92.86%; 50 → 41 uncovered, all deliberate).
- `cargo test --all-features` — **91 tests green** (15 lib + 16
- `cargo llvm-cov --all-features --workspace --summary-only` reports
**93.91% lines** (from 92.86%; 50 → 41 uncovered, all deliberate).
- `cargo test --all-features` passes with **91 tests green** (15 lib
+ 16
consumer_session + 18 end_to_end + 13 doc/lib + 19 local_halves
+ 9 producer_listen + 13 producer_open_op + 1 doc-test compile_fail).
- `cargo test` (default) — green; the root-gated veth test skips
- `cargo test` (default) is green; the root-gated veth test skips
loud-free without root.
- `cargo clippy --all-targets --all-features -- -D warnings` and
`cargo fmt --check` — clean.
`cargo fmt --check` are clean.
- `cargo check --target wasm32-unknown-unknown` +
`cargo clippy --target wasm32-unknown-unknown -- -D warnings` —
`cargo clippy --target wasm32-unknown-unknown -- -D warnings` are
clean (the default crate stays wasm-clean).
+16 -16
View File
@@ -22,8 +22,8 @@ fill in.
```rust
// src/lib.rs
//! alktunnels — arbitrary bidirectional tunnels over alkcall channels.
//! (crate docs per overview.md §What — the two-paragraph shape)
//! alktunnels: arbitrary bidirectional tunnels over alkcall channels.
//! (crate docs per overview.md §What, the two-paragraph shape)
pub mod error;
pub mod params;
@@ -35,32 +35,32 @@ pub mod consumer;
// Public API surface: re-exports filled in by subsequent tasks (convention 16).
```
- `src/error.rs` — `TunnelError` (thiserror; convention 2) + the typed
- `src/error.rs`: `TunnelError` (thiserror; convention 2) + the typed
open-error surface re-exported from alkcall (`ChannelOpenError`,
`establishment_reason`-shaped helper). Skeleton only; filled by
`tunnels/params` and `tunnels/consumer-session`.
- `src/params.rs` — `TunnelParams {resource, substrate}` +
- `src/params.rs`: `TunnelParams {resource, substrate}` +
`Substrate` enum (`tcp | udp | unix`) + `tunnel_open_spec()` builder +
`TUNNEL_OPEN_SCOPE`/`OP_TUNNEL_OPEN`/`TUNNEL_ALPN` constants. Skeleton
with type definitions; the schema builder + tests land in
`tunnels/params`.
- `src/wire.rs` — the codec module doc (ADR-003 summary + bast.md
- `src/wire.rs`: the codec module doc (ADR-003 summary + bast.md
pointer); skeleton for `frame_datagram`/`DatagramReader`/
`DatagramCodecError`, filled by `tunnels/wire-codec`.
- `src/producer.rs` — module doc (producer.md summary); skeleton for
- `src/producer.rs`: module doc (producer.md summary); skeleton for
`tunnel_open_spec` wiring, `register_tunnel_openable`, the establisher
shape, the pump handler — filled by `tunnels/producer-open-op` +
shape, and the pump handler, filled by `tunnels/producer-open-op` +
`tunnels/producer-listen`.
- `src/consumer.rs` — module doc (consumer.md summary); skeleton for
`TunnelSession` — filled by `tunnels/consumer-session`.
- `src/consumer.rs`: module doc (consumer.md summary); skeleton for
`TunnelSession`, filled by `tunnels/consumer-session`.
### Keep in place / verify
- Cargo.toml: alkcall 0.7.0 pin, the wasm-clean tokio subset, the empty
`[features]` table (the `local` feature is added by `tunnels/local-socket-halves`).
- `bytes`, `serde`, `serde_json`, `thiserror`, `tracing`, `futures` stay;
drop `async-trait` IF the skeleton confirms no trait is needed (ADR-004 —
it likely is not; note the decision in Summary if dropped).
drop `async-trait` IF the skeleton confirms no trait is needed
(per ADR-004, it likely is not; note the decision in Summary if dropped).
- The default crate stays wasm-clean: `cargo check --target
wasm32-unknown-unknown` must pass at every task in this graph
(convention 4; the structural guard).
@@ -88,17 +88,17 @@ pub mod consumer;
docs pointing at the spec docs + ADRs and `tunnels/*` tasks that fill
them in.
- Constants placed early (`OP_TUNNEL_OPEN`, `TUNNEL_ALPN`,
`TUNNEL_OPEN_SCOPE` in params.rs — they are part of the wire surface,
`TUNNEL_OPEN_SCOPE` in params.rs, which are part of the wire surface,
not producer-only, so they live in the shared module per the module
map's "params.rs … + open-op input schema").
- `establishment_reason` is a thin delegation to
`ChannelOpenError::establishment_reason` (alkcall 0.7.0 has the
method upstream); the crate-level helper keeps the POC's free-fn
shape for consumers and re-exports the typed error.
- `MAX_DATAGRAM_LEN` (65535) placed in wire.rs — the one codec constant
- `MAX_DATAGRAM_LEN` (65535) placed in wire.rs, the one codec constant
ADR-003 pins regardless of the codec functions landing later.
- **`async-trait` dropped** per the task's IF: the skeleton confirms no
trait is needed (ADR-004 — halves functions, not traits);
trait is needed (ADR-004 calls for halves functions, not traits);
`cargo check` passes without it.
## Summary
@@ -108,5 +108,5 @@ docs + re-exports); Cargo.toml unchanged except `async-trait` dropped
(ADR-004 confirmation). Verified: `cargo check`, `cargo clippy
--all-targets -- -D warnings`, `cargo fmt --check`, `cargo test`,
`cargo check --target wasm32-unknown-unknown`,
`cargo clippy --target wasm32-unknown-unknown -- -D warnings` — all
clean.
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`:
all clean.
+10 -10
View File
@@ -1,6 +1,6 @@
---
id: tunnels/decide-take-framed-halves
name: "N-3 decision — take_halves on datagram sessions hands RAW FRAMED bytes; decide the API posture before the first external consumer"
name: "N-3 decision: take_halves on datagram sessions hands RAW FRAMED bytes; decide the API posture before the first external consumer"
status: completed
depends_on: [tunnels/review-impl]
scope: single
@@ -16,7 +16,7 @@ completed: 2026-09-08
Review 001 finding N-3
(`docs/reviews/001-implementation-review.md`): a datagram session
exposes `send_datagram`/`recv_datagram` (codec applied) until
`take_halves` — then the caller owns RAW halves whose wire bytes carry
`take_halves`; then the caller owns RAW halves whose wire bytes carry
the codec framing (`[len: u16 BE][payload]`, documented on
`take_halves`). The doc covers it, but it is a footgun asymmetry: the
same session flips from "codec applied" to "codec is yours" depending
@@ -36,23 +36,23 @@ before then. The options on the table:
3. **Re-shape `take_halves` for datagram sessions** (breaking, before
the window closes): e.g. a datagram session's `take_halves` returns
framed-preserving halves by default, raw access via a distinct
explicit method — removes the footgun entirely, at the cost of
explicit method, which removes the footgun entirely at the cost of
diverging `take_halves` semantics per substrate.
The decision should weigh: the pump_against shape (a UDP session
pumped against an accepted local half needs the FRAMED adapter on the
accepted side — the same framing-responsibility seam N-11's doc note
accepted side (the same framing-responsibility seam N-11's doc note
covers), what an actual consumer (alkcall's assembly layer, a
DNS-proxy shape) reaches for first, and whether a `DatagramReader` +
write-frame wrapper pair is small enough to just ship (option 2 is a
thin compose of existing public pieces — `src/wire.rs` already
thin compose of existing public pieces (`src/wire.rs` already
exposes both codec halves of the job).
## Work
- Re-read review 001 §N-3 + ADR-005 (the API-stability window);
confirm no external consumer has pinned the `take_halves` import
style yet (if one has, the window is closed — record that and
style yet. If one has, the window is closed; record that and
choose the additive path (2) or docs-only (1)).
- Write the decision as an ADR amendment (ADR-005) or a new ADR if
the posture adds a public method; file the OQ only if the decision
@@ -75,20 +75,20 @@ exposes both codec halves of the job).
## Resolution (2026-09-08)
**Option 1 — keep as-is, docs carry it.** Recorded as ADR-005's
**Option 1: keep as-is, docs carry it.** Recorded as ADR-005's
§Amendment (2026-09-08, N-3). Decision inputs:
- **No external consumer exists** (workspace grep verified 2026-09-08)
— the window is open, so the breaking reshape (option 3) was
so the window is open and the breaking reshape (option 3) was
available but nothing demonstrates the need.
- One `take_halves` semantic across substrates (the tunnel's wire
bytes, uninterpreted) beats per-substrate divergence; the framing
asymmetry is ADR-003's spec, not drift.
- The `pump_against` seam composes the framed ADAPTER (`UdpHalf`,
ADR-003's boundary codec — landed with the U-1 remediation the same
ADR-003's boundary codec, which landed with the U-1 remediation the same
day), not framed halves; option 2 would not feed that use.
- Option 2 (`take_framed_halves`) is additive and can land after the
first consumer defines its shape without breaking anything —
first consumer defines its shape without breaking anything, so
deferring costs nothing. Design note recorded in the ADR: an
`AsyncRead`-faced wrapper re-collides with F-2's
empty-datagram/EOF ambiguity; the codec-side framing wants
+35 -35
View File
@@ -1,6 +1,6 @@
---
id: tunnels/direct-op
name: Direct op — params, spec, establisher, consumer open_direct (ADR-007)
name: Direct op, params, spec, establisher, consumer open_direct (ADR-007)
status: completed
depends_on: []
scope: broad
@@ -16,12 +16,12 @@ Implement the direct op (`channels/tunnel/direct`, ADR-007) end to
end: the params type + spec builder (`src/params.rs`), the establisher
+ registration (`src/producer.rs`), and the consumer constructor
(`src/consumer.rs`). The op rides the same `alk/tunnel` ALPN (ADR-002
Amendment 1's flavor-form op ids — the upstream discovery derivation
Amendment 1's flavor-form op ids; the upstream discovery derivation
landed in alkcall 0.8.0, ADR-047 amendment 3). This is the ssh
`direct-tcpip` / SOCKS5 CONNECT egress capability with structural ACL
separation from `tunnel:open`.
### params.rs — types + spec
### params.rs: types + spec
```rust
pub const OP_TUNNEL_DIRECT: &str = "channels/tunnel/direct";
@@ -29,7 +29,7 @@ pub const TUNNEL_DIRECT_SCOPE: &str = "tunnel:direct";
/// The substrate-shaped address object (ADR-007 §2): keyed by the
/// sibling `substrate` field of the params. Also the `peer` value
/// shape of the forwarded op (ADR-008 §3) — define once here.
/// shape of the forwarded op (ADR-008 §3); define once here.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SubstrateAddr {
@@ -50,10 +50,10 @@ pub fn tunnel_direct_spec() -> OperationSpec
- Spec per wire.md §The Direct Op: `Sub`-typed, `Visibility::External`,
`ChannelOpenSpec::new(TUNNEL_ALPN)`, scope `["tunnel:direct"]`
(**never implied by `tunnel:open`** — ADR-006 Amendment 1), output
(**never implied by `tunnel:open`**, ADR-006 Amendment 1), output
schema `{channel_id: integer > 0}`.
- `input_schema` pins both fields required and the target sub-schema
per-substrate — a `oneOf` on the sibling `substrate` value
per-substrate, as a `oneOf` on the sibling `substrate` value
(`tcp`/`udp` → `{host, port}`, `unix` → `{path}`), so malformed
targets fail the registry's schema validation (the typed
`INVALID_INPUT` class) **before** the establisher runs. The
@@ -62,9 +62,9 @@ pub fn tunnel_direct_spec() -> OperationSpec
- `deny_unknown_fields` on both params types (the loud-rejection
posture, ADR-001's precedent).
- The `Substrate` enum is reused as the discriminator (no new
substrate values — ADR-001's additive rule).
substrate values (ADR-001's additive rule).
### producer.rs — the establisher + registration
### producer.rs: the establisher + registration
```rust
pub fn direct_establisher(dial: DialFn) -> OpenEstablisher
@@ -82,28 +82,28 @@ pub fn register_tunnel_direct_openable(
`TunnelDirectParams`, renders the target to the same backing-string
form the registry backings use (`"host:port"` for tcp/udp, the path
for unix), and calls the injected `DialFn(substrate, rendered)`.
No second dial type — a producer that registered `dial_tcp` for the
No second dial type: a producer that registered `dial_tcp` for the
base op registers the direct op with zero new substrate code
(ADR-007's `local` interaction note). The UDP framed-adapter wrap
(ADR-003) happens inside the dial closure (the `local` feature's
`connect_udp` already does this) — the establisher stays
`connect_udp` already does this), so the establisher stays
substrate-agnostic.
- **No registry lookup** — `unknown_resource` can never fire
- **No registry lookup**: `unknown_resource` can never fire
(ADR-007 §2). Error mapping (ADR-007 §4's table minus the registry
row): the serde-parse failure of a schema-valid-shaped target maps
`HandlerError` (the schema gate should have caught it — the parse
`HandlerError` (the schema gate should have caught it; the parse
is the backstop); dial failures pass through the `DialFn`'s own
typed errors unchanged (`dial_failed` / `resource_shortage` /
`handler_error`).
- The per-call `auth` carries the opener identity (CF-006) — the
- The per-call `auth` carries the opener identity (CF-006); the
witness seam records it, same as the base op (the direct op is the
arbitrary-egress capability; identity visibility here matters more,
not less).
- Registration uses the SAME pump handler (`make_tunnel_pump_handler`
— the direct op's plan payload is a `TargetHandle` exactly like the
base op's; plan-flow is unchanged).
(the direct op's plan payload is a `TargetHandle` exactly like the
base op's, and plan-flow is unchanged).
### consumer.rs — `open_direct`
### consumer.rs: `open_direct`
```rust
pub async fn open_direct(
@@ -116,27 +116,27 @@ pub async fn open_direct(
- A separate constructor, not a params enum (ADR-007 §5: two scopes
are two capabilities; the caller states which one it holds).
Serializes `{substrate, target}`, `open_channel(OP_TUNNEL_DIRECT, …,
TUNNEL_ALPN)`, `from_halves` — identical session type, data planes,
and teardown API as `open`.
TUNNEL_ALPN)`, `from_halves`, with an identical session type, data
planes, and teardown API as `open`.
- Uses plain `open_channel` (no reply fields expected on the direct
op — dial establishers never bind; wire.md §The Direct Op).
op, because dial establishers never bind; wire.md §The Direct Op).
- Documented error surface: the base open errors minus
`unknown_resource`; malformed targets are the schema/
`invalid_input`-class failure (registry-side); an old producer
rejects the op loudly (`NOT_FOUND` — the SSH posture).
rejects the op loudly (`NOT_FOUND`, the SSH posture).
### Tests
Unit (params.rs): `TunnelDirectParams` round-trip per substrate;
untagged `SubstrateAddr` disambiguation (`{host, port}` vs `{path}`);
`deny_unknown_fields` rejection; spec-shape conformance to wire.md §The
Direct Op (op id, scope, oneOf schema structure — structural
Direct Op (op id, scope, oneOf schema structure; structural
assertions, no jsonschema dep here, per `tunnels/params`'s precedent).
Integration (extend the duplex harness from `tunnels/producer-open-op`):
- `open_direct` e2e round-trip through `pump_bidi` (tcp duplex target).
- `open_direct` udp e2e (the framed adapter wraps at the dial closure —
datagram boundary preserved).
- `open_direct` udp e2e (the framed adapter wraps at the dial closure, so the datagram
boundary is preserved).
- Malformed target (unix without `path`) → schema-class failure, no
phantom session.
- Producer without the direct op registered → `NOT_FOUND`-class.
@@ -175,7 +175,7 @@ Integration (extend the duplex harness from `tunnels/producer-open-op`):
- The `input_schema` pins the per-substrate target sub-schema as two
`if`/`then` branches under `allOf`, keyed on the sibling
`substrate` value (tcp/udp → `{host, port}` with
`additionalProperties: false`; unix → `{path}`, same posture) —
`additionalProperties: false`; unix → `{path}`, same posture);
JSON Schema `if/then` composes through alkcall's jsonschema 0.46
compile, and the integration tests prove the gate fires
(`INVALID_INPUT` before the establisher). Structural assertions
@@ -189,11 +189,11 @@ Integration (extend the duplex harness from `tunnels/producer-open-op`):
`direct_establisher` maps `TunnelEstablishError::HandlerError`).
This asymmetry is documented in both the type docs and the test.
- `TunnelDirectParams::render_target()` centralizes the backing-string
form (`"host:port"` / path) — the establisher injects the SAME
form (`"host:port"` / path); the establisher injects the SAME
`DialFn` with the rendered string; the render is pinned by test to
the registry-backing form.
- `TunnelSession::open_direct` uses plain `open_channel` (no reply
fields — dial establishers never bind); `SubstrateAddr` re-exports
fields, since dial establishers never bind); `SubstrateAddr` re-exports
as the future forwarded-op `peer` shape (ADR-008 §3, defined once).
- Harness: `RegistrationMode::Direct`, `wire_direct` /
`wire_direct_with_identity` (the raw-call topology),
@@ -206,7 +206,7 @@ Integration (extend the duplex harness from `tunnels/producer-open-op`):
Implemented the direct op end to end per ADR-007:
- **params.rs** — `OP_TUNNEL_DIRECT` / `TUNNEL_DIRECT_SCOPE`
- **params.rs**: `OP_TUNNEL_DIRECT` / `TUNNEL_DIRECT_SCOPE`
constants; `SubstrateAddr` (untagged `{host, port}` | `{path}`,
the ADR-008 §3 `peer` shape too); `TunnelDirectParams`
(`deny_unknown_fields`, `render_target()`); `tunnel_direct_spec()`
@@ -215,27 +215,27 @@ Implemented the direct op end to end per ADR-007:
per-substrate `if/then` target sub-schema). 7 unit tests: round-
trips per substrate, untagged disambiguation, loud unknown-field
rejection, spec-shape conformance, schema structure.
- **producer.rs** — `direct_establisher` /
- **producer.rs**: `direct_establisher` /
`direct_establisher_with_witness` (parse → render → injected
`DialFn`; no registry lookup; typed-error mapping per ADR-007 §4
minus the registry row; CF-006 witness seam) +
`register_tunnel_direct_openable` (direct spec + establisher + the
SAME `make_tunnel_pump_handler`, plan-flow unchanged).
- **consumer.rs** — `TunnelSession::open_direct(client, target,
- **consumer.rs**: `TunnelSession::open_direct(client, target,
substrate)`: separate constructor, serializes `{substrate, target}`,
plain `open_channel(OP_TUNNEL_DIRECT, …, TUNNEL_ALPN)`,
`from_halves` — identical session/data-planes/teardown as `open`.
- **lib.rs** — the new surface re-exported.
`from_halves`, with identical session/data-planes/teardown as `open`.
- **lib.rs**: the new surface re-exported.
Tests: `tests/producer_direct_op.rs` — 15 integration tests over the
Tests: `tests/producer_direct_op.rs` has 15 integration tests over the
duplex harness: tcp + udp e2e round-trips through `pump_bidi`
(datagram boundary preserved via the framed dial closure), malformed
targets (`unix` without `path`, `tcp` without `port`, missing target)
→ `INVALID_INPUT` with no phantom channel/session, dial-failure
pass-through (`dial_failed`), NOT_FOUND on a producer without the op,
scope separation both ways (`tunnel:open` ↛ `tunnel:direct` and
`tunnel:direct` ↛ `tunnel:open` — FORBIDDEN, raw-call AND session-
level), CF-006 witness on the direct establisher (raw + session
`tunnel:direct` ↛ `tunnel:open`, all FORBIDDEN (raw-call AND
session-level), CF-006 witness on the direct establisher (raw + session
paths), the establisher parse backstop (`handler_error`), and the
target-rendering pin (the dial closure observes the exact
backing-string form). Harness: `Direct` registration mode +
@@ -243,6 +243,6 @@ backing-string form). Harness: `Direct` registration mode +
Verified: `cargo test` green (93 native; 94 with `--all-features`),
clippy `-D warnings` clean (native + wasm32), fmt clean, wasm32
check passes (default crate stays wasm-clean — no substrate code
check passes (default crate stays wasm-clean; no substrate code was
added), `cargo doc --no-deps` clean. CHANGELOG updated (Unreleased →
Added).
+21 -20
View File
@@ -1,6 +1,6 @@
---
id: tunnels/end-to-end-suite
name: End-to-end suite — forward, reverse, listen, datagram, teardown matrix
name: "End-to-end suite: forward, reverse, listen, datagram, teardown matrix"
status: completed
depends_on: [tunnels/consumer-session, tunnels/producer-listen, tunnels/local-socket-halves]
scope: broad
@@ -15,7 +15,7 @@ tags: [tests, integration, phase-gate]
Consolidate the integration coverage into `tests/` as the crate's
end-to-end suite: every validated POC behavior, re-expressed against the
crate's public API (not the POCs' internals). This is the quality gate
before the implementation review — the suite is the spec's executable
before the implementation review; the suite is the spec's executable
form.
### Suites
@@ -24,8 +24,8 @@ form.
`pump_bidi` → round-trip; 1 MiB backpressure; typed errors
(unknown_resource / dial_failed / FORBIDDEN / timeout-adjacent);
per-call opener identity witness (CF-006).
2. **Reverse (`-R`)**: hub initiator — `open_reverse_channel` +
`adopt` + `pump_against`; odd-ID allocation asserted (ADR-047 §5);
2. **Reverse (`-R`)**: hub initiator (`open_reverse_channel` +
`adopt` + `pump_against`); odd-ID allocation asserted (ADR-047 §5);
identity precedence chain (transport alone / `ServingConfig.
identity` override / token override / identity-less fail-closed);
half-close (W4); out-of-band `channel/close` + self-reaping (W3);
@@ -33,16 +33,17 @@ form.
3. **Listen producer**: the accept-queue flow end-to-end + its typed
errors (from `tunnels/producer-listen`).
4. **Datagram (`udp`)**: forward + reverse datagram sessions via the
codec — round-trip, empty datagram (the F-2 layering), chunk-split
codec: round-trip, empty datagram (the F-2 layering), chunk-split
survival, TCP+UDP concurrent channels on one connection.
5. **Teardown matrix** (ADR-005): close / join (with + without pump) /
Drop — `channel_ids()` asserts no leaks on either side after each.
Drop, with `channel_ids()` asserting no leaks on either side after
each.
6. **`local` feature suite**: real sockets end-to-end (from
`tunnels/local-socket-halves` — runs after that task lands; the
`tunnels/local-socket-halves`; runs after that task lands, though the
duplex-transport suites above do not need it).
Transport stand-in: `tokio::io::duplex` (both POCs' harness; the
alkcall layer owns real transports — unchanged scope). The suite may
alkcall layer owns real transports; unchanged scope). The suite may
share one harness module (`tests/common/`); the POC harnesses
(`/workspace/alktunnels-udp-poc/src/harness.rs`,
`/workspace/alktunnels-reverse-poc/src/harness.rs`) are the two
@@ -50,18 +51,18 @@ topologies to unify.
### The spec-conformance assertions to keep visible
- A failed open never returns a `channel_id` (no phantom channel —
- A failed open never returns a `channel_id` (no phantom channel;
both sides' `channel_ids()` hold only 0 afterward).
- A failed adopt never leaks the session (Drop reaps).
- The pump handler's `JoinHandle` tracks the data plane (a test that
simulates early-return would hang/EOF-instantly — the R-02 shape;
assert the correct shape survives).
simulates early-return would hang/EOF-instantly, which is the
R-02 shape; assert the correct shape survives).
## Acceptance Criteria
- [ ] All 6 suites present; ≥20 integration tests total
- [ ] `cargo test` green; `cargo test --features local` green
- [ ] Repeat-run stable (3× clean — the POCs' flakiness bar)
- [ ] Repeat-run stable (3× clean, the POCs' flakiness bar)
- [ ] Clippy/fmt clean; wasm32 check passes
## References
@@ -84,21 +85,21 @@ task consolidated the remaining POC-validated behaviors they didn't
cover into `tests/end_to_end.rs` (18 tests, the suites as enumerated)
and extended `tests/harness.rs`:
- `RegistrationMode::Timeout(Duration)` — a hanging establisher with
- `RegistrationMode::Timeout(Duration)`: a hanging establisher with
an explicit per-registration establishment timeout (the ADR-049 §2
deadline-expiry probe; the wrapper maps expiry to reason `timeout`
with no channel surviving).
- `wire_serving_identity` — the `ServingConfig.identity` override
- `wire_serving_identity`, the `ServingConfig.identity` override
probe shape (CF-005 (a)).
Gaps the suite filled (behaviors the per-task suites did not cover):
1 MiB backpressure round-trip (both POCs' sizing probe); the
deadline→`timeout` wire mapping; odd-ID allocation asserts (ADR-047 §5)
plus distinctness; the FULL identity precedence chain — the
plus distinctness; the FULL identity precedence chain, including the
`ServingConfig.identity` override and the token-over-transport
precedence with a DISTINCT resolved id (the existing token test
resolved to the same id as the transport identity, so it proved
authorization but not precedence — the new `DistinctTokenProvider`
authorization but not precedence. The new `DistinctTokenProvider`
probe proves precedence); reverse datagram sessions via the codec
(pump-less `(0, 0, reaped)` join shape); 40-datagram bursts with
batching (the pending-queue cover, both directions); the 7-byte
@@ -109,15 +110,15 @@ failed-adopt no-leak arm; R-02's JoinHandle-tracks-the-data-plane
assertion (alive-and-pumpable long after the open, completion only at
both-direction EOF).
### Test-side findings (not product bugs — recorded for the record)
### Test-side findings (not product bugs; recorded for the record)
- **Parked ≠ adopted.** `teardown_channel` on a reverse-open'd but
never-adopted channel id is `UnknownChannel` — the manager parks
never-adopted channel id is `UnknownChannel`; the manager parks
early arrivals for unseen ids (the adoption-race cover, ADR-047 §5)
but does not install a channel entry until adopt. Cleanup for such
ids is adopt-then-close (what the tests do). Worth knowing when an
assembly layer errors between `open_reverse_channel` and `adopt`.
- **`read(&mut Vec)` vs `read(buf)`** — an unpopulated `Vec` passed to
- **`read(&mut Vec)` vs `read(buf)`**: an unpopulated `Vec` passed to
`AsyncReadExt::read` has len 0 and reads nothing (instant EOF); the
1 MiB loop needed a size-`expected` buffer with `&mut received[got..]`.
- The empty-queue-vs-error posture from `tunnels/producer-listen`
@@ -149,6 +150,6 @@ Existing suites untouched. Verification: `cargo test` green (68 tests
incl. unit + doc), `cargo test --features local` green (75), 3×
repeat-run clean (both feature configs), clippy `-D warnings` clean
(all-targets, `--features local`, wasm32), `cargo fmt --check` clean,
wasm32 check passes. No product-code changes were needed — the
wasm32 check passes. No product-code changes were needed; the
failures during development were all test-harness bugs (zero-len read
buffer, teardown-on-parked-id), recorded above.
+25 -25
View File
@@ -1,6 +1,6 @@
---
id: tunnels/fix-udp-framed-adapter
name: "U-1 remediation — make UdpHalf the FRAMED adapter (ADR-003 at the local boundary)"
name: "U-1 remediation: make UdpHalf the FRAMED adapter (ADR-003 at the local boundary)"
status: completed
depends_on: [tunnels/review-impl]
scope: moderate
@@ -15,17 +15,17 @@ completed: 2026-09-08
Review 001 finding U-1 (`docs/reviews/001-implementation-review.md`):
the `local` feature's `UdpHalf` (`src/local/mod.rs`) is NOT the framed
adapter ADR-003 mandates at the substrate boundary — it is
adapter ADR-003 mandates at the substrate boundary: it is
boundary-preserving (one datagram per poll_read/poll_write) but raw.
The `[len: u16 BE]` codec exists only on the session side, so
UDP-over-`local` tunnels:
1. deliver codec-framed bytes to real targets (the target sees
`[len]payload`, not payload — executable-pinned in the review
`[len]payload`, not payload; executable-pinned in the review
probes);
2. tear the tunnel down on an empty datagram (a raw zero-length
datagram is `Ok(())` with 0 bytes filled = EOF-shaped to
`tokio::io::copy` — the F-2 violation end-to-end; the session-side
`tokio::io::copy`, the F-2 violation end-to-end; the session-side
codec exists precisely to prevent this);
3. split datagrams > ~8 KiB at the target (`tokio::io::copy`'s
`DEFAULT_BUF_SIZE` bounds each read; the raw adapter sends each
@@ -34,38 +34,38 @@ UDP-over-`local` tunnels:
codec parses a raw datagram's first two bytes as a length header).
The session-side tests pass because the harness's
`framed_udp_echo_dial` frames symmetrically at the test boundary — a
`framed_udp_echo_dial` frames symmetrically at the test boundary, a
correct-shape stand-in for what `connect_udp` itself must do.
### The fix
Apply ADR-003's placement: `UdpHalf` becomes the framed adapter at the
substrate boundary — **as seen from the pump (the channel side)**:
substrate boundary, **as seen from the pump (the channel side)**:
- `poll_write` is fed CHANNEL bytes (already `[len: u16 BE][payload]`
frames from the session's codec) → **de-frame** (an incremental
`DatagramReader` held on the write half) and send each decoded
payload to the target as one raw datagram. `len=0` decodes to a
zero-payload datagram — a real empty datagram, NOT EOF.
zero-payload datagram, i.e. a real empty datagram, NOT EOF.
- `poll_read` yields bytes destined for the CHANNEL (the session's
codec decodes there) → **frame** (`frame_datagram`) each raw target
datagram and hand the frame bytes to the pump.
Both halves of the association then speak the same framing on both
faces; the target sees payload-only bytes, and an empty datagram
crosses the pump as its 2-byte frame (unambiguous with EOF — the F-2
crosses the pump as its 2-byte frame (unambiguous with EOF, the F-2
fix end-to-end).
**Orientation caution** (the review's §U-1 sketch had this inverted
and was corrected in re-validation — read the review's §Re-validation
and was corrected in re-validation; read the review's §Re-validation
before implementing): "frame on write, de-frame on read" describes
the TARGET's perspective. `UdpHalf`'s caller is the pump; the channel
side is the framed side. Getting the orientation wrong double-frames
the channel→target direction and leaves unframed target→channel
traffic undecodable — the U-1 defects survive the wrong fix.
traffic undecodable; the U-1 defects survive the wrong fix.
The recv scratch (U-3 interplay): the de-frame path needs a FULL
65535-byte recv (tokio's `try_recv` truncates silently — a smaller
65535-byte recv (tokio's `try_recv` truncates silently, so a smaller
scratch corrupts the framing before any size check runs). Any memory
mitigation must keep the full-size read underneath; see U-3 below.
@@ -76,7 +76,7 @@ mitigation must keep the full-size read underneath; see U-3 below.
(session `recv_datagram` yields `Some(b"")`, channel alive).
2. A >8 KiB datagram round-trips as ONE datagram at the target.
3. An unframed target-initiated datagram resolves at the session.
4. The existing `local_halves` suite stays green — EXCEPT
4. The existing `local_halves` suite stays green, EXCEPT
`truncation_fails_loud_oq_tn_13`, whose assertion surface may
legitimately change: once framing composes, a >caller-buffer frame
is a partial-drain read (or an equivalent typed failure) rather
@@ -89,31 +89,31 @@ mitigation must keep the full-size read underneath; see U-3 below.
### Riders (from the same review, same file)
- U-2 (bind posture, sharpened during review follow-up — read the
- U-2 (bind posture, sharpened during review follow-up; read the
whole bullet before implementing): the crate must NOT wildcard-bind
implicitly. Two distinct cases:
- `connect_udp` binds `("0.0.0.0", 0)` — this is a CLIENT socket
- `connect_udp` binds `("0.0.0.0", 0)`: this is a CLIENT socket
(bind-ephemeral → `connect(target)`; the kernel delivers only
datagrams from the connected peer), so its internet-exposure
profile is small — but the v4 hardcoded bind breaks v6-only
profile is small, but the v4 hardcoded bind breaks v6-only
targets at connect, and wildcard binds are a bad default
regardless. Fix: resolve the target first, bind the UNSPECIFIED
address of the MATCHING family (`0.0.0.0:0` for v4 targets,
`[::]:0` for v6). Do NOT bind loopback (`127.0.0.1:0`) — that
`[::]:0` for v6). Do NOT bind loopback (`127.0.0.1:0`); that
would break every non-loopback target (a query to `8.8.8.8` can't
route from a loopback-bound socket). No user-supplied bind address
here unless a caller actually needs source-address selection —
here unless a caller actually needs source-address selection;
keep the signature unless that need exists.
- `bind_tcp` (the listener half) already REQUIRES the caller to
supply the address — that is the correct posture (the assembly
supply the address; that is the correct posture (the assembly
layer decides loopback vs a public interface). Keep it; add a doc
warning that a wildcard bind (`0.0.0.0` / `::`) is the caller's
explicit choice and exposes the listener per the host's firewall.
- Note: `::1` is loopback (the v6 `127.0.0.1`), NOT a wildcard —
- Note: `::1` is loopback (the v6 `127.0.0.1`), NOT a wildcard, as
the risky v6 address is `::`. The rule is "no implicit wildcard
binds", not "no v6 loopback".
- U-3: the per-tunnel 65535-byte recv scratch is correctness-
load-bearing ON THE DE-FRAME PATH — tokio's `try_recv` truncates
load-bearing ON THE DE-FRAME PATH, because tokio's `try_recv` truncates
SILENTLY, so a scratch smaller than the largest in-flight datagram
would corrupt the framing before any size check could reject it
(a truncated frame decodes as a shorter declared length,
@@ -139,19 +139,19 @@ All acceptance criteria met. Key implementation notes beyond the
sketch (pinned in ADR-003's §Amendment):
- **Orientation** (the caution held): de-frame on write, frame on
read — the channel side is the framed side.
read; the channel side is the framed side.
- **The write half accepts ALL offered bytes** and queues completed
datagrams for the socket: tokio's `copy` re-offers a FULL window
unsatisfied until drained (its `read_done` path never tops up), so
the "hold the partial tail, re-feed" shape deadlocked gate 2 (the
9000-byte test) and `Ready(Ok(0))` on a partial-only feed tripped
the copy's `WriteZero` abrupt-close. The `DatagramReader`'s
partial-frame state is bounded by construction (u16 cap — review
partial-frame state is bounded by construction (u16 cap, per review
001 N-2); backpressure is an accepted-byte high-water mark
(128 KiB), and the socket drain pumps on write/flush/shutdown
(copy polls flush after every window drain).
- **A >caller-buffer frame stages across reads** (length prefix
first, remainder on the next read) — gate 4's predicted
first, remainder on the next read), which is gate 4's predicted
assertion-surface change. The fail-loud probe is now the 65507-byte
max datagram reassembling through 16 KiB reads
(`truncation_fails_loud_oq_tn_13`, rewritten) plus the direct
@@ -161,7 +161,7 @@ sketch (pinned in ADR-003's §Amendment):
wildcard-exposure doc warning. Pinned by a real `::1` round-trip
(`connect_udp_binds_the_matching_family_v6_targets_dial`).
- **U-3**: the 65535 scratch allocates lazily on first recv (the
full-size read kept — the MTU-shrink idea confirmed unsafe).
full-size read kept; the MTU-shrink idea confirmed unsafe).
Verification: 63 default / 76 `local` tests green ×3 repeat runs;
clippy `-D warnings` (all-targets + wasm32), fmt, doc, publish
@@ -170,7 +170,7 @@ dry-run, wasm check all clean.
## References
- `docs/reviews/001-implementation-review.md` §U-1 (executable-pinned
probe evidence), §U-2, §U-3, §N-1 — and §Re-validation (2026-09-08:
probe evidence), §U-2, §U-3, §N-1, and §Re-validation (2026-09-08:
findings re-confirmed by fresh probes; the fix orientation
corrected; gates 4 and U-3 adjusted as above)
- `docs/architecture/decisions/003-codec-and-udp-framing.md` (the
+47 -43
View File
@@ -13,7 +13,7 @@ tags: [opener, forwarded-op, serving, adopt, correlation]
## Description
Implement the forwarded op's SERVING side (the listen-opener half,
ADR-008 §3): `register_tunnel_forwarded_openable` — the
ADR-008 §3): `register_tunnel_forwarded_openable`, the
`register_openable` shape (no establisher; the data plane starts
immediately), scope-gated `tunnel:forwarded`, whose handler parses
`TunnelForwardedParams`, hands the correlation material to the
@@ -46,38 +46,40 @@ pub fn register_tunnel_forwarded_openable(
) -> Result<(), String>
```
- **No establisher** (`register_openable` — the forwarded channel
needs no establishment; its data plane starts immediately — ADR-008
- **No establisher** (`register_openable`; the forwarded channel
needs no establishment, since its data plane starts immediately,
ADR-008
§3). The wrapper's `channel_id` allocation IS the reply (the
producer adopts it).
- The built-in handler shape: parse
`TunnelForwardedParams` (`deny_unknown_fields`; a malformed open →
the handler logs + returns early — teardown-at-birth is correct for
the handler logs + returns early, because teardown-at-birth is
correct for
a malformed ingress), accept the `BiStream`, invoke the injected
assembly handler with the parsed params + the stream. The assembly
correlates (via `listen`), adopts nothing (the WRAPPER owns this
channel — the serving side allocated it; the assembly pumps the
channel: the serving side allocated it; the assembly pumps the
BiStream against its local halves via `pump_bidi`, and the wrapper's
R-02 tracking reaps on handler exit).
- **Pump responsibility split (the honest-shape decision to make and
document):** the injected `ForwardedHandler` receives the channel
`BiStream` and the params — the ASSEMBLY owns the pump
`BiStream` and the params; the ASSEMBLY owns the pump
(`pump_bidi(forwarded_bidi, local_read, local_write)`), because the
local halves are assembly state (the SOCKS5 BIND client's socket).
The protocol-side built-in handler only bridges wrapper→assembly.
Await the injected handler's JoinHandle inline (R-02 — the
Await the injected handler's JoinHandle inline (R-02: the
forwarded channel's lifetime tracks it).
- Identity: the opener-side gate sees the PRODUCER's per-call
identity (CF-005 precedence — the producer may carry `auth_token`
identity (CF-005 precedence: the producer may carry `auth_token`
when it is itself forwarding); the `peer` param is
producer-asserted metadata, not transport truth (producer.md's
posture — ACL trusts the grant, app treats `peer` as metadata).
posture: ACL trusts the grant, app treats `peer` as metadata).
The registration's `auth` + the per-call overlay handle this
automatically (upstream machinery); nothing to implement, but the
test suite pins it.
- The `listen`-token correlation failure (no matching session) is the
ASSEMBLY's rejection (it holds the map): the injected handler's
error path — drop the stream, return (teardown-at-birth reaps the
error path drops the stream and returns (teardown-at-birth reaps the
channel). Document that a correlation miss tears the forwarded
channel down; the producer's accept loop drops the accepted
connection on the open's failure (the shapes mirror).
@@ -86,7 +88,7 @@ pub fn register_tunnel_forwarded_openable(
```rust
/// Extract the bind-first reply fields from an open-op success reply
/// (the full output Value — `channel_id` + optional `bound`/`listen`).
/// (the full output Value: `channel_id` + optional `bound`/`listen`).
pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
```
@@ -109,7 +111,7 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
handler logs + early return → the channel tears down (no phantom).
- The reply reader: full/empty/extra-field replies parse per the
additive posture (unit).
- Old-opener posture is upstream's registry lookup (`NOT_FOUND`) —
- Old-opener posture is upstream's registry lookup (`NOT_FOUND`);
pin it in the producer-side test in `tunnels/bindfirst-producer`
(cross-referenced, not duplicated here).
@@ -129,21 +131,21 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§3 the forwarded op — the opener-side serving shape; Amendment 1)
(§3 the forwarded op, the opener-side serving shape; Amendment 1)
- docs/architecture/producer.md §The Forwarded Op; consumer.md §The
Listen Flow; wire.md §The Forwarded Op
- docs/architecture/decisions/006-access-control-posture.md Amendment 1
- tasks/tunnels/bindfirst-params.md (the types);
tasks/tunnels/bindfirst-producer.md (the caller half — the tests
tasks/tunnels/bindfirst-producer.md (the caller half; the tests
compose)
## Notes
- **`ForwardedHandler` (the injected assembly handler):** `Arc<dyn
Fn(TunnelForwardedParams, BiStream) -> tokio::task::JoinHandle<()> +
Send + Sync>` — the correlation material the opener-side handler
Send + Sync>`, the correlation material the opener-side handler
yields per forwarded open. The assembly owns the pump (the honest
split: the local halves — the SOCKS5 BIND client's socket — are
split: the local halves, the SOCKS5 BIND client's socket, are
assembly state), correlating via the `listen` token against its own
waiting-listener map; the protocol provides the op + params, not the
wait state machine (ADR-008 §3's posture, realized).
@@ -151,26 +153,27 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
`TunnelForwardedParams` (`deny_unknown_fields` + the landed
positive-`listen` serde pin), accepts the channel `BiStream`
(yield-once; accepting BEFORE the assembly handler runs keeps the
R-02 accepted-flag telemetry truthful — a parse-gated early return
R-02 accepted-flag telemetry truthful: a parse-gated early return
never accepted, which the wrapper's birth-teardown hint reports),
then awaits the injected handler's `JoinHandle` inline (R-02 — the
forwarded channel's lifetime tracks it; the wrapper's tracking reaps
then awaits the injected handler's `JoinHandle` inline (R-02, since
the forwarded channel's lifetime tracks it; the wrapper's tracking
reaps
on handler exit).
- **No establisher** — `register_openable` exactly (the forwarded
channel needs no establishment; the data plane starts immediately —
- **No establisher**, `register_openable` exactly (the forwarded
channel needs no establishment; the data plane starts immediately,
ADR-008 §3). The wrapper's `channel_id` allocation IS the reply (the
producer adopts it) — nothing to implement, upstream machinery.
producer adopts it): nothing to implement, upstream machinery.
- **Identity posture (upstream machinery, test-pinned):** the
opener-side gate sees the PRODUCER's per-call identity (CF-005
precedence — payload token > `ServingConfig.identity` >
precedence: payload token > `ServingConfig.identity` >
transport); the `peer` param is producer-asserted metadata, not
transport truth. Nothing implemented here; the FORBIDDEN probe
pins the gate runs (identity without the grant → FORBIDDEN with the
missing scope named, never implied by `tunnel:open` — probed with a
missing scope named, never implied by `tunnel:open`; probed with a
`tunnel:open`-only identity).
- **Correlation-miss semantics (documented + test-pinned):** the
`listen`-token miss is the ASSEMBLY's rejection (it holds the map):
the injected handler's error path drops the stream and returns —
the injected handler's error path drops the stream and returns;
teardown-at-birth reaps the forwarded channel. The producer's accept
loop drops the accepted connection on the open's failure (the shapes
mirror; pinned producer-side in `tunnels/bindfirst-producer`'s
@@ -178,31 +181,32 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
- **Reply reader lands in `src/params.rs`** as `listen_reply_fields`
(the lenient, infallible session-surface projection) DELEGATING to
the landed `parse_listen_reply` (the loud typed viewer): unknown
extra reply fields tolerated (the additive posture — no deny-unknown
extra reply fields tolerated (the additive posture; no deny-unknown
gate on the reply), a malformed shape logs `tracing::error!` loud
and surfaces both-`None` (the session presents `Option` fields and
never refuses construction over an informational field; use
`parse_listen_reply` directly when the loud typed distinction
matters). Both `Option` — absent for dial/accept-first producers;
matters). Both `Option`, absent for dial/accept-first producers;
the projection is unit-testable independent of the session type
(this task's placement rationale), consumed by `TunnelListener`
(`tunnels/tunnel-listener`, pending).
- **Test-data-plane shape (the harness):** the e2e drives the
forwarded channel through BOTH pumps — the opener-side assembly
forwarded channel through BOTH pumps: the opener-side assembly
pump pops correlated duplex halves for the token (pushed BEFORE the
forwarded open — the waiting-session posture; a late push is the
miss shape, probed separately) and the producer-side test adopts the
opener-allocated ID and pumps it against a driving echo duplex —
then round-trips bytes from the client end through the whole path
forwarded open, the waiting-session posture; a late push is the
miss shape, probed separately), and the producer-side test adopts
the opener-allocated ID and pumps it against a driving echo
duplex, then round-trips bytes from the client end through the
whole path
(assembly pump → forwarded channel → adopted pump → driving echo →
back). The malformed-params probes pin the no-phantom property at
both gates: `listen: 0` / missing `peer` fail the registry's schema
(`INVALID_INPUT`, no channel ever); an unknown top-level field
passes the schema (additive posture) but fails the serde parse →
passes the schema (additive posture) but fails the serde parse, so
the channel (whose ID the reply already carried) reaps promptly.
- **Harness note:** the suite reuses the existing harness's
`wire_with` + `Topology.consumer_registry` (the
`bindfirst-producer` shape) — no harness changes needed; the
`bindfirst-producer` shape), so no harness changes are needed; the
forwarded-op serving stub helpers (`make_forwarded_stub_handler`
etc.) stay as-is (they remain the producer-leg stub stand-in; the
real opener-side registration is this task's, exercised directly).
@@ -210,30 +214,30 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
## Summary
Implemented the forwarded op's SERVING side (the listen-opener half,
ADR-008 §3 as amended) per the task: `src/producer.rs` —
ADR-008 §3 as amended) per the task: `src/producer.rs` gained
`ForwardedHandler` (the injected assembly handler: parsed params +
channel `BiStream`; the assembly owns the pump, correlating by the
`listen` token against its waiting-listener bookkeeping);
`make_forwarded_op_handler` (the built-in no-establisher wrapper:
deny-unknown params parse — a malformed open fails loud → early
deny-unknown params parse, so a malformed open fails loud → early
return → teardown-at-birth reaps the channel, no phantom; `accept_bi`
FIRST per R-02 telemetry; the injected handler's `JoinHandle` awaited
inline); `register_tunnel_forwarded_openable` (`register_openable` —
inline); `register_tunnel_forwarded_openable` (`register_openable`,
no establisher, the data plane starts immediately; scope gate
`tunnel:forwarded` via `tunnel_forwarded_spec`). `src/params.rs` —
`listen_reply_fields` (the lenient, infallible reply projection:
`tunnel:forwarded` via `tunnel_forwarded_spec`). `src/params.rs`
gained `listen_reply_fields` (the lenient, infallible reply projection:
unknown extra fields tolerated, a malformed shape logs loud and
surfaces both-`None`; shared with `TunnelListener`), delegating to the
landed `parse_listen_reply`. Re-exported through `lib.rs`.
Tests: 4 new unit tests in `params.rs` (full/bare/extra-field/malformed
reply projection) + 4 integration tests in `tests/producer_forwarded.rs`
(the serving-half e2e round-trip gate — reply projection → token
(the serving-half e2e round-trip gate: reply projection → token
correlation → forwarded open → byte round-trip through the assembly
pump + the producer-side adopted pump; the FORBIDDEN scope gate —
pump + the producer-side adopted pump; the FORBIDDEN scope gate:
`tunnel:open`-only identity, no channel allocated, handler never ran;
malformed params — both gates pinned loud, the serde-gated shape
tears down at birth; the correlation-miss teardown — the assembly's
malformed params: both gates pinned loud, the serde-gated shape
tears down at birth; the correlation-miss teardown: the assembly's
rejection, drop the stream, the channel reaps, the invocation
witnessed). Verified: `cargo test` green (126 default; 146
`--all-features`), clippy `--all-targets -D warnings` clean (native +
+41 -41
View File
@@ -1,6 +1,6 @@
---
id: tunnels/local-bind-halves
name: local feature — TCP bind halves + the bind-first assembly pieces
name: local feature, TCP bind halves + the bind-first assembly pieces
status: completed
depends_on: [tunnels/bindfirst-producer]
scope: narrow
@@ -12,13 +12,13 @@ tags: [local, tcp, bind, listener, feature-gated]
## Description
Extend the `local` feature (non-wasm, `tokio/net`-gated —
Extend the `local` feature (non-wasm, `tokio/net`-gated;
`tunnels/local-socket-halves` established the pattern) with the
bind-first shape's substrate half: a real TCP bind function
(`BindFn`-compatible) and an accept loop producing
`(peer, TargetHandle)` pairs, plus the direct-op convenience dial
closure composition (the `DialFn` the direct op shares with the base
op — likely already satisfied by `local_dial`; verify, don't
op, likely already satisfied by `local_dial`; verify, don't
duplicate).
### The bind half
@@ -26,7 +26,7 @@ duplicate).
```rust
/// The real-socket BindFn source (the `local` feature's shape 3
/// backing): bind `addr` (port 0 = OS-chosen), report the REAL
/// bound address (the `bound` reply field's source — ADR-008 §2),
/// bound address (the `bound` reply field's source, ADR-008 §2),
/// expose the accept loop that feeds the dispatcher.
pub async fn bind_tcp_first(addr: &str)
-> Result<BoundListener, TunnelEstablishError>;
@@ -35,28 +35,28 @@ pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first
```
- `BoundListener.listener` carries the `TcpListener` (type-erased in
the plan — `tunnels/bindfirst-producer`'s shape note); the accept
loop is assembly code composed ON it — the protocol crate stays
the plan; `tunnels/bindfirst-producer`'s shape note); the accept
loop is assembly code composed ON it: the protocol crate stays
socket-blind, so the loop lives here (`local` = substrate code,
ADR-004):
- **`TcpListenerHalves` already has an accept loop** (`accept_loop`
feeding an `AcceptQueue` — the accept-first shape). The bind-first
feeding an `AcceptQueue`, the accept-first shape). The bind-first
shape needs a DIFFERENT loop: per-accept it must produce
`(peer_addr, TargetHandle)` for the accept dispatcher (which opens
forwarded channels per accept), not feed a queue for the
establisher to pop. Add a sibling method/struct (e.g.
`TcpListenerHalves::accept_pairs` or a new
`BindFirstAcceptLoop` adapter) — decide the honest shape, document
`BindFirstAcceptLoop` adapter); decide the honest shape, document
in Notes. The peer address rides from `accept()`'s return value
(reply#2 fidelity's source — ADR-008 §3's `peer` carrier).
(reply#2 fidelity's source, ADR-008 §3's `peer` carrier).
- The direct-op `DialFn`: `local_dial()` already renders
`(Substrate, backing)` → halves with the UDP framed adapter — the
`(Substrate, backing)` → halves with the UDP framed adapter; the
direct establisher calls the SAME closure with the rendered target
string. Verify the UDP wrap happens INSIDE the closure (it does —
string. Verify the UDP wrap happens INSIDE the closure (it does;
review 001 U-1) so direct-udp gets the framed adapter free; add a
test pinning direct-udp-through-`local_dial` framing if missing.
### Tests (`tests/`, real sockets — the `local` suite's posture)
### Tests (`tests/`, real sockets; the `local` suite's posture)
- Bind port 0 → `bound` reports the REAL OS-chosen address (the
reply#1 fidelity pin).
@@ -64,17 +64,17 @@ pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first
channels (via the bind-first harness) → both pump independently
(peer fidelity: each forwarded open carries the REAL accepted
peer).
- Unix-domain bind (OQ-TN-14 parity): `bind_unix_first`-equivalent —
decide whether unix bind-first ships in this task or is deferred
- Unix-domain bind (OQ-TN-14 parity): `bind_unix_first`-equivalent.
Decide whether unix bind-first ships in this task or is deferred
with a note (the wire enum already carries unix; the local dial
path exists — the bind path is the same shape; prefer shipping
path exists, and the bind path is the same shape; prefer shipping
both, matching dial parity).
## Acceptance Criteria
- [x] `bind_tcp_first` + the accept-pairs loop (peer + halves per
accept) behind `local`
- [x] The bound address is the REAL OS-chosen one (port 0 resolved —
- [x] The bound address is the REAL OS-chosen one (port 0 resolved,
pinned by test)
- [x] Direct-op `DialFn` reuse verified (no duplicate dial code;
UDP framed-adapter wrap composes)
@@ -87,96 +87,96 @@ pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§1 shape 3 — the bind-first establisher; §3 the peer carrier)
(§1 shape 3, the bind-first establisher; §3 the peer carrier)
- docs/architecture/producer.md §The Registration API (`BindFn` shape)
- tasks/tunnels/local-socket-halves.md (the `local` pattern + the
existing `bind_tcp`/`TcpListenerHalves`/`AcceptQueue` accept-first
shapes)
- tasks/tunnels/bindfirst-producer.md (the `BindFn`/
`BindFirstAcceptLoop` contracts this implements)
- OQ-TN-14 (unix ships with `local` v1; stdio out — alktty's)
- OQ-TN-14 (unix ships with `local` v1; stdio is out, as in alktty's)
## Notes
- **The task sketch's `bind_tcp_first(addr: &str)` signature held** —
- **The task sketch's `bind_tcp_first(addr: &str)` signature held**:
the committed [`BindFn`] contract takes `(substrate, backing)`; the
substrate dispatch lives inside [`local_bind_first`] (tcp →
[`bind_tcp_first`], unix → [`bind_unix_first`], udp → fails loud),
matching the type-erased closure precedent (the protocol crate stays
socket-blind; the closure is the assembly side).
- **The accept-pairs shape: a `*_pair` method, not a sibling loop
struct** — [`TcpListenerHalves::accept_pair`] (one accept per call →
struct**: [`TcpListenerHalves::accept_pair`] (one accept per call →
`(peer, halves)`). The task floated `accept_pairs` or a
`BindFirstAcceptLoop` adapter struct; one method is the honest shape:
the DISPATCHER owns the loop pacing (the committed
`BindFirstAcceptLoop` contract is one closure call = one accept), so
the substrate half is just the single-accept step with the peer
attached. `local_bindfirst_accept_loop()` is the adapter — it wraps
attached. `local_bindfirst_accept_loop()` is the adapter: it wraps
the `*_pair` methods with the downcast (the assembly side of the
type-erased injection; a foreign listener type fails loud as
`handler_error` — pinned by test). The accept-FIRST
`handler_error`, pinned by test). The accept-FIRST
[`TcpListenerHalves::accept_loop`] (queue-feeding) is untouched.
- **UDP bind-first is NOT here — refused loud** (a decision the task
- **UDP bind-first is NOT here: refused loud** (a decision the task
left open): a UDP socket has no accept, so the bind-first shape is
not DEFINED for udp; [`local_bind_first`] maps `Substrate::Udp` to a
`handler_error` rather than faking a listener (pinned by
`udp_bind_first_fails_loud`). The per-peer associate shape (an
accept-analogue for UDP) would be a wire/protocol design decision —
an ADR, if it ever ships. The direct/forward udp dial shapes cover
accept-analogue for UDP) would be a wire/protocol design decision,
an ADR if it ever ships. The direct/forward udp dial shapes cover
the substrate.
- **Unix bind-first SHIPPED** (the task's preferred posture — OQ-TN-14
dial parity): `bind_unix` + `UnixListenerHalves` (a sibling of
- **Unix bind-first SHIPPED** (the task's preferred posture,
OQ-TN-14 dial parity): `bind_unix` + `UnixListenerHalves` (a sibling of
[`TcpListenerHalves`]: `local_addr`, the queue-feeding
`accept_loop`, and the bind-first `accept_pair`) +
[`bind_unix_first`]. The bind-first `accept_pair`'s peer is the
ACCEPTED client's bound path; an unnamed (autobind) client fails
loud — the forwarded op's `peer` param is required on the wire, an
loud: the forwarded op's `peer` param is required on the wire, an
addressless peer cannot be represented (ADR-008 §3). The unix
parity test binds the CLIENT to a named path first (socket2 — std
parity test binds the CLIENT to a named path first (socket2; std
has no `UnixStream::bind`), making the peer representable.
- **`bind_unix` maps bind failure to `ResourceShortage`** (matching
[`bind_tcp`]'s posture — the held-resource class), not
[`bind_tcp`]'s posture, the held-resource class), not
`DialFailed`: the bind IS the dial for the establisher's error table
(shape-3: unbindable/held → `dial_failed`-class rides the
establisher's passthrough), but the substrate function's own class
follows the sibling bind functions.
- **Direct-op `DialFn` reuse: verified, no duplication** —
- **Direct-op `DialFn` reuse: verified, no duplication**:
[`local_dial`] is unchanged and shared; the UDP wrap happens INSIDE
the closure (review 001 U-1). The missing pin was added:
`direct_udp_through_local_dial_round_trips_framed` (tests/
local_halves.rs) drives `TunnelSession::open_direct` +
`Substrate::Udp` through [`local_dial`] against a REAL udp echo
socket — datagram boundary preserved through the direct op's
socket, with the datagram boundary preserved through the direct op's
establisher path.
## Summary
Implemented the `local` feature's bind-first substrate half (shape 3,
ADR-008 as amended) per the task: `src/local/mod.rs` — `bind_tcp_first`
ADR-008 as amended) per the task: `src/local/mod.rs` now has `bind_tcp_first`
(the real-socket [`BindFn`] source: port 0 resolved via `local_addr`,
the `bound` reply field's data), `TcpListenerHalves::accept_pair` (one
accept → `(peer, halves)` — the peer rides `accept()`'s return, the
accept → `(peer, halves)`, with the peer riding `accept()`'s return, the
forwarded op's `peer` carrier), `bind_unix` + `UnixListenerHalves` +
`bind_unix_first` (unix bind-first parity SHIPPED — OQ-TN-14; unnamed
`bind_unix_first` (unix bind-first parity SHIPPED per OQ-TN-14; unnamed
peer fails loud, the forwarded `peer` param requires an address),
`local_bind_first()` (the [`BindFn`] convenience: tcp/unix dispatch,
udp fails loud — no accept on a UDP socket, the shape is not defined
udp fails loud: no accept on a UDP socket, the shape is not defined
there, never a fake listener) and `local_bindfirst_accept_loop()` (the
[`BindFirstAcceptLoop`] convenience: downcasts the plan's opaque
listener to the module's wrappers, one pair per call; a foreign
listener fails loud). The accept-FIRST shapes (`accept_loop` +
`AcceptQueue`) are untouched.
Tests: `tests/local_bindfirst.rs` (4, `#![cfg(feature = "local")]`) —
the tcp e2e over REAL sockets (bind port 0 → the reply's `bound` is
the REAL OS-chosen address the test then DIALS — a fake bound would
Tests: `tests/local_bindfirst.rs` (4, `#![cfg(feature = "local")]`),
covering the tcp e2e over REAL sockets (bind port 0 → the reply's `bound` is
the REAL OS-chosen address the test then DIALS; a fake bound would
strand the dial; two clients → two accepts → two forwarded channels →
independent interleaved pumps, each forwarded open carrying the REAL
accepted peer, distinct ports), unix parity (real socket path in
`bound`; a named-path client connects; the forwarded open carries the
REAL peer path), the udp refusal, and the downcast-miss probe. Plus
`direct_udp_through_local_dial_round_trips_framed` in tests/
local_halves.rs — the direct-op `DialFn` reuse pin (the framed udp
local_halves.rs, the direct-op `DialFn` reuse pin (the framed udp
adapter composes through the direct op; review 001 U-1).
Verified: `cargo test` (default) and `cargo test --all-features` green
@@ -184,4 +184,4 @@ Verified: `cargo test` (default) and `cargo test --all-features` green
`--all-targets --all-features -D warnings` clean, fmt clean,
`wasm32-unknown-unknown` check passes (the default crate stays
wasm-clean; the bind-first half lives behind `local`, non-wasm by
design).
design).
+16 -15
View File
@@ -1,6 +1,6 @@
---
id: tunnels/local-socket-halves
name: "local feature — real socket halves functions (TCP dial, UDP connect, unix)"
name: "local feature: real socket halves functions (TCP dial, UDP connect, unix)"
status: completed
depends_on: [tunnels/producer-open-op, tunnels/wire-codec]
scope: moderate
@@ -27,10 +27,10 @@ pub async fn dial_tcp(target: &str) -> Result<TargetHandle, TunnelEstablishError
pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishError>;
// UdpSocket::bind(("0.0.0.0", 0)) → connect(target) → the FRAMED adapter
// (UdpHalf + codec — ADR-003; truncation fail-loud per OQ-TN-13)
// (UdpHalf + codec per ADR-003; truncation fails loud per OQ-TN-13)
pub async fn dial_unix(path: &str) -> Result<TargetHandle, TunnelEstablishError>;
// UnixStream::connect → into_split (OQ-TN-14: ships with local v1 — cheap,
// UnixStream::connect → into_split (OQ-TN-14: ships with local v1; it is cheap,
// same halves shape as TCP)
pub struct TcpListenerHalves { listener: tokio::net::TcpListener }
@@ -45,10 +45,11 @@ pub struct UdpAssociateHalves { sock: Arc<UdpSocket> } // the POC's associate s
- **UDP adapter (`UdpHalf`)**: port from the forward POC
(`params.rs`'s `UdpHalfRead`/`UdpHalfWrite`) with the truncation fix:
`poll_recv` into a too-small caller buffer must fail loud
(OQ-TN-13's resolved posture — ADR-003). The codec wraps at this
(OQ-TN-13's resolved posture, i.e. ADR-003). The codec wraps at this
boundary; the pump never sees UDP specifics.
- **Unix**: `dial_unix` ships (OQ-TN-14 resolved — same halves shape
as TCP). **Stdio does NOT** — a spawned process's stdio is alktty's
- **Unix**: `dial_unix` ships (OQ-TN-14 resolved, with the same
halves shape as TCP). **Stdio does NOT**, because a spawned
process's stdio is alktty's
pipe mode (exit codes + signals are tty semantics, not tunnel
semantics); remote command execution composes via alktty, not here.
- **Error mapping** to `TunnelEstablishError` variants (the POC's
@@ -67,11 +68,11 @@ local = ["tokio/net"]
- `tokio/net` is the only dep addition (no new external crates; the
POCs proved `rt+sync+io-util+macros+time+net` covers everything).
- The default crate stays wasm-clean: `local`-gated code must not be
importable from `producer.rs`/`consumer.rs` (convention 16 — backend
importable from `producer.rs`/`consumer.rs` (convention 16: backend
modules are never imported from the shared/producer/consumer
modules).
- Unix is IN v1 (OQ-TN-14's lean-yes posture; same halves shape as
TCP); stdio bridging is NOT (different lifecycle — deferred).
TCP); stdio bridging is NOT (a different lifecycle, deferred).
### Tests
@@ -106,22 +107,22 @@ POC's sizing test).
- **`bind_tcp` + `TcpListenerHalves::accept_loop(queue)`** complete the
listen side (the task surface sketched `bind`/`accept_fn`): the
accept loop is a spawned task feeding an [`AcceptQueue`] — the
accept loop is a spawned task feeding an [`AcceptQueue`], the
protocol-side queue contract from producer-listen. The assembly
layer aborts the task on teardown; accept errors end the loop.
- **`UdpHalf` truncation probe** (OQ-TN-13): `poll_read` recvs into a
64KiB scratch buffer first, then size-checks against the caller's
buffer — a >buffer datagram is `io::ErrorKind::InvalidData`, never
buffer, so a >buffer datagram is `io::ErrorKind::InvalidData`, never
a silent truncation. (`tokio`'s `poll_recv`-into-ReadBuf truncates
silently, which is exactly the invariant F-2/OQ-TN-13 forbids.)
WouldBlock polls `poll_recv_ready` for waker registration.
- **The task sketch's `TcpListenerHalves::accept_fn(&self, queue)`
became `accept_loop(&self, queue)`** (an owned async loop to spawn)
— the `accept_fn` borrow-shape fought the spawn model; the loop
; the `accept_fn` borrow-shape fought the spawn model, while the loop
returning on listener failure is the assembly-abort contract.
- **Test plumbing note** (tests): the listen-over-real-socket test
drives the client socket via std `try_clone` BEFORE `from_std`
(O_NONBLOCK is shared across dup — both ends go async); the session
(O_NONBLOCK is shared across dup, so both ends go async); the session
uses borrowed halves (the producer's wrapper-managed pump already
pumps channel <-> accepted handle; `pump_against` there would loop
the same socket back into itself).
@@ -132,7 +133,7 @@ POC's sizing test).
Implemented the `local` feature (src/local/mod.rs, feature =
`["tokio/net"]`): `dial_tcp` (5s timeout), `connect_udp` (ephemeral
bind + connect + the FRAMED `UdpHalf` adapter — truncation fails
bind + connect + the FRAMED `UdpHalf` adapter (truncation fails
loud), `dial_unix` (OQ-TN-14 in v1), `bind_tcp`/`TcpListenerHalves::
accept_loop` (the listen shape's assembly half), and `local_dial()`
(the one `DialFn` covering all three substrates). No socket type
@@ -141,8 +142,8 @@ imported from producer/consumer/shared; `TargetHandle` re-used from
producer.rs). Stdio bridging is NOT here (alktty's pipe mode owns
process stdio).
Tests: `tests/local_halves.rs` (7, `#![cfg(feature = "local")]`) —
TCP dial round-trip through the real establisher path, UDP framed
Tests: `tests/local_halves.rs` (7, `#![cfg(feature = "local")]`) with
the TCP dial round-trip through the real establisher path, UDP framed
round-trip, the 1400-byte MTU datagram, unix dial round-trip, the
OQ-TN-13 truncation fail-loud probe (60k datagram vs 16KiB read →
InvalidData), dial-refusal → `dial_failed`, and the listen producer
+12 -9
View File
@@ -14,8 +14,8 @@ tags: [wire, params, open-op]
Implement `src/params.rs` per ADR-001 + wire.md §The Open Op: the
`TunnelParams` wire type, the `Substrate` discriminator, and the open-op
`OperationSpec` builder. This is the wire-stable surface (one-way door) —
exact conformance to the ADR is the point.
`OperationSpec` builder. This is the wire-stable surface (a one-way
door), so exact conformance to the ADR is the point.
### Types
@@ -35,13 +35,14 @@ pub enum Substrate { Tcp, Udp, Unix }
- `deny_unknown_fields` is the loud-rejection posture (ADR-001: unknown
fields fail schema validation, never silently ignored).
- `Substrate::Unix` is included in the type AND in the open-op schema's
enum list v1 (`["tcp", "udp", "unix"]` — OQ-TN-14 resolved: unix has
enum list v1 (`["tcp", "udp", "unix"]`; OQ-TN-14 resolved, so unix
has
an implementation path via the `local` feature). The schema list is
the authoritative gate for newer-substrate negotiation: an older
producer rejecting a newer substrate value is the SSH "unknown
channel type" posture (loud, not silent).
### The open-op spec (in producer.rs or params.rs — put it where
### The open-op spec (in producer.rs or params.rs; put it where
`tunnels/producer-open-op` can consume; the spec builder lives in
params.rs per the module map, producer.rs re-exports)
@@ -56,8 +57,8 @@ pub fn tunnel_open_spec() -> OperationSpec
- `OperationType::Sub`; `Visibility::External`; channel-open marker
`ChannelOpenSpec::new(TUNNEL_ALPN)`.
- `input_schema`: `{resource: string (required), substrate: string enum
["tcp","udp","unix"] (required)}` — JSON Schema Draft shape as the
POCs used (unix in the list per OQ-TN-14's resolution).
["tcp","udp","unix"] (required)}`, which is the JSON Schema Draft
shape as the POCs used (unix in the list per OQ-TN-14's resolution).
- `output_schema`: `{channel_id: integer > 0}`.
- `AccessControl.required_scopes: [TUNNEL_OPEN_SCOPE]` (ADR-006).
- `description`: a one-line human hint (round-trips through discovery;
@@ -100,7 +101,8 @@ errors (filled incrementally by later tasks); plus the re-export of
enum). `Eq` added to `TunnelParams` (test equality).
- `tunnel_open_spec()` lives in params.rs per the module map;
producer.rs re-exports. Output schema pins `channel_id` with
`minimum: 1` + `required` (wire.md: integer > 0) — the POC's
`minimum: 1` + `required` (wire.md says integer > 0), which corrects
the POC's
`minimum: 0` / no-required was loose.
- `establishment_reason` delegates to alkcall 0.7.0's
`ChannelOpenError::establishment_reason` method (upstream has it);
@@ -108,7 +110,7 @@ errors (filled incrementally by later tasks); plus the re-export of
re-exports through it).
- Schema-validation tests use the `TunnelParams` deserializer as the
enum validator (same deny-unknown/enum posture as the registry's
JSON Schema check; no jsonschema dep added for v1 — the
JSON Schema check; no jsonschema dep added for v1, and the
input_schema string is asserted structurally in
`open_spec_matches_wire_md`).
- 6 unit tests: round-trip ×3 substrates, unknown-field rejection,
@@ -123,4 +125,5 @@ External visibility, input/output schemas with the 3-value substrate
enum, `tunnel:open` ACL, channel-open marker, description),
constants, and the `establishment_reason` helper. Verified: `cargo
test` (6 passed), clippy --all-targets -D warnings (native + wasm32),
fmt --check, wasm32 check — all clean.
fmt --check, and wasm32
check are all clean.
+15 -14
View File
@@ -1,6 +1,6 @@
---
id: tunnels/producer-listen
name: Listen establisher — the producer-side listener variant
name: Listen establisher, the producer-side listener variant
status: completed
depends_on: [tunnels/producer-open-op]
scope: narrow
@@ -15,7 +15,7 @@ tags: [producer, establisher, listen, reverse]
Implement the listen establisher shape per producer.md §The Establisher
(shape 2): a producer whose resource is a LISTENER (SSH `-R` far-side
listener; a hub exposing its own port to a third party). Same open op,
same params, same typed errors — the establisher pops the next accepted
same params, same typed errors; the establisher pops the next accepted
connection from an assembly-owned listener queue instead of dialing.
No new wire surface (ADR-004).
@@ -36,8 +36,8 @@ pub fn listen_establisher(registry: ResourceRegistry, accept: AcceptFn) -> OpenE
- Error mapping (producer.md's table): empty queue during
establishment → `resource_shortage`; closed listener →
`dial_failed`; registry miss → `unknown_resource` (same as dial).
- The pump handler is UNCHANGED (the same `make_tunnel_pump_handler`) —
the listen variant is plan-flow with a different halves source.
- The pump handler is UNCHANGED (the same `make_tunnel_pump_handler`),
since the listen variant is plan-flow with a different halves source.
### Assembly-side listener helper (the `local`-gated half lands in
`tunnels/local-socket-halves`; here, define the protocol-side queue
@@ -52,7 +52,7 @@ impl AcceptQueue {
```
- `pop` during establishment: bounded (the establisher's own deadline
applies — ADR-049 §2); `None` → `resource_shortage`.
applies; ADR-049 §2). `None` → `resource_shortage`.
### Tests
@@ -67,7 +67,7 @@ queue → `resource_shortage`, closed listener → `dial_failed`.
- [ ] `listen_establisher` + `AcceptFn` + the queue contract compile
and wire through `register_tunnel_openable`'s shape (a listen
variant of the registration or a dial/accept enum — pick the
variant of the registration or a dial/accept enum (pick the
honest shape, document it)
- [ ] The pump handler is untouched (same function registered for
listen producers)
@@ -81,7 +81,7 @@ queue → `resource_shortage`, closed listener → `dial_failed`.
- docs/architecture/decisions/004-no-backend-trait.md (the listen
variant rationale)
- Reverse POC (the hub's accept loop stands in for the assembly
listener — `docs/research/reverse-poc-summary.md` §The -R template)
listener; see `docs/research/reverse-poc-summary.md` §The -R template)
## Notes
@@ -89,7 +89,7 @@ queue → `resource_shortage`, closed listener → `dial_failed`.
- **Registration shape (the "pick the honest shape" decision):** a
separate `register_tunnel_listen_openable(core, registry, on_registry,
auth, accept: AcceptFn)` — not a dial/accept enum on
auth, accept: AcceptFn)`, not a dial/accept enum on
`register_tunnel_openable`. Rationale: the establisher is the only
difference; `register_openable_with_establisher` takes a pre-built
`OpenEstablisher`, so both variants share the same spec + pump
@@ -99,17 +99,17 @@ queue → `resource_shortage`, closed listener → `dial_failed`.
not need.
- **`AcceptQueue::push` is async** (lock-acquiring): the accept loop's
step is async anyway; a sync `push` would need a sync mutex with
cross-thread notify — not worth it for a queue that drains one
handle per open.
cross-thread notify, which is not worth it for a queue that drains
one handle per open.
- **Empty-queue posture is assembly-owned, not queue-owned:** an
empty queue during establishment is NOT inherently an error (a late
accept is legitimate — `accept_wait_resolves_when_push_arrives_late`
accept is legitimate (`accept_wait_resolves_when_push_arrives_late`
pins the wait-then-resolve shape). The mapping to
`resource_shortage` belongs to the accept closure (the assembly
knows its listener's bound: drained listener, accept budget); the
wrapper's establishment timeout (10s) is the backstop. The task
sketch's "empty queue → resource_shortage" is realized by the
assembly mapping — the test pins the fail-fast closure posture.
assembly mapping, and the test pins the fail-fast closure posture.
- **`closed_listener → dial_failed`** is likewise the closure's
mapping (the queue's `close()` + pop → `None` → the closure maps).
The typed-error table holds: both reasons verified on the wire via
@@ -127,12 +127,13 @@ Establisher: `AcceptFn` (the injected accepted-connection source),
`AcceptQueue` (the protocol-side queue contract: async push/pop/
close, FIFO with always-before-take ordering, close-while-waiting
resolves None), `listen_establisher` (registry namespace gate →
accept() → `Establishment::new(plan)` — the same plan flow, a
accept() → `Establishment::new(plan)`; the same plan flow, but a
different halves source; the pump handler is untouched), and
`register_tunnel_listen_openable` (the same spec/pump registration,
listen establisher).
Tests: `tests/producer_listen.rs` (6) — the listen flow end-to-end
Tests: `tests/producer_listen.rs` (6), covering the listen flow
end-to-end
(pre-accepted handle round-trips through the pump), FIFO ordering
across two opens (the R-01 plan-flow property, listen-flavored),
empty queue → `resource_shortage` (fail-fast closure posture pinned),
+27 -27
View File
@@ -1,6 +1,6 @@
---
id: tunnels/producer-open-op
name: Producer half — establisher (dial shape) + pump handler + register_tunnel_openable
name: Producer half, establisher (dial shape) + pump handler + register_tunnel_openable
status: completed
depends_on: [tunnels/params, tunnels/wire-codec]
scope: broad
@@ -14,14 +14,14 @@ tags: [producer, establisher, open-op, pump]
Implement `src/producer.rs` per producer.md: the dial-shape establisher,
the substrate-agnostic pump handler, and the registration surface. This
is the crate's core — the open op that both POCs validated (the forward
is the crate's core: the open op that both POCs validated (the forward
POC's producer on the accept side; the reverse POC's worker on the
connect side). Port from the REVERSE POC first
(`/workspace/alktunnels-reverse-poc/src/producer.rs`) — it is the 0.6/0.7
idiomatic shape (plan flow, no handoff) and its 16 tests are the
reference suite.
(`/workspace/alktunnels-reverse-poc/src/producer.rs`), which is the
0.6/0.7 idiomatic shape (plan flow, no handoff) and whose 16 tests are
the reference suite.
### The establisher (dial shape — the listen shape is a separate task)
### The establisher (dial shape, with the listen shape as a separate task)
```rust
pub type DialFn = Arc<
@@ -29,21 +29,21 @@ pub type DialFn = Arc<
+ Send + Sync,
>;
// TargetHandle: boxed halves (read: Box<dyn AsyncRead + Send + Sync + Unpin>, write: ...),
// or the framed UDP adapter (the establisher wraps BEFORE returning the plan —
// or the framed UDP adapter (the establisher wraps BEFORE returning the plan per
// ADR-003's placement; the pump stays substrate-agnostic)
```
- `tunnel_establisher(registry: ResourceRegistry, dial: DialFn,
identity_witness: Option<...>) -> OpenEstablisher` — the assembly layer
identity_witness: Option<...>) -> OpenEstablisher`; the assembly layer
injects the substrate dial function (ADR-004); the establisher:
1. Parses params (`TunnelParams` — schema-validated upstream, semantic
1. Parses params (`TunnelParams`: schema-validated upstream, semantic
parse here).
2. Registry lookup `(resource, substrate)` → backing address —
2. Registry lookup `(resource, substrate)` → backing address,
`unknown_resource` on miss (typed reason mapping per ADR-001's
table).
3. `dial(substrate, backing)` → the handle (the closure owns real
socket code — behind `local` or assembly-constructed).
4. UDP: wrap in the framed adapter (ADR-003) — `UdpHalf` + codec,
socket code, behind `local` or assembly-constructed).
4. UDP: wrap in the framed adapter (ADR-003): `UdpHalf` + codec,
truncation fail-loud (OQ-TN-13).
5. Return `Ok(Establishment::new(Arc::new(handle)))` (R-01 plan flow).
- `ResourceRegistry`: the assembly-owned
@@ -56,14 +56,14 @@ pub type DialFn = Arc<
pub fn make_tunnel_pump_handler() -> OpenHandler
```
1. Downcast the plan to `TargetHandle` (`Arc::downcast` — error path:
log + return; the birth-teardown telemetry catches it).
1. Downcast the plan to `TargetHandle` (`Arc::downcast`; the error
path logs and returns, and the birth-teardown telemetry catches it).
2. `conn.accept_bi()` (yield-once).
3. `pump_bidi(bidi, t_read, t_write)` — **awaited inline** (R-02: the
3. `pump_bidi(bidi, t_read, t_write)`, **awaited inline** (R-02: the
returned `JoinHandle` tracks the data-plane lifetime; early return =
teardown-at-birth). The handler is substrate-agnostic by
construction — it must not know what the halves came from
(ADR-004).
construction (it must not know what the halves came from, per
ADR-004).
### Registration
@@ -89,7 +89,7 @@ Port the reverse POC's test topology (`harness.rs`: worker =
`from_connection_with_serving` + `ChannelOperations::register_on` +
post-hoc openable; hub = adapter + capturing install hook) into
`tests/` with the `dial` closure standing in for real sockets
(in-process duplex halves — no `local` feature needed). Cover:
(in-process duplex halves, so no `local` feature is needed). Cover:
establishment success + typed errors (unknown_resource / dial_failed /
FORBIDDEN / timeout-adjacent), the plan flow under same-resource
concurrency, per-call opener identity (CF-006 witness),
@@ -105,7 +105,7 @@ late-registration visibility, pump round-trip through `pump_bidi`.
- [x] Integration tests: forward AND reverse topology (the harness
shapes from both POCs), ≥8 tests covering the above
- [x] Clippy/fmt clean; wasm32 check passes (dial closures are
runtime-injected — the crate compiles without `local`)
runtime-injected, so the crate compiles without `local`)
- [x] `cargo test` green
## References
@@ -120,16 +120,16 @@ late-registration visibility, pump round-trip through `pump_bidi`.
- Ported from the reverse POC's producer.rs (the 0.7.0-idiomatic plan
flow, R-01) with the crate generalizations:
- The dial closure is INJECTED (`DialFn`) — the POC had real socket
- The dial closure is INJECTED (`DialFn`): the POC had real socket
code inline (tokio TCP/UDP); the crate's establisher is
substrate-agnostic and the `local` feature (or the assembly layer)
supplies the closure (ADR-004's inversion point, tested via
in-process duplex echo + failing dials).
- `register_tunnel_openable` takes `dial` + `identity_witness` (the
CF-006 probe seam) — the POC's registration closure had no dial
CF-006 probe seam); the POC's registration closure had no dial
injection.
- `ResourceRegistry` keys on `(String, Substrate)` (the enum, not a
&'static str — `Hash` added to `Substrate`).
`&'static str`; `Hash` added to `Substrate`).
- `TargetHandle` carries `+ Sync` halves (the F-1 plan-payload
bound).
- Harness (tests/harness.rs): the reverse POC's topology (producer =
@@ -139,16 +139,16 @@ late-registration visibility, pump round-trip through `pump_bidi`.
`call_with_payload` on channel 0 and adopts the producer-allocated
ID.
- 11 integration tests: round-trip through `pump_bidi` (wrapper reaps
the producer channel on pump completion — R-02), unknown_resource +
the producer channel on pump completion, per R-02), unknown_resource +
dial_failed typed errors (no phantom channel either side), FORBIDDEN
identity-less (fails closed), transport-identity open (CF-005 (b)),
token precedence (CF-005 (a) + the CF-006 witness), same-resource
concurrency (no plan race), late registration (W2), unknown
substrate schema rejection (INVALID_INPUT — the registry's
substrate schema rejection (INVALID_INPUT; the registry's
schema-validation runs before the establisher), outbound-calls-
resolve-while-serving (ADR-022 §2), substrate-keyed registry lookups.
- alkcall 0.7.0 note: the establisher's per-call `auth` carries the
dispatch-resolved identity (CF-005 corollary) — the witness proves
dispatch-resolved identity (CF-005 corollary), and the witness proves
the overlay; `INVALID_INPUT` is the wire code for a schema failure
(distinct from `channel:open_failed` establishment failures).
- `futures` crate used for `BoxFuture` in `DialFn` (already a dep).
@@ -161,6 +161,6 @@ pump handler (`pump_bidi` awaited inline, R-02), and
`register_tunnel_openable` (spec + establisher + pump + dial
injection + witness). 14 unit tests (params + codec) + 11 integration
tests over the duplex harness pass. Verified: cargo test, clippy
--all-targets -D warnings (native + wasm32), fmt --check, wasm32
check — all clean. The listen establisher variant lands with
--all-targets -D warnings (native + wasm32), fmt --check, and the
wasm32 check are all clean. The listen establisher variant lands with
`tunnels/producer-listen`.
+25 -25
View File
@@ -1,6 +1,6 @@
---
id: tunnels/review-002-coverage-gaps
name: "G-4 + G-5 — close the remaining cheap coverage gaps (unix accept-loop test, unnamed unix fail-loud guards)"
name: "G-4 + G-5: close the remaining cheap coverage gaps (unix accept-loop test, unnamed unix fail-loud guards)"
status: completed
depends_on: []
scope: narrow
@@ -14,40 +14,40 @@ tags: [tests, local, unix, coverage, review-002, review-remediation]
The two remaining cheap-to-close coverage gaps from review 002's
coverage appendix (`docs/reviews/
002-graduation-implementation-review.md`), batched — both are
002-graduation-implementation-review.md`), batched: both are
`local`-feature test additions (no protocol-layer changes).
### G-4 — `UnixListenerHalves::accept_loop` never driven
### G-4: `UnixListenerHalves::accept_loop` never driven
`src/local/mod.rs:591-609` has zero coverage: no test constructs it
(the unix dial test hand-rolls its own echo listener; the bind-first
unix test uses `accept_pair`). It is the unix parity twin of the
covered `TcpListenerHalves::accept_loop` (hit at ~107-125) — 15 lines
covered `TcpListenerHalves::accept_loop` (hit at ~107-125), 15 lines
of the same shape, and the largest single uncovered block in the
crate.
Test: mirror `tests/producer_listen.rs`'s
`accept_loop_stops_feeding_after_close_and_maps_the_drop` with
`bind_unix` + a temp path — closes the entire 593-607 range and
`bind_unix` + a temp path, which closes the entire 593-607 range and
exercises `UnixListenerHalves` beyond the bind-first path. Low value
(copy-shaped) but the largest single uncovered block.
### G-5 — the unnamed-peer/unnamed-bind fail-loud arms (unix)
### G-5: the unnamed-peer/unnamed-bind fail-loud arms (unix)
Two fail-loud guards the ADR pins (ADR-008 §3 — "a producer that
Two fail-loud guards the ADR pins (ADR-008 §3, where "a producer that
cannot determine the peer does not serve a bind-first listener over
that substrate") never fire:
- `src/local/mod.rs:626-633` — the `accept_pair` unnamed-peer
- `src/local/mod.rs:626-633`: the `accept_pair` unnamed-peer
`handler_error` (an autobind client)
- `src/local/mod.rs:578-583` — the `local_addr` unnamed-bind error
- `src/local/mod.rs:578-583`, the `local_addr` unnamed-bind error
The existing unix bind-first test binds a named client socket
deliberately (the parity case); no test binds an abstract/autobind
unix listener.
Test: bind an abstract unix listener (Linux: a leading-`\0` path via
`socket2` — already a `local` dependency, or `UnixListener::bind` on
`socket2`, already a `local` dependency, or `UnixListener::bind` on
an unnamed autobind socket), call `local_addr()` expecting the
"no filesystem path" `handler_error`, and drive one autobind-client
accept through `accept_pair` expecting the unnamed-peer error. Cheap,
@@ -56,18 +56,18 @@ promise.
### Explicitly NOT in this task (review guidance)
- **G-7** — kernel-dependent readiness arms; leave uncovered.
- **G-6** — the UDP empty-resolve arm; only if the `ResolveFn` seam
refactor is ever wanted (optional, separate decision — do not do it
here).
- **G-1/G-3** — unreachable-by-construction serde/JoinError arms;
- **G-7**, kernel-dependent readiness arms; leave uncovered.
- **G-6** (the UDP empty-resolve arm:; only if the `ResolveFn` seam
refactor is ever wanted; optional, separate decision, so do not do
it here).
- **G-1/G-3**, unreachable-by-construction serde/JoinError arms;
leave uncovered.
## Acceptance Criteria
- [ ] The unix `accept_loop` test added (G-4 — the 591-609 range
- [ ] The unix `accept_loop` test added (G-4, the 591-609 range
covered)
- [ ] The abstract-listener test added (G-5 — both fail-loud arms
- [ ] The abstract-listener test added (G-5, both fail-loud arms
covered: `local_addr` unnamed-bind + `accept_pair`
unnamed-peer)
- [ ] `cargo test --features local` green; clippy `--all-targets
@@ -91,7 +91,7 @@ promise.
### Implementation (2026-09-20)
**G-4** — `unix_accept_loop_stops_feeding_after_close_and_maps_the_drop`
**G-4**: `unix_accept_loop_stops_feeding_after_close_and_maps_the_drop`
in `tests/producer_listen.rs` (mirroring the TCP twin above it):
`bind_unix` + a temp path under the test's own pid-scoped dir, the
listener's `accept_loop` spawned against a pre-closed `AcceptQueue`,
@@ -99,19 +99,19 @@ one unix client connect, and the loop's `handler_error` exit asserted
within a 5s bound. Covers the full 591-609 range (the unix
accept-first loop was previously never driven).
**G-5** — `unix_unnamed_bind_and_unnamed_peer_fail_loud` in
`tests/local_bindfirst.rs` (`#[cfg(target_os = "linux")]` — abstract
**G-5**: `unix_unnamed_bind_and_unnamed_peer_fail_loud` in
`tests/local_bindfirst.rs` (`#[cfg(target_os = "linux")]`; abstract
addresses + autobind are Linux-shaped). Both fail-loud arms pinned via
the crate's own public API (no private-constructor seam needed):
- **Unnamed bind**: `bind_unix("")` — tokio passes the empty path
- **Unnamed bind**: `bind_unix("")`, so tokio passes the empty path
through to `bind(2)`, which the kernel autobinds (addlen ==
`offsetof(sun_path)`, an abstract name, no filesystem path) —
`local_addr` maps it to the "no filesystem path" handler error
`offsetof(sun_path)`, an abstract name, no filesystem path),
so `local_addr` maps it to the "no filesystem path" handler error
(the 578-583 arm). Probed on this host: the bound addr reports
`as_pathname() == None`.
- **Unnamed peer**: a socket2 client bound to the empty abstract
sockaddr (the autobind shape — verified `Ok` on this kernel)
sockaddr (the autobind shape, verified `Ok` on this kernel)
connects to a NAMED `bind_unix` listener; `accept_pair` refuses
with the unnamed-peer handler error (the 626-633 arm). A plain
unbound client behaves identically: mio's UDS accept maps ANY
@@ -120,7 +120,7 @@ the crate's own public API (no private-constructor seam needed):
catches both client shapes through tokio's accept.
`bind_unix_first` itself stays untested for the unnamed-bind arm at
the establisher level (it composes `bind_unix` + `local_addr` — the
the establisher level (it composes `bind_unix` + `local_addr`, and the
arm is the same code path the test drives directly); the local_addr
assertion covers the composition's error leg.
+15 -15
View File
@@ -1,6 +1,6 @@
---
id: tunnels/review-002-doc-sync
name: "D-1 + D-2 — sync wire.md's schema bounds + ADR-007's oneOf→if/then + the establisher-time→registry-gate sentence"
name: "D-1 + D-2. Sync wire.md's schema bounds + ADR-007's oneOf→if/then + the establisher-time→registry-gate sentence"
status: completed
depends_on: []
scope: narrow
@@ -15,23 +15,23 @@ tags: [docs, wire-format, adr-007, review-002, review-remediation]
Two doc-only review 002 findings
(`docs/reviews/002-graduation-implementation-review.md` §D-1, §D-2)
batched: the shipped code is the wire-stable side and is correct; the
*docs* lag it. Wire-contract-adjacent — do this before the first
*docs* lag it. This is wire-contract-adjacent, so do it before the first
consumer (alksocks) ships.
### D-1 — wire.md omits the `port` bounds the shipped schema enforces
### D-1: wire.md omits the `port` bounds the shipped schema enforces
The shipped direct-op and forwarded-op schemas pin
`"port": {"minimum": 0, "maximum": 65535}` plus
`"additionalProperties": false` on the target/peer objects
(`src/params.rs:208-222, 453-467`), and `listen` with
`"minimum": 1` (`src/params.rs:442`). wire.md (the normative WHAT)
says only `port: <u16>` and "integer > 0" in prose — it does not
says only `port: <u16>` and "integer > 0" in prose; it does not
record the bounds as schema properties, nor the
`additionalProperties: false` gate that makes cross-shape targets
fail the registry's `INVALID_INPUT` class BEFORE the establisher (the
behavior `malformed_target_is_schema_class_and_leaves_no_channel`
pins on the wire). The BAST doc (`bast.md`) covers only the
data-plane codec, not the params schemas — no second source records
data-plane codec, not the params schemas; no second source records
these bounds.
Fix: record the exact schema constraints in wire.md's §The Direct Op
@@ -41,15 +41,15 @@ sentence). The risk addressed: a future consumer implementing from
wire.md alone accepting a shape the reference producer rejects (or
vice versa).
### D-2 — ADR-007's `oneOf` wording is stale; the "validated at establisher time" sentence contradicts the shipped design
### D-2: ADR-007's `oneOf` wording is stale; the "validated at establisher time" sentence contradicts the shipped design
1. ADR-007 §2 (line ~82) says "the target sub-schema is per-substrate
(`oneOf` on the sibling `substrate` value, validated at establisher
time — see Consequences)". The shipped schema (and wire.md §The
time; see Consequences)". The shipped schema (and wire.md §The
Forwarded Op, and the unit tests pinning it) use **`allOf` of two
`if`/`then` branches** instead. `oneOf` of full-target-schemas
would actually be WRONG here (a `unix`-substrate open would need to
fail the `tcp` branch — plain `oneOf` on sibling-conditioned
fail the `tcp` branch, so plain `oneOf` on sibling-conditioned
subschemas is awkward; `if`/`then` composes correctly with the base
`properties`/`required`). The code is right; the ADR's one-word
schema-structure reference is stale.
@@ -59,11 +59,11 @@ vice versa).
the REGISTRY gate fires first (the establisher's dial never runs).
The ADR/wire.md sentence should read "validated registry-side (the
schema gate), before the establisher; re-checked by serde at
establisher time as the backstop" — which is exactly what
establisher time as the backstop", which is exactly what
`src/params.rs`'s doc comments already say.
Fix: a one-line ADR-007 erratum (or an amendment note — house style
has precedent: ADR-008 carries numbered amendments) replacing
Fix: a one-line ADR-007 erratum (or an amendment note; the house
style has precedent: ADR-008 carries numbered amendments) replacing
`oneOf` with the shipped `allOf`-of-`if`/`then` structure, plus the
corrected gate-ordering sentence; mirror the same
gate-ordering correction in wire.md §The Direct Op's
@@ -80,8 +80,8 @@ gate-ordering correction in wire.md §The Direct Op's
- [ ] The "validated at establisher time" sentence corrected to
"registry-side schema gate first; serde backstop at
establisher time" in ADR-007 (and wire.md if it repeats it)
- [ ] `cargo doc --no-deps` clean (docs changed — the verification
battery's doc gate)
- [ ] `cargo doc --no-deps` clean (docs changed, which trips the
verification battery's doc gate)
## References
@@ -98,11 +98,11 @@ gate-ordering correction in wire.md §The Direct Op's
## Implementation Notes (2026-09-20)
- **D-1 (wire.md §The Direct Op + §The Forwarded Op)**: both sections
now record the exact shipped schema constraints — the per-branch
now record the exact shipped schema constraints: the per-branch
target/peer objects (`type: object`, `additionalProperties: false`,
`port` bounds `minimum: 0`/`maximum: 65535`, the `unix` `path`
branch) and the forwarded `listen` `minimum: 1` + its serde-pin
backstop — with the gate-ordering sentence (registry-side schema
backstop, plus the gate-ordering sentence (registry-side schema
gate before the establisher, serde backstop) and the
cross-shape-INVALID_INPUT behavior the integration test pins. No
second source existed for these bounds (bast.md covers only the
@@ -1,6 +1,6 @@
---
id: tunnels/review-002-g2-graceful-teardown-observation
name: "G-2 — observe close()'s EOF sentinel at the far accept dispatcher (+ N-2's write-happy-peer teardown pin)"
name: "G-2. Observe close()'s EOF sentinel at the far accept dispatcher (+ N-2's write-happy-peer teardown pin)"
status: completed
depends_on: []
scope: narrow
@@ -13,19 +13,19 @@ tags: [tests, consumer, bind-first, teardown, review-002, review-remediation]
## Description
Review 002 coverage gaps G-2 and the N-2 branch note, batched (the
review itself suggests the same harness shape covers both — see its
review itself suggests the same harness shape covers both; see its
§Branch-coverage notes, last bullet).
### G-2 — `TunnelListener::close`'s `shutdown` arm never observed at the far end
### G-2: `TunnelListener::close`'s `shutdown` arm never observed at the far end
`src/consumer.rs:529-531`: `close()` runs `write.shutdown().await`
(the explicit EOF sentinel) then reaps — but no test currently
(the explicit EOF sentinel) then reaps, but no test currently
observes the FAR accept dispatcher's idle-read firing from a `close()`
(the graceful path). The Drop path (implicit EOF → far teardown) IS
observed end-to-end
(`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`), and
the close path is pinned only at the reap level
(`close_and_drop_reap_the_listen_channel`) — the sentinel's far-side
(`close_and_drop_reap_the_listen_channel`), so the sentinel's far-side
effect for `close()` specifically is asserted nowhere: the "the pushed
accept was never consumed" assertion runs only in the Drop-shaped
tests.
@@ -36,15 +36,15 @@ observation.
Test: mirror `bindfirst_full_flow…`'s harness (the
`tests/consumer_listener.rs` bind-first topology) with a `close()`
instead of a drop — open against a bind-first producer, push an
instead of a drop: open against a bind-first producer, push an
accept, `listener.close().await`, then assert the far accept loop is
gone (the same queue-len / never-consumed assertion the Drop tests
use) and the serving-side listen channel reaped.
### N-2's testable residue — the write-happy-peer teardown posture
### N-2's testable residue: the write-happy-peer teardown posture
The idle-stream EOF watch (`src/producer.rs:781-794`) triggers
teardown on `Ok(0)`, `Err(_)`, AND `Ok(1..)` — a misbehaving opener
teardown on `Ok(0)`, `Err(_)`, AND `Ok(1..)`; a misbehaving opener
that writes to the idle listen channel also tears the listener down
(fail-closed; the channel is not a data plane; the doc comment owns
this). N-2 records the shape as correct, but the review's
@@ -52,7 +52,7 @@ branch-coverage notes flag that the `Ok(_)` (non-empty read) arm is
untested: a test would write a byte to the idle channel and assert
far teardown, pinning the "any non-empty read tears down" posture.
Test: same harness — open the listen channel, write a byte into the
Test: same harness. Open the listen channel, write a byte into the
idle stream from the opener side, assert the accept loop cancels and
the listener tears down (the fail-closed posture).
@@ -81,7 +81,7 @@ the listener tears down (the fail-closed posture).
Filled (2026-09-20):
- Both tests landed in `tests/consumer_listener.rs` — no `src/`
- Both tests landed in `tests/consumer_listener.rs` with no `src/`
changes needed (pure coverage additions):
- `graceful_close_eof_sentinel_observed_at_far_accept_dispatcher`
(G-2): mirrors the bind-first full-flow harness with a `close()`
@@ -89,7 +89,7 @@ Filled (2026-09-20):
accept), then asserts after `listener.close().await` that the
serving-side listen channel reaped AND a follow-on pushed accept
is never consumed (the same queue-len assertion the Drop-shaped
tests use) — the sentinel's far-side effect for the graceful path.
tests use), which is the sentinel's far-side effect for the graceful path.
- `write_happy_peer_on_idle_listen_channel_tears_down_far_listener`
(N-2 residue): opens the listen channel via the raw client
surface (`open_channel_with_reply`, both halves held alive), proves
@@ -97,9 +97,9 @@ Filled (2026-09-20):
asserts the fail-closed teardown (channel reaped + the follow-on
accept never consumed). The typed `TunnelListener` never exposes
its idle write half by contract, so the misbehaving-opener shape
requires the raw client — that shape difference is the point.
requires the raw client; that shape difference is the point.
- Harness notes: the write-happy test installs NO forwarded-op
serving stub — the pre-write consumed accept fails its forwarded
serving stub. The pre-write consumed accept fails its forwarded
open (per-accept isolation), which is fine: the assertion only
needs the queue emptied (loop alive), and the isolation path is
pinned by `open_only_opener_still_opens_listen_but_forwarded_fails`.
@@ -1,6 +1,6 @@
---
id: tunnels/review-002-m1-substrate-fallback
name: "M-1 remediation — the accept dispatcher's silent substrate fallback (stamp the plan, fail loud)"
name: "M-1 remediation: the accept dispatcher's silent substrate fallback (stamp the plan, fail loud)"
status: completed
depends_on: []
scope: narrow
@@ -28,7 +28,7 @@ let substrate = serde_json::from_value::<TunnelParams>(input.clone())
In the wired path the fallback is unreachable (the registry schema gate
and the establisher's `parse_params` both validate first), but the
shape itself is wrong: a future drift would silently stamp `Tcp` into
every forwarded open — for a UDP listen tunnel that is a
every forwarded open; on a UDP listen tunnel that is a
raw-vs-framed data-plane mismatch on the opener side (the exact
invariant class review 001's U-1 was about), degrading silently
instead of loudly.
@@ -36,18 +36,18 @@ instead of loudly.
### The fix (decide deliberately, then implement)
The review offers two shapes; **the second is recommended** (the
review calls it strictly better, and the pre-consumer window is open —
review calls it strictly better, and the pre-consumer window is open,
`BindFirstPlan` has no external consumer yet):
1. Log-loud-and-return on parse failure (teardown-at-birth — the
1. Log-loud-and-return on parse failure (teardown-at-birth, the
same posture as every other early return in the file). Smaller,
but keeps the second parse and the fallback branch.
2. **Stamp the substrate into `BindFirstPlan` at establisher time and
drop the second parse entirely** — one parse, one source of truth,
drop the second parse entirely**: one parse, one source of truth,
no fallback branch at all. The establisher already parsed the
params successfully; the substrate is per-open-static; the plan is
already the per-open carrier. `BindFirstPlan` gains a
`substrate: Substrate` field (a plan-payload change — cheap now,
`substrate: Substrate` field (a plan-payload change, cheap now,
pre-consumer); the dispatcher reads it from the plan it already
downcast.
@@ -61,10 +61,10 @@ plan (the dispatcher must not re-parse wire input).
### Tests
- Keep the existing suite green (the two-open token-discipline test
pins per-dispatcher stamping from its own plan — it now also pins
pins per-dispatcher stamping from its own plan; it now also pins
the substrate's origin implicitly).
- A failure-path test: with shape 2 there is no failure branch left
in the dispatcher (assert the removed fallback is gone — e.g. the
in the dispatcher (assert the removed fallback is gone, that is, the
forwarded opens for a UDP bind-first listen carry `substrate: udp`
from the plan, not a re-parse). With shape 1: a malformed-input
accept teardown test (loud return, channel reaps, no phantom
@@ -85,7 +85,7 @@ plan (the dispatcher must not re-parse wire input).
- docs/reviews/002-graduation-implementation-review.md §M-1 (the
finding, both fix shapes, and why not fixed inline)
- src/producer.rs — `BindFirstPlan` (~line 97), the establisher, and
- src/producer.rs: `BindFirstPlan` (~line 97), the establisher, and
`make_bindfirst_accept_handler` (~line 755)
- tasks/tunnels/bindfirst-producer.md (the original implementation
task + Notes on the plan's shape)
@@ -94,20 +94,20 @@ plan (the dispatcher must not re-parse wire input).
> Agent fills during implementation.
**Chosen shape: 2 — stamp the plan** (the review's recommendation).
Rationale: one parse (the establisher's `parse_params` — which
**Chosen shape: 2, stamp the plan** (the review's recommendation).
Rationale: one parse (the establisher's `parse_params`, which
rejects malformed input with `handler_error` before any channel
exists), one source of truth; the substrate is per-open-static and
the plan is already the per-open carrier; the fallback branch is not
just guarded against, it is REMOVED — a future drift cannot degrade
just guarded against, it is REMOVED: a future drift cannot degrade
silently because there is no fallback to degrade into. Pre-consumer
window used (`BindFirstPlan` has no external consumer yet — a
window used (`BindFirstPlan` has no external consumer yet, so a
plan-payload change is cheap).
Implemented:
- `BindFirstPlan` gains `substrate: Substrate` (doc comment states
why it rides the plan — the dispatcher must not re-parse wire
why it rides the plan; the dispatcher must not re-parse wire
input; the removed fallback's data-plane mismatch risk is cited).
The `Debug` impl stamps it (the opaque listener field stays).
- `bindfirst_establisher` stamps the parsed `params.substrate` into
@@ -120,7 +120,7 @@ Implemented:
Tests:
- Existing suite kept green — the two-open token-discipline test
- Existing suite kept green: the two-open token-discipline test
(`token_discipline_two_opens_distinct_tokens`) still pins
per-dispatcher stamping from its own plan.
- `bindfirst_plan_downcasts_through_channel_plan` (unit): the plan's
@@ -130,18 +130,18 @@ Tests:
stamps the parsed substrate into the plan (`Tcp` input →
`plan.substrate == Tcp`).
- **`forwarded_opens_carry_plan_stamped_substrate` (integration,
the M-1 pin):** a UDP bind-first listen over the wire — the
the M-1 pin):** a UDP bind-first listen over the wire; the
forwarded open the dispatcher makes carries `substrate: udp` (the
plan's stamp), observed through a substrate-recording
forwarded-op stub on the opener side. The removed fallback would
have stamped `Tcp`; the assert is `vec![Substrate::Udp]`.
Docs synced: `docs/architecture/producer.md` (§3 — the plan carries
`{listener, token, substrate}`; §The Forwarded Op item 1 — the
Docs synced: `docs/architecture/producer.md` (§3, the plan carries
`{listener, token, substrate}`; §The Forwarded Op item 1, the
substrate also comes from the plan, never re-parsed),
`docs/architecture/decisions/008-listen-metadata-forwarded-op.md`
(§1 — the plan's substrate stamp + the removed fallback, review 002
M-1 referenced).
(§1, the plan's substrate stamp + the removed fallback; review
002's M-1 referenced).
Verification (all green): `cargo test` (145 tests across the lib +
10 integration binaries), `cargo test --all-features` (153),
+30 -29
View File
@@ -1,6 +1,6 @@
---
id: tunnels/review-002-recorder
name: "N-1..N-7 — record the review's no-action notes in the code/ADR trail (N-4's ADR line + N-3's telemetry note)"
name: "N-1..N-7: record the review's no-action notes in the code/ADR trail (N-4's ADR line + N-3's telemetry note)"
status: completed
depends_on: []
scope: narrow
@@ -14,70 +14,71 @@ tags: [docs, notes, review-002, review-remediation]
Review 002's [note] findings
(`docs/reviews/002-graduation-implementation-review.md` §N-1..N-7)
are correct-as-is with **no code action** — but two of them ask for a
are correct-as-is with **no code action**; but two of them ask for a
durable trail beyond the review doc itself, and the rest are recorded
here as the tracker (the review's own remediation list only orders
M-1/D-*/the coverage gaps).
### The two notes wanting a durable trail
- **N-3** — `make_forwarded_op_handler` parses params BEFORE
- **N-3**: `make_forwarded_op_handler` parses params BEFORE
`accept_bi` (`src/producer.rs:993-1006`), so the R-02 accepted-flag
telemetry reads `false` for a malformed-params open — the
telemetry reads `false` for a malformed-params open; the
birth-teardown hint fires for a handled-rejection case. The order
is deliberately the better one (accepting first would hold a stream
open for a params set the producer already got wrong). Record one
sentence in the code's doc comment ("the accepted-flag telemetry
reads false here by design — a malformed-params open IS a
reads false here by design; a malformed-params open IS a
birth-teardown shape") so a future alkcall reviewer doesn't flag
the flag.
- **N-4** — `SubstrateAddr`'s untagged leniency is schema-gated (the
- **N-4**: `SubstrateAddr`'s untagged leniency is schema-gated (the
schema's `additionalProperties: false` per branch is the real
gate). Two latent sharp edges to keep in view, in the ADR trail:
1. The establisher-level serde backstop keys `render_target` off
the sibling `substrate` — a cross-shape target that sneaked past
the sibling `substrate`, so a cross-shape target that sneaked past
a looser registry would render *something* rather than fail.
Worth an ADR line if a future consumer runs a schema-less
registry variant. Add the line to ADR-007 (Consequences or the
wire-stable section) while the doc-sync task's context is fresh
— one sentence: "the serde backstop renders the sibling-keyed
wire-stable section) while the doc-sync task's context is fresh.
One sentence: "the serde backstop
renders the sibling-keyed
target; cross-shape garbage is the schema's absence, not a
serde failure."
2. If `SubstrateAddr` grows a variant whose shapes overlap (e.g. a
future `{host, port, flowlabel}`), untagged disambiguation
starts ordering — switch to an internally-tagged form at that
starts ordering; switch to an internally-tagged form at that
point. This is additive-only, pre-consumer; the ADR line should
say "don't let it happen by accident."
### The no-action notes (recorded here as the tracker)
- **N-1** — `parse_listen_reply`'s positive-`listen` requirement
- **N-1**: `parse_listen_reply`'s positive-`listen` requirement
matches the forwarded params' wire contract (`minimum: 1`);
JSON-null-as-absent is the standard lenient reading; the struct
`deny_unknown_fields` warning in the doc comment is doing real
work — keep both. No action.
- **N-2** — the idle-stream EOF watch treats ANY byte as teardown
work, so keep both. No action.
- **N-2**: the idle-stream EOF watch treats ANY byte as teardown
(fail-closed). Correct; the ADR sentence is thinner than the
implementation — the write-happy-peer test pin is tracked in
implementation; the write-happy-peer test pin is tracked in
`tunnels/review-002-g2-graceful-teardown-observation`. Optionally
widen ADR-008 §1's "its EOF cancels the loop" sentence to name the
any-byte posture while editing (coordinate with
`tunnels/review-002-doc-sync` if both touch ADR-008).
- **N-5** — the token-mint discipline is sound; the 256-channel
- **N-5**: the token-mint discipline is sound; the 256-channel
per-connection cap makes the wrap-collision math even more
favorable than the doc comment states. No action (optionally
tighten the doc comment's math note).
- **N-6** — the sleep-then-assert test pattern
- **N-6**: the sleep-then-assert test pattern
(`tests/consumer_listener.rs:276` + siblings) is the established
house pattern; if CI ever flakes, the fix is a bounded poll loop on
`channel_ids()` extended to the arrivals. No action now.
- **N-7** — `test_bind()`'s bare `Arc<u32>` listener is fine (the
- **N-7**: `test_bind()`'s bare `Arc<u32>` listener is fine (the
downcast-miss probe covers the failure mode). No action.
### G-1/G-3 — the unreachable-by-construction arms (document-only)
### G-1/G-3: the unreachable-by-construction arms (document-only)
Per the coverage appendix's recommendations: leave both uncovered,
noted here as the review's "unreachable error arm" record —
noted here as the review's "unreachable error arm" record:
`TunnelSession::open`/`open_direct`/`TunnelListener::open` (+
`open_reverse_channel`'s twin) params-serialize closures (serde_json
cannot fail on these types) and `TunnelSession::join`'s pump-`Err`
@@ -113,7 +114,7 @@ G-1's four duplicated closures into one `params_to_input` helper
> Agent fills during implementation.
## Implementation Notes (2026-09-20) — the review's no-action dispositions, recorded
## Implementation Notes (2026-09-20): the review's no-action dispositions, recorded
The two durable-trail items landed as specified; the no-action notes'
dispositions follow (this section is the tracker the task asked for).
@@ -122,24 +123,24 @@ dispositions follow (this section is the tracker the task asked for).
- **N-3**: the parse-before-accept telemetry sentence added to
`make_forwarded_op_handler`'s doc comment
(`src/producer.rs`) — "the accepted-flag telemetry reads false here
(`src/producer.rs`): "the accepted-flag telemetry reads false here
by design; a malformed-params open IS a birth-teardown shape; do
not flag the flag (review 002 N-3)".
- **N-4**: both ADR-007 Consequences lines added — (1) the
- **N-4**: both ADR-007 Consequences lines added: (1) the
`SubstrateAddr` untagged-leniency-is-schema-gated paragraph (the
sibling-keyed `render_target` backstop; "cross-shape garbage is the
schema's absence, not a serde failure"; the schema-less-registry
ADR-line-if-ever requirement) and (2) the overlapping-variants
caution (untagged disambiguation starts ordering → switch to an
internally-tagged form, deliberately, never by accident).
- **N-2 (ADR-008 §1 widening — APPLIED, not declined)**: §1's
- **N-2 (ADR-008 §1 widening: APPLIED, not declined)**: §1's
accept-dispatcher bullet now states the any-byte idle-read posture
(`Ok(0)` / error / any non-empty read all tear down; fail closed;
the write-happy-peer test pins it) and names the `Ok(0)`-only
hang-on-write-happy-peer trap the narrower reading would produce.
Note: the doc-sync task and this task both touched ADR files but
never ADR-008, so no coordination conflict materialized.
- **N-5 (optionally tighten — done)**: `mint_token`'s doc comment now
- **N-5 (optionally tighten: done)**: `mint_token`'s doc comment now
records the review's cap analysis (the token is
per-producer-connection; the 256-channels-per-connection cap makes
the live-collision window unreachable by a wide margin).
@@ -149,21 +150,21 @@ dispositions follow (this section is the tracker the task asked for).
- **N-1**: no action. `parse_listen_reply`'s positive-`listen`
requirement matches the forwarded params' `minimum: 1`; JSON-null-
as-absent is the standard lenient reading; the `deny_unknown_fields`
warning in `TunnelListenReply`'s doc comment is doing real work —
kept as-is.
warning in `TunnelListenReply`'s doc comment is doing real work and
is kept as-is.
- **N-2's code**: no action (fail-closed any-byte watch is correct as
shipped; only the ADR sentence was widened, above).
- **N-6**: no action. Sleep-then-assert is the established house
pattern (3x-repeat stable historically); if CI ever flakes on the
forwarded-arrival tests, the fix is a bounded poll loop on
`channel_ids()` extended to the arrivals — recorded here, not
`channel_ids()` extended to the arrivals; recorded here, not
implemented.
- **N-7**: no action. `test_bind()`'s bare `Arc<u32>` listener is
fine; the downcast-miss probe
(`accept_pair_loop_downcast_miss_fails_loud`) covers the failure
mode.
- **G-1/G-3 (the unreachable-by-construction arms)**: left uncovered,
per the review's recommendations — `open`/`open_direct`/
per the review's recommendations: `open`/`open_direct`/
`TunnelListener::open` (+ `open_reverse_channel`'s twin)
params-serialize closures (serde_json cannot fail on these types)
and `join`'s pump-`Err` arm (unreachable through the public API; the
@@ -176,4 +177,4 @@ dispositions follow (this section is the tracker the task asked for).
- `cargo fmt --check`; `cargo clippy --all-targets -D warnings`
(native + wasm32); `cargo test` (all suites + doc-tests);
`cargo doc --no-deps` — all green.
`cargo doc --no-deps` are all green.
+11 -11
View File
@@ -1,6 +1,6 @@
---
id: tunnels/review-002-trivial-batch
name: "Trivial batch — D-3 (rename the overpromising schema test helper) + D-4 (trim the stale CHANGELOG preamble)"
name: "Trivial batch: D-3 (rename the overpromising schema test helper) + D-4 (trim the stale CHANGELOG preamble)"
status: completed
depends_on: []
scope: narrow
@@ -13,13 +13,13 @@ tags: [tests, changelog, review-002, review-remediation]
## Description
Two trivial review 002 findings
(`docs/reviews/002-graduation-implementation-review.md`) batched —
each a one-line-scale change; no behavior impact.
(`docs/reviews/002-graduation-implementation-review.md`) batched:
each is a one-line-scale change with no behavior impact.
### D-3 — the test helper's name overpromises
### D-3: the test helper's name overpromises
`src/params.rs:628-637`: `jsonschema_valid` parses the value with
serde and pattern-matches the substrate string — it validates the
serde and pattern-matches the substrate string, so it validates the
serde layer, NOT the JSON Schema (the registry compiles the real
validators; adding a `jsonschema` dev-dependency purely for this unit
test is not warranted). The call-site
@@ -38,18 +38,18 @@ end-to-end by the integration probes
`malformed_target_is_schema_class…`, the forwarded `listen: 0`
gate)"). Do NOT add a `jsonschema` dev-dependency.
### D-4 — the CHANGELOG "Unreleased" preamble is stale
### D-4: the CHANGELOG "Unreleased" preamble is stale
`CHANGELOG.md`'s Unreleased section still opens with "implementation
is decomposed in `tasks/tunnels/` and lands in the next release…
the only behavior-affecting change so far (no wire or API change at
this level yet)" — written before the six graduation implementation
this level yet)", written before the six graduation implementation
commits. The `### Added` section below it correctly lists all six
features; the preamble contradicts the section it introduces.
Fix: trim the preamble to a one-liner (e.g. "The graduation
implementation (ADRs 007/008) landed — see Added below." — final
wording agent's choice) or drop it.
implementation (ADRs 007/008) landed; see Added below." (the final
wording is the agent's choice) or drop it.
## Acceptance Criteria
@@ -73,14 +73,14 @@ wording agent's choice) or drop it.
- **D-3**: inlined the honest serde check directly into the renamed
test `serde_rejects_unknown_substrate_value` (the `jsonschema_valid`
helper deleted — it had exactly one call-site, and its
helper deleted, since it had exactly one call-site, and its
`matches!`-with-guard body was convoluted beyond its job). The doc
comment names the integration probes that pin the real registry-side
gate end-to-end. No `jsonschema` dev-dependency added (the
structural-assertion sibling `direct_target_schema_gates_shape_per_
substrate` is untouched and stays honest).
- **D-4**: trimmed the preamble to the one-liner ("The graduation
implementation … landed — see Added below."); the stale
implementation … landed; see Added below."); the stale
`tasks/tunnels/` sentence and the "only behavior-affecting change"
claim are gone.
- Verification: `cargo fmt --check`, `cargo clippy --all-targets -D
+18 -18
View File
@@ -1,6 +1,6 @@
---
id: tunnels/review-core-crates
name: Mid-phase review — producer + consumer halves before the local feature
name: Mid-phase review, producer + consumer halves before the local feature
status: completed
depends_on: [tunnels/consumer-session]
scope: moderate
@@ -12,22 +12,22 @@ tags: [review, injection-point]
## Description
Review injection point (SDD §Review Injection — "high-risk tasks: review
Review injection point (SDD §Review Injection: "high-risk tasks: review
before proceeding"): `tunnels/producer-open-op` and
`tunnels/consumer-session` are the two `risk: high, impact: phase` tasks
and the crate's wire/API surface. Review them BEFORE
`tunnels/local-socket-halves` and `tunnels/end-to-end-suite` build
against the shapes — a wire or API mistake found here costs one task's
rework; found later it costs three.
against the shapes; a wire or API mistake found here costs one task's
rework, while found later it costs three.
### Checklist (focused — the full gate is `tunnels/review-impl`)
### Checklist (focused; the full gate is `tunnels/review-impl`)
1. Wire conformance of what landed (params/codec/open-op vs wire.md +
ADR-001/003) — the one-way-door check.
ADR-001/003), the one-way-door check.
2. The pump handler shape: `pump_bidi` inline, JoinHandle tracks the
data plane (R-02) — the POC's hang-bug class must be structurally
data plane (R-02), so the POC's hang-bug class must be structurally
absent.
3. Teardown matrix of `TunnelSession` — close/join/Drop soundness, no
3. Teardown matrix of `TunnelSession`: close/join/Drop soundness, no
leaks (the W3 class).
4. No substrate types outside `src/local/`-to-be; no hand-rolled
two-pump loops; no side-channel handoff.
@@ -60,38 +60,38 @@ items were run against `producer.rs`/`consumer.rs`/`params.rs`/
alkcall 0.7.0's wrapper/establisher/pump source. Because both
downstream tasks built on the shapes immediately after, the full
re-verification (with the complete suite) landed in
`tunnels/review-impl` — consolidated findings live in
`tunnels/review-impl`; consolidated findings live in
`docs/reviews/001-implementation-review.md` (the review-impl
deliverable). Verdicts, summarized:
1. **Wire conformance — PASS.** Params shape exact
1. **Wire conformance: PASS.** Params shape exact
(`{resource, substrate}`, `deny_unknown_fields`, enum
`["tcp","udp","unix"]` — schema + serde both pinned by tests); op
`["tcp","udp","unix"]`, schema + serde both pinned by tests); op
id/ALPN/scope constants asserted against wire.md; codec
(`[len: u16 BE]`, `len=0` legal, `Oversize` at frame time, no
sentinel) correct.
2. **Pump handler shape — PASS.** `pump_bidi` awaited inline inside
2. **Pump handler shape: PASS.** `pump_bidi` awaited inline inside
the spawned handler task; `accept_bi` → plan downcast →
`Arc::try_unwrap` → inline await; no spawn-and-forget, no
hand-rolled two-pump loop (grep-verified); R-02 pinned by an
end-to-end test (`pump_handle_tracks_the_data_plane_r02`).
3. **Teardown matrix — PASS.** close (abort + reap), join (await +
3. **Teardown matrix: PASS.** close (abort + reap), join (await +
reap + counts, panic arm logged), Drop (abort + sync reap),
pump-less join `(0, 0, reaped)`, failed-adopt no-leak — all pinned
pump-less join `(0, 0, reaped)`, failed-adopt no-leak; all pinned
with `channel_ids()` leak asserts on both sides. No `Clone`
(compile-fail doctest).
4. **No substrate types / no hand-rolled pumps / no side-channel
handoff — PASS.** The only plan flow is `Establishment::new(plan)`
handoff: PASS.** The only plan flow is `Establishment::new(plan)`
→ wrapper → handler `plan` param (R-01); the only `Mutex<HashMap>`
is the `ResourceRegistry` lookup table (a registry, not a plan
handoff); `tokio::net` appears only under the `local` gate.
5. **Tests green + 3× repeat-run stable — PASS** (both configs).
5. **Tests green + 3× repeat-run stable: PASS** (both configs).
### Majors logged (carried to `tunnels/review-impl`, re-verified there)
- **C-1** (`AcceptQueue::pop` lost wakeup — found in the full sweep,
- **C-1** (`AcceptQueue::pop` lost wakeup, found in the full sweep and
fixed in-review): see review 001 §C-1.
- **U-1** (the `local` UDP adapter is not the framed adapter — found
- **U-1** (the `local` UDP adapter is not the framed adapter, found
by the review-impl probes over real sockets): see review 001 §U-1;
remediation task `tunnels/fix-udp-framed-adapter`.
+23 -23
View File
@@ -30,9 +30,9 @@ surface (ADR-001/002/003) is a one-way door, and the API surface
- typed errors: the five establishment reasons + `FORBIDDEN` +
`channel:too_many_channels` surfaces
2. **Producer conformance** (producer.md):
- establisher = the awaited bounded phase; plan flow (R-01) — no
- establisher = the awaited bounded phase; plan flow (R-01), meaning no
side-channel handoff anywhere (grep for `Mutex<HashMap>` +
poll-loop take patterns — the POC's dead shape)
poll-loop take patterns, the POC's dead shape)
- pump handler: `pump_bidi` inline, no substrate types, JoinHandle
tracks the data plane (R-02)
- registration: post-hoc supported (W2); `ChannelOperations::
@@ -81,21 +81,21 @@ tasks; minors get a follow-up batch.
> Agent fills during implementation.
### Review executed 2026-09-08 (tree `2efac9b`) — deliverable: `docs/reviews/001-implementation-review.md`
### Review executed 2026-09-08 (tree `2efac9b`); deliverable: `docs/reviews/001-implementation-review.md`
Method: full source read (`src/` + `tests/`) against the complete spec
set; alkcall 0.7.0 API surfaces verified in source (wrapper, establisher
types, client errors, manager adopt/teardown, `pump_bidi`); executable
probes over real sockets for the `local` UDP path (temporary test file,
deleted after the run — probe evidence quoted in the review); the full
deleted after the run, with the probe evidence quoted in the review); the full
verification battery (see below). The mid-phase `tunnels/
review-core-crates` checklist is subsumed (consolidated in the review
doc; that task's Notes carry its verdicts).
### The five checklist verdicts
1. **Wire conformance — PASS.** Params shape exact (`deny_unknown_
fields`, required both, enum `["tcp","udp","unix"]` — the OQ-TN-14
1. **Wire conformance: PASS.** Params shape exact (`deny_unknown_
fields`, required both, enum `["tcp","udp","unix"]`; the OQ-TN-14
note in this checklist predates the OQ's resolution; `unix` SHIPS
per OQ-TN-14's resolution, and the enum carries it correctly); op
id/ALPN/scope pinned; codec correct including `len=0` and frame-time
@@ -104,58 +104,58 @@ doc; that task's Notes carry its verdicts).
`resource_shortage`; `handler_error` through the same `From` arm;
`timeout` by the hanging-establisher probe) + `FORBIDDEN` +
`channel:too_many_channels`.
2. **Producer conformance — PASS.** Awaited bounded establisher (10s
2. **Producer conformance: PASS.** Awaited bounded establisher (10s
default + per-registration override proven); pure R-01 plan flow
(grep-verified — no side-channel handoff, no poll-loop take); pump
(grep-verified: no side-channel handoff, no poll-loop take); pump
handler structurally R-02 (`pump_bidi` inline; JoinHandle tracks
the data plane, pinned by test); post-hoc registration (W2)
supported; listen variant rides the same op with the typed-error
table proven.
3. **Consumer conformance — PASS.** Full session surface per ADR-005;
teardown matrix sound (close/join/Drop/pump-less/failed-adopt — all
3. **Consumer conformance: PASS.** Full session surface per ADR-005;
teardown matrix sound (close/join/Drop/pump-less/failed-adopt, all
leak-asserted on both managers); no `Clone` (compile-fail doctest).
One documented divergence: `adopt` takes the substrate (the task
sketch omitted it — a datagram session cannot exist without it).
4. **Conventions sweep — PASS.** No unwrap/expect/panic outside
sketch omitted it, since a datagram session cannot exist without it).
4. **Conventions sweep: PASS.** No unwrap/expect/panic outside
`#[cfg(test)]` (grep-verified); thiserror everywhere; all locks are
`tokio::sync::Mutex` (unpoisoned — the `into_inner` rule has no
`tokio::sync::Mutex` (unpoisoned: the `into_inner` rule has no
application point, N/A); substrate types confined to `src/local/`
+ feature gate; wasm clean (`check` + `clippy --target
wasm32-unknown-unknown`, `-D warnings` both); `pump_bidi` consumed,
never hand-rolled. Inline `//` comments are the
safety-constraint class convention 1 allows.
5. **Docs ↔ implementation sync — PASS with drift fixed in-review.**
5. **Docs ↔ implementation sync: PASS with drift fixed in-review.**
Module map matches; ADRs all Accepted (implementation confirms); N-5
(stale doc forward-reference), N-7 (broken doc link), N-8 (missing
root re-exports) fixed in-review.
### Findings (full detail in the review doc)
- **U-1 [major]** — the `local` UDP adapter is NOT the framed adapter
- **U-1 [major]**, the `local` UDP adapter is NOT the framed adapter
ADR-003 mandates: codec-framed bytes reach real targets; empty
datagrams are EOF-shaped at the adapter (tunnel teardown);
>8 KiB datagrams split; unframed target-initiated datagrams are
dropped. Executable-pinned by probes. **NOT fixed — remediation
dropped. Executable-pinned by probes. **NOT fixed; remediation
task `tunnels/fix-udp-framed-adapter`** (blocks UDP-over-`local`
consumers; wire surface unaffected).
- **C-1 [major]** — `AcceptQueue::pop` lost wakeup in the
- **C-1 [major]**: `AcceptQueue::pop` lost wakeup in the
check→register window (open op hangs to the establishment bound on
a lost push). **Fixed in-review** (create-`Notified`-before-check
per iteration, the tokio `notify_waiters` contract; regression test
added).
- **U-2/U-3 [minor]** — `connect_udp` v4-only bind posture; 65535-byte
- **U-2/U-3 [minor]**: `connect_udp` v4-only bind posture; 65535-byte
per-tunnel scratch. Riders on the U-1 task.
- **N-4/N-5/N-7/N-8/N-9/N-10/N-11 [minor]** — fixed in-review (payload
- **N-4/N-5/N-7/N-8/N-9/N-10/N-11 [minor]**, fixed in-review (payload
serialization, doc staleness, doc link, root re-exports, README +
publish dry-run, harness comment, `pump_against` UDP framing doc
note).
- **N-2** — retracted during the review by executable falsification
- **N-2**, retracted during the review by executable falsification
(the codec's buffering is bounded by construction; the u16 cap IS
the bound).
- **N-6** — non-finding with rationale (`TunnelEstablishError` has no
- **N-6**, a non-finding with rationale (`TunnelEstablishError` has no
`Timeout` variant, correctly).
No criticals — the phase-gate passes.
There are no criticals; the phase-gate passes.
### Verification battery (all green)
@@ -173,7 +173,7 @@ No criticals — the phase-gate passes.
> Agent fills this on completion.
Phase-gate review complete: the wire surface (the one-way door) is
conformant; the API surface matches ADR-005/006; two majors found —
conformant; the API surface matches ADR-005/006; two majors found:
U-1 (local UDP framing, remediation task created) and C-1 (queue lost
wakeup, fixed with the review commit). The trivial batch + README +
publish dry-run also landed with the review. Findings with
+48 -47
View File
@@ -1,6 +1,6 @@
---
id: tunnels/tunnel-listener
name: TunnelListener — the listen-flow consumer session (ADR-008 §4)
name: TunnelListener, the listen-flow consumer session (ADR-008 §4)
status: completed
depends_on: [tunnels/bindfirst-params, tunnels/forwarded-receipt]
scope: moderate
@@ -12,14 +12,14 @@ tags: [consumer, listener, listen-flow, teardown, tunnel-listener]
## Description
Implement `TunnelListener` (consumer.md §The Listen Flow — ADR-008
Implement `TunnelListener` (consumer.md §The Listen Flow, ADR-008
§4): the listen-flow sibling of `TunnelSession`. It OPENS a listen
tunnel (`channels/tunnel/sub` — the establisher shape is the
tunnel (`channels/tunnel/sub`; the establisher shape is the
producer's choice, invisible here), reads the bind-first reply fields
(`bound`/`listen`), presents them, and owns the listen channel's
teardown (ADR-005's ownership posture extended to the listen flow).
Forwarded connections arrive as ordinary adopted-side `TunnelSession`s
on the opener's manager — `adopt` gains nothing.
on the opener's manager; `adopt` gains nothing.
### The type
@@ -39,45 +39,45 @@ impl TunnelListener {
pub fn bound(&self) -> Option<&SubstrateAddr>;
pub fn listen(&self) -> Option<u32>;
pub async fn close(self) -> bool; // tear the listen channel down
// Drop: sync-reap, best-effort — ADR-005's Drop posture
// Drop: sync-reap, best-effort (ADR-005's Drop posture)
}
```
- **`open`** — `open_channel_with_reply(OP_TUNNEL_OPEN, params,
- **`open`**: `open_channel_with_reply(OP_TUNNEL_OPEN, params,
TUNNEL_ALPN)` (the U-2 surface, landed alkcall 0.8.0): the full
reply arrives, `listen_reply_fields` extracts
`bound`/`listen`, `adopt`-free construction (the client already
adopted the channel — the streams come back from
adopted the channel, since the streams come back from
`open_channel_with_reply`), and the listener holds the channel
entry. The listen channel's own `BiStream` is held but IDLE — the
entry. The listen channel's own `BiStream` is held but IDLE: the
listener session's drop/EOF is the teardown signal the far accept
dispatcher watches (ADR-008 §1's contract); `TunnelListener` never
pumps its own stream.
- **The held BiStream (the one new thing):** unlike
`TunnelSession` (which presents a data plane), a listen session's
stream has NO data-plane role — its only job is to EOF when the
stream has NO data-plane role; its only job is to EOF when the
session drops. Keep the stream (or its write half) alive in the
struct so the drop → EOF ordering is structural (the mux sender
drops → EOF sentinel). Do NOT expose the stream publicly —
drops → EOF sentinel). Do NOT expose the stream publicly;
pumping on a listen channel is meaningless (the far side's
dispatcher ignores it too).
- **`close(self)`/`Drop`** — abort nothing (no pump), reap the
- **`close(self)`/`Drop`**: abort nothing (no pump), reap the
adopted channel entry (`teardown_channel`). The far-side teardown
cascades (EOF → accept-loop cancel → listener drop). Forwarded
sessions are NOT owned by the listener (they arrive as separate
adopted sessions the assembly correlates + owns) — document the
adopted sessions the assembly correlates + owns). Document the
split (listener teardown does not cascade to already-adopted
forwarded sessions; the far listener dying stops NEW accepts).
- **No `Clone`** — same posture as `TunnelSession` (aliasing would
- **No `Clone`**, same posture as `TunnelSession` (aliasing would
alias teardown). Compile-assert with a doc-test like the session's.
- **`forwarded()`**: NOT a method on `TunnelListener` — forwarded
- **`forwarded()`**: NOT a method on `TunnelListener`. Forwarded
sessions arrive on the opener's manager as adopted channels the
assembly correlates via the `listen` token (consumer.md's posture;
the correlation registry is assembly-side). The type hands the
assembly the token (`listen()`); it does not own the receipt
queue. (The task sketch in consumer.md showed `listener.forwarded()`
— the honest shape is the assembly's map; document if a v1
convenience accessor is still wanted — decide, don't silently
queue. (The task sketch in consumer.md showed `listener.forwarded()`,
but the honest shape is the assembly's map; document if a v1
convenience accessor is still wanted: decide, don't silently
drop.)
### The direct path (ADR-007's consumer ctor lives in `tunnels/direct-op`; this task adds nothing to `TunnelSession`)
@@ -85,7 +85,7 @@ impl TunnelListener {
Per ADR-008 §4: "`TunnelSession::adopt` gains nothing." Verify the
claim while implementing: a forwarded channel adopted by the opener's
assembly (`manager.adopt_channel` or `TunnelSession::adopt`) presents
the same session/data-plane/teardown semantics — pin it with a test
the same session/data-plane/teardown semantics; pin it with a test
(adopt a forwarded channel, pump, teardown).
### Tests
@@ -101,7 +101,7 @@ the same session/data-plane/teardown semantics — pin it with a test
- Adopt-path pin: the forwarded session adopt + pump + teardown.
- Scope interaction: an opener identity with `tunnel:open` but NOT
`tunnel:forwarded` still opens the listen channel (that gate is on
the producer side) — forwarded opens to it then fail FORBIDDEN
the producer side), and forwarded opens to it then fail FORBIDDEN
(the per-accept isolation path from `tunnels/bindfirst-producer`).
## Acceptance Criteria
@@ -112,7 +112,7 @@ the same session/data-plane/teardown semantics — pin it with a test
BiStream's EOF is the structural teardown trigger; no
`Clone`
- [x] Forwarded sessions are assembly-adopted ordinary
`TunnelSession`s (adopt gains nothing — pinned by test)
`TunnelSession`s (adopt gains nothing; pinned by test)
- [x] Integration tests: bind-first e2e with the listener type,
teardown cascade, Option posture, adopt-path pin
- [x] Clippy/fmt clean; wasm32 check passes
@@ -121,13 +121,13 @@ the same session/data-plane/teardown semantics — pin it with a test
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§4 the consumer API; Amendment 1 — the token the `listen()` view
(§4 the consumer API; Amendment 1, the token the `listen()` view
reads)
- docs/architecture/decisions/005-consumer-session-owns-teardown.md
(the ownership posture extended to the listen flow)
- docs/architecture/consumer.md §The Listen Flow
- tasks/tunnels/consumer-session.md (the TunnelSession shape this
siblings — close/join/Drop posture, the no-Clone assert)
siblings: close/join/Drop posture, the no-Clone assert)
- tasks/tunnels/bindfirst-params.md (the reply reader);
tasks/tunnels/forwarded-receipt.md (the serving half the tests
compose against)
@@ -138,15 +138,15 @@ the same session/data-plane/teardown semantics — pin it with a test
`BiStream`.** The task sketch said "hold the stream (or its write
half) alive"; the honest shape is the write half alone: the mux
sender's drop is the structural EOF trigger (the mux pump writes
the implicit-EOF chunk for the channel — REQ-CH-01's implicit path —
the implicit-EOF chunk for the channel, REQ-CH-01's implicit path,
which the far accept dispatcher's idle-stream watch observes as the
zero-byte sentinel). Holding the read half too would add nothing
(nothing ever reads it) and would keep the demux receiver alive.
`close()` additionally calls `shutdown()` first (the explicit
sentinel — the graceful path) before the reap; `Drop` relies on the
sentinel, the graceful path) before the reap; `Drop` relies on the
implicit path.
- **`forwarded()` decided: not provided.** Per the task's "decide,
don't silently drop": the honest shape is the assembly's map — the
don't silently drop": the honest shape is the assembly's map. The
listener hands the assembly the token via `listen()`; it does not
own the receipt queue. A v1 convenience accessor would either own a
queue (duplicating assembly state) or wrap one (an accessor over
@@ -154,9 +154,10 @@ the same session/data-plane/teardown semantics — pin it with a test
.forwarded()` sketch line was already marked as the task-sketch
shape; the docs (type docs + consumer.md) state the split.
- **`open` is the ONLY construction path.** No `adopt` (the listener
is an opener-flow type — the open reply carries the correlation
is an opener-flow type; the open reply carries the correlation
material) and no `join` (there is no pump to await; the task's
sketch has no `join` either — teardown is `close`/`Drop` only).
sketch has no `join` either, since teardown is `close`/`Drop`
only).
- **Teardown ordering nuance (test-discovered, documented):** the
far-side wrapper's teardown waits on its handler's R-02 await,
which tracks every per-accept pump it spawned. The listener's EOF
@@ -165,15 +166,15 @@ the same session/data-plane/teardown semantics — pin it with a test
winds down. The e2e drops the local BIND client's end before
asserting the far reap (the forwarded pump's own EOF path).
- **`close()` on a dial-producer listener (both fields `None`)** is
ordinary: the Option posture does not affect teardown — the entry
ordinary: the Option posture does not affect teardown; the entry
reaps regardless of what the establisher contributed.
- **Harness addition (`wire_listener_bindfirst`, ListenerTopology):**
the listen-flow topology — the OPENER dials the transport (connect
the listen-flow topology: the OPENER dials the transport (connect
side, `from_connection_with_serving`: it can both open the listen
tunnel and serve the forwarded op post-hoc via its captured
registry); the producer accepts (serving the bind-first openable;
its forwarded-open caller rides the serving session's
`CallConnection` + the producer-side manager — the design pin from
`CallConnection` + the producer-side manager, the design pin from
`tunnels/bindfirst-producer`). The existing topologies could not
serve this shape: `wire_bindfirst`'s consumer is a hook-captured
`CallConnection` (no `ChannelClient`), and `wire_forward`'s
@@ -181,47 +182,47 @@ the same session/data-plane/teardown semantics — pin it with a test
- **The adopt-path pin lives in its own test** (`forwarded_channel
_adopt_presents_ordinary_session_semantics`, the forwarded-serving
standalone topology): the gate e2e's forwarded channel on the
opener side is wrapper-managed (the serving wrapper allocated it —
a second `adopt_channel` fails `ChannelExists`, the same property
opener side is wrapper-managed (the serving wrapper allocated it,
so a second `adopt_channel` fails `ChannelExists`, the same property
producer_bindfirst.rs pinned), so the pin drives the PRODUCER-side
adoption — `TunnelSession::adopt` + pump + teardown on the
opener-allocated ID — proving the session type gains nothing.
adoption (`TunnelSession::adopt` + pump + teardown on the
opener-allocated ID), proving the session type gains nothing.
## Summary
Implemented `TunnelListener` (src/consumer.rs, ~130 lines) per
consumer.md §The Listen Flow + ADR-008 §4 as amended: `open` (rides
`ChannelClient::open_channel_with_reply` — the full reply arrives and
`ChannelClient::open_channel_with_reply`: the full reply arrives and
`listen_reply_fields` projects `bound`/`listen`; adopt-free
construction), `bound()`/`listen()` (the Option posture), `close`
(the graceful path: `shutdown` on the held write half — the explicit
EOF sentinel — then the sync reap), and `Drop` (the held write half
drops → the mux pump's implicit-EOF chunk — the far accept
dispatcher's teardown signal — then the sync reap; never leaks).
(the graceful path: `shutdown` on the held write half, the explicit
EOF sentinel, then the sync reap), and `Drop` (the held write half
drops → the mux pump's implicit-EOF chunk, the far accept
dispatcher's teardown signal, then the sync reap; never leaks).
The held stream is the WRITE HALF (`MpscSendStream`) only, IDLE and
private — a listen channel has no data-plane role. No `Clone`
private, since a listen channel has no data-plane role. No `Clone`
(compile_fail doc-test, the session's precedent). `forwarded()`
decided: NOT provided — the receipt queue is assembly-side
decided: NOT provided. The receipt queue is assembly-side
bookkeeping; the type hands the assembly the token (`listen()`) and
the docs state the split (listener teardown does not cascade to
already-adopted forwarded sessions; the far listener dying stops NEW
accepts). `TunnelSession` gained nothing (the direct path lives in
`tunnels/direct-op`, untouched).
Tests: `tests/consumer_listener.rs` — 5 integration tests: the
Tests: `tests/consumer_listener.rs` has 5 integration tests: the
bind-first gate (both reply fields surface; the forwarded flow
round-trips through the REAL `register_tunnel_forwarded_openable`
+ correlation map; the teardown cascade — drop listener → far
listener gone → a follow-on accept is never consumed), the Option
+ correlation map; the teardown cascade (drop listener → far
listener gone → a follow-on accept is never consumed)), the Option
posture (a dial producer's open: both fields `None`; close reaps),
close/Drop reaping (the ADR-005 leak-free guarantee,
listen-flavored), the adopt-path pin (a forwarded channel adopted
via `TunnelSession::adopt` presents the ordinary session semantics —
via `TunnelSession::adopt` presents the ordinary session semantics;
adopt gains nothing), and the scope interaction (`tunnel:open`-only
opener still opens the listen channel; forwarded opens to it fail
FORBIDDEN before the assembly handler runs). Harness addition:
`wire_listener_bindfirst` + `ListenerTopology` (the opener-dials
shape — client + serving on the opener, bind-first serving +
shape: client + serving on the opener, bind-first serving +
call-plane forwarded-open caller on the producer). One doc-fix drive-
by in producer.rs (two broken intra-doc links, pre-existing).
@@ -229,6 +230,6 @@ Verified: `cargo test` green (132 default; 157 `--all-features`), doc
tests pass (both `compile_fail` asserts), clippy `--all-targets -D
warnings` clean (native + wasm32), fmt clean,
`wasm32-unknown-unknown` check passes (the default crate stays
wasm-clean), `cargo doc --no-deps` clean (0 warnings — the 2
wasm-clean), `cargo doc --no-deps` clean (0 warnings, the 2
pre-existing broken links fixed). CHANGELOG updated (Unreleased →
Added).
+18 -16
View File
@@ -23,7 +23,7 @@ pin.
```rust
pub const MAX_DATAGRAM_LEN: usize = u16::MAX as usize; // 65535
pub struct DatagramCodecError { .. } // thiserror: Oversize(usize, usize) (planned shape; InvalidLength removed post-review — see Notes)
pub struct DatagramCodecError { .. } // thiserror: Oversize(usize, usize) (planned shape; InvalidLength removed post-review, see Notes)
pub fn frame_datagram(payload: &[u8]) -> Result<Bytes, DatagramCodecError>
// [len: u16 BE][payload]; len=0 is a legal empty datagram; >65535 = Oversize at frame time
@@ -41,7 +41,7 @@ impl DatagramReader {
spec's executable form)
1. Single datagram round-trip (frame → feed → exact bytes out).
2. Empty datagram (`len=0`): survives as a real datagram — NEVER
2. Empty datagram (`len=0`): survives as a real datagram and is NEVER
confused with EOF (the F-2 invariant; the codec layer never emits a
zero-length read).
3. Split across chunks (feed a frame in awkward 7-byte chunks; exact
@@ -51,15 +51,16 @@ spec's executable form)
6. Mid-datagram state observable (incremental decode correctness).
7. Oversize rejected at frame time (never a wire overflow).
8. Truncation fail-loud (OQ-TN-13): the adapter-level receive that
would truncate surfaces as an error — test at the codec boundary by
would truncate surfaces as an error. Test at the codec boundary by
asserting `frame_datagram` + reader round-trip with a buffer
smaller than a full datagram is not silently accepted (the concrete
API shape lands with `tunnels/local-socket-halves`; here, pin the
codec-side invariant: a truncated stream yields an error, not a
partial datagram).
No `stream_type` byte, no 5-byte header — a tunnel has one data stream
per direction (ADR-003). No EOF sentinel in the codec — EOF is the
No `stream_type` byte and no 5-byte header: a tunnel has one data
stream per direction (ADR-003). No EOF sentinel in the codec, since
EOF is the
channels-level `length=0` chunk, a different layer (the two coexist;
the POC proved no collision).
@@ -71,8 +72,8 @@ the POC proved no collision).
boundaries
- [x] `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`
clean
- [x] wasm32 check passes (the codec is pure byte work — it must be
wasm-clean)
- [x] wasm32 check passes (the codec is pure byte work, so it must
be wasm-clean)
## References
@@ -86,17 +87,17 @@ the POC proved no collision).
- Direct port of the POC's wire.rs (17 tests rode it) with the crate
conventions: thiserror doc comments, module doc per ADR-003 +
wire.md + bast.md layering (sentinel layering called out — the F-2
invariant is load-bearing, so the module doc states why raw
wire.md + bast.md layering (sentinel layering is called out; the
F-2 invariant is load-bearing, so the module doc states why raw
pass-through is structurally broken for UDP).
- Error naming per the task's API: `Oversize(usize, usize)` (was
POC's `TooLarge`). (2026-09-09: the `InvalidLength` variant was
REMOVED post-review — `feed` never returned it; the truncated-stream
REMOVED post-review (`feed` never returned it); the truncated-stream
contract is `is_mid_datagram` + the session's `TruncatedDatagram`
instead, per the coverage-review commit. A completed task doc
records its planned shape, not corrections made after.)
- `feed` returns `Vec<Bytes>` per the task API (the POC's
`Vec<Datagram>` wrapper dropped — `Bytes` is the payload; the mux
`Vec<Datagram>` wrapper dropped, since `Bytes` is the payload; the mux
handoff is zero-copy via `BytesMut::freeze`).
- `MAX_DATAGRAM_LEN`/`DATAGRAM_LEN_FIELD` constants; `DATAGRAM_LEN_FIELD`
is private (implementation detail), `MAX_DATAGRAM_LEN` public (the
@@ -104,8 +105,8 @@ the POC proved no collision).
- 8 test families: single round-trip, empty-datagram-not-EOF,
7-byte-chunk split reassembly, two-datagram batch, partial header,
mid-datagram observability, oversize at frame time, truncated
stream (feeds an empty chunk + a one-byte-short tail — never yields
a partial datagram; the codec-side half of OQ-TN-13; the
stream (feeds an empty chunk + a one-byte-short tail; it never
yields a partial datagram, the codec-side half of OQ-TN-13; the
adapter-level receive shape lands with local-socket-halves).
- POC's `Datagram` struct dropped: `Bytes` suffices (the task's
`feed -> Vec<Bytes>` signature); `is_empty()` moves to callers
@@ -116,7 +117,8 @@ the POC proved no collision).
`src/wire.rs` complete: `frame_datagram` (Oversize at frame time,
`len=0` legal), `DatagramReader` (incremental decoder, `is_mid_datagram`
teardown diagnostic), `DatagramCodecError {Oversize}` (originally
sketched `{Oversize, InvalidLength}`; see the 2026-09-09 note above) —
the ADR-003/BAST contract in executable form. 8 codec tests + 6 params
sketched `{Oversize, InvalidLength}`; see the 2026-09-09 note above).
This is the ADR-003/BAST contract in executable form. 8 codec tests + 6 params
tests = 14 passing. Verified: cargo test, clippy --all-targets -D
warnings (native + wasm32), fmt --check, wasm32 check — all clean.
warnings (native + wasm32), fmt --check, and the wasm32 check are all
clean.
+21 -21
View File
@@ -1,5 +1,5 @@
//! Integration tests for `TunnelListener` (the listen-flow consumer
//! session — ADR-008 §4, the `tunnels/tunnel-listener` shape): the
//! session (ADR-008 §4, the `tunnels/tunnel-listener` shape): the
//! listen-flow sibling of `TunnelSession`. `open` rides
//! `open_channel_with_reply` (the U-2 surface), the bind-first reply
//! fields surface via `bound()`/`listen()` (the Option posture), the
@@ -17,12 +17,12 @@
//! - `close` reaps the listen channel; `Drop` without close reaps.
//! - Adopt-path pin: a forwarded channel adopted via
//! `TunnelSession::adopt` presents the ordinary session semantics
//! (adopt gains nothing — pump + teardown on the adopted entry).
//! (adopt gains nothing: pump + teardown on the adopted entry).
//! - Scope interaction: an opener identity with `tunnel:open` but NOT
//! `tunnel:forwarded` still opens the listen channel; forwarded
//! opens to it then fail FORBIDDEN (per-accept isolation).
//! - Graceful `close` (review 002 G-2): the explicit shutdown
//! sentinel is observed at the FAR accept dispatcher — the idle-read
//! sentinel is observed at the FAR accept dispatcher; the idle-read
//! fires, the loop cancels, the serving side reaps.
//! - Write-happy peer (review 002 N-2's testable residue): a
//! non-empty read on the idle listen stream tears the listener down
@@ -70,7 +70,7 @@ fn test_bind() -> BindFn {
}
/// The test accept loop: pops from the test's VecDeque + Notify pair
/// (one accepted pair per closure call — the dispatcher invokes the
/// (one accepted pair per closure call; the dispatcher invokes the
/// closure per accept).
fn queue_loop(
queue: Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
@@ -222,10 +222,10 @@ fn opener_only_channel_0(topo: &ListenerTopology) -> bool {
.all(|&id| id == 0)
}
/// THE gate: `open` against the bind-first producer — both reply
/// THE gate: `open` against the bind-first producer, both reply
/// fields surface via `bound()`/`listen()`, the forwarded flow runs
/// through a REAL `TunnelSession::adopt` of the producer-allocated
/// forwarded channel (adopt gains nothing — the session type is
/// forwarded channel (adopt gains nothing; the session type is
/// ordinary), and dropping the listener tears the far listener down
/// (subsequent forwarded opens fail).
#[tokio::test]
@@ -276,7 +276,7 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() {
// The forwarded channel arrives as a PRODUCER-INITIATED open the
// opener serves: the opener-allocated ID appears on the opener's
// manager (wrapper-managed — the serving side allocates, ADR-047
// manager (wrapper-managed: the serving side allocates, ADR-047
// §5; the PRODUCER adopts it inside the harness's forwarded-open
// caller and pumps the accepted halves against it).
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
@@ -293,7 +293,7 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() {
// writes on the local BIND client's end cross the assembly pump →
// the forwarded channel → the producer-side adopted pump → the
// accepted handle's echo → and back. The forwarded session is an
// ordinary channel — `adopt` gains nothing (the standalone adopt
// ordinary channel; `adopt` gains nothing (the standalone adopt
// pin below drives the same session type directly).
use tokio::io::{AsyncReadExt, AsyncWriteExt};
b_write.write_all(b"bind-ping").await.expect("write");
@@ -311,7 +311,7 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() {
// Teardown cascade: drop the listener → the held write half EOFs
// the listen channel → the far accept loop cancels → the far
// listener is gone (a follow-on accept is never consumed). The
// client end drops first (the forwarded session's own teardown —
// client end drops first (the forwarded session's own teardown:
// the pump winds down; the handler's R-02 await unblocks only
// when every per-accept pump finished).
drop(b_read);
@@ -337,11 +337,11 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() {
}
/// The adopt-path pin (ADR-008 §4: "`TunnelSession::adopt` gains
/// nothing — forwarded channels are ordinary adopted channels"): in
/// nothing; forwarded channels are ordinary adopted channels"): in
/// the forwarded-serving standalone shape (the test plays the
/// producer), a forwarded channel adopted via `TunnelSession::adopt`
/// presents the ordinary session semantics — substrate-shaped data
/// plane, pump, teardown — identical to any other adopted session.
/// presents the ordinary session semantics (substrate-shaped data
/// plane, pump, teardown) identical to any other adopted session.
#[tokio::test]
async fn forwarded_channel_adopt_presents_ordinary_session_semantics() {
// The forwarded-serving topology: the producer leg (connect side,
@@ -366,7 +366,7 @@ async fn forwarded_channel_adopt_presents_ordinary_session_semantics() {
correlations.lock().await.insert(TOKEN, local);
// The producer calls the forwarded op (valid params + scope) and
// adopts the opener-allocated ID — via `TunnelSession::adopt` (the
// adopts the opener-allocated ID via `TunnelSession::adopt` (the
// session type's adopt path; `adopt gains nothing`).
let out = topo
.producer
@@ -412,7 +412,7 @@ async fn forwarded_channel_adopt_presents_ordinary_session_semantics() {
assert_eq!(&buf, b"adopt-ping");
// Teardown: the adopted session reaps its OWN entry (per-session,
// ADR-005) — independent of any listener. The far end (the local
// ADR-005), independent of any listener. The far end (the local
// BIND client end) drops first: the assembly pump's `pump_bidi`
// (R-02, the wrapper's handler task) unwinds when both pumps
// finish, and only THEN does the wrapper's handler-exit teardown
@@ -433,10 +433,10 @@ async fn forwarded_channel_adopt_presents_ordinary_session_semantics() {
);
}
/// The Option posture: `open` against a plain DIAL producer — both
/// The Option posture: `open` against a plain DIAL producer, both
/// reply fields `None`, the session is ordinary. (`wire_forward`'s
/// producer (accept side) serves the base openable with a DIAL
/// establisher — a dial establisher never binds, so the reply carries
/// establisher; a dial establisher never binds, so the reply carries
/// neither field; the listener session is otherwise ordinary.)
#[tokio::test]
async fn dial_producer_open_both_fields_none() {
@@ -499,7 +499,7 @@ async fn close_and_drop_reap_the_listen_channel() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(!topo.producer_manager.has_channel(id1));
// Drop(): the best-effort path — no leak.
// Drop(): the best-effort path (no leak).
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
@@ -589,7 +589,7 @@ async fn open_only_opener_still_opens_listen_but_forwarded_fails() {
/// The graceful-teardown half of ADR-008 §1's contract (review 002
/// G-2): `TunnelListener::close`'s explicit `shutdown` (the EOF
/// sentinel) is observed at the FAR accept dispatcher — the idle-read
/// sentinel) is observed at the FAR accept dispatcher; the idle-read
/// fires, the accept loop cancels, the pushed accept is never
/// consumed, and the serving-side listen channel reaps. The Drop path
/// (`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`) and
@@ -665,7 +665,7 @@ async fn graceful_close_eof_sentinel_observed_at_far_accept_dispatcher() {
/// The write-happy-peer teardown posture (review 002 N-2's testable
/// residue): a non-empty read on the idle listen stream tears the
/// listener down — the fail-closed arm (`Ok(_)` in the idle-stream EOF
/// listener down; the fail-closed arm (`Ok(_)` in the idle-stream EOF
/// watch, src/producer.rs). The channel is not a data plane; ANY
/// non-empty read is a teardown trigger, not just the zero-byte
/// sentinels. The typed `TunnelListener` never writes, so the test
@@ -687,7 +687,7 @@ async fn write_happy_peer_on_idle_listen_channel_tears_down_far_listener() {
.await;
// The misbehaving opener: the raw client surface (the typed
// `TunnelListener` never exposes the write half — it holds it
// `TunnelListener` never exposes the write half; it holds it
// idle by contract). Hold the write half ALIVE: the idle read
// must fire from the written byte, not from a dropped session.
let input = serde_json::to_value(listen_params("bind")).expect("params serialize");
@@ -703,7 +703,7 @@ async fn write_happy_peer_on_idle_listen_channel_tears_down_far_listener() {
assert!(topo.producer_manager.has_channel(channel_id));
// Prove the accept loop is alive FIRST (a consumed accept), then
// write a byte into the idle channel — the dispatcher's idle read
// write a byte into the idle channel, and the dispatcher's idle read
// observes the non-empty read and tears the listener down
// (fail-closed).
push_accept(&queue, &notify, (198, 55236)).await;
+20 -20
View File
@@ -1,4 +1,4 @@
//! Integration tests for the consumer half — `TunnelSession` per
//! Integration tests for the consumer half (`TunnelSession` per
//! consumer.md + ADR-005, over the duplex harness. Ported from the
//! reverse POC's `ReverseTunnel` suite (the W3/W4 probes) plus the
//! forward POC's session shapes, generalized to the spec's session
@@ -7,9 +7,9 @@
//! Covered: forward open (session halves drive a duplex round-trip),
//! reverse open+adopt+pump_against (round-trip, W4 half-close, join
//! copy counts, out-of-band close + self-reaping, pump-less join),
//! datagram variant (round-trip incl. the empty datagram — the F-2
//! datagram variant (round-trip incl. the empty datagram, the F-2
//! layering), wrong-substrate errors, and the teardown matrix
//! (close/join/Drop — no leaked channel entries via `channel_ids()`).
//! (close/join/Drop; no leaked channel entries via `channel_ids()`).
mod harness;
@@ -105,7 +105,7 @@ async fn forward_open_session_halves_round_trip() {
#[tokio::test]
async fn forward_open_take_halves_then_pumpless_join() {
// consumer.md's pinned semantics: after take_halves the session is
// pump-less — join completes immediately, reaps only, (0, 0, reaped).
// pump-less: join completes immediately, reaps only, (0, 0, reaped).
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
@@ -145,7 +145,7 @@ async fn forward_open_take_halves_then_pumpless_join() {
#[tokio::test]
async fn datagram_round_trip_including_empty() {
// The datagram variant over the codec (ADR-003): round-trip incl.
// the F-2 empty datagram — `len=0` is a legal datagram, never EOF.
// the F-2 empty datagram (`len=0` is a legal datagram, never EOF).
let registry = ResourceRegistry::new();
registry.register("dns", Substrate::Udp, "in-process").await;
let topo = wire_forward(registry, framed_udp_echo_dial()).await;
@@ -177,13 +177,13 @@ async fn datagram_round_trip_including_empty() {
.await
.expect("recv timed out")
.expect("no io error")
.expect("second datagram (empty round-trips — F-2)");
.expect("second datagram (empty round-trips, F-2)");
assert!(dg2.is_empty(), "the empty datagram round-trips (F-2)");
let reaped = session.close().await;
assert!(reaped);
// The producer-side wrapper reap is async (the pump task exits,
// the wrapper reaps) — settle before asserting both managers.
// the wrapper reaps); settle before asserting both managers.
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(only_channel_0(&topo));
}
@@ -191,7 +191,7 @@ async fn datagram_round_trip_including_empty() {
#[tokio::test]
async fn datagram_backlog_tracks_pending_and_drains_fifo() {
// The §1.3/§3.3 session-shape diagnostic: `datagram_backlog()` is
// the `DatagramHalves.pending` count — None for stream sessions,
// the `DatagramHalves.pending` count: None for stream sessions,
// 0 at rest, batched arrivals queue FIFO, and a drained queue
// reads 0 (the wave-2 harness asserts the bound against it).
let registry = ResourceRegistry::new();
@@ -224,7 +224,7 @@ async fn datagram_backlog_tracks_pending_and_drains_fifo() {
// Backlog drain posture: two echoed datagrams land back-to-back;
// whether the echo batches them into one chunk is timing-shaped,
// so the suite pins only the rest state (empty after drains) —
// so the suite pins only the rest state (empty after drains);
// the batched-arrival bound is the wave-2 harness's (§3.3).
session.send_datagram(b"first").await.expect("send");
session.send_datagram(b"second").await.expect("send");
@@ -409,7 +409,7 @@ async fn reverse_half_close_semantics_w4() {
async fn out_of_band_close_then_session_self_reap() {
// The POC's reverse_channel_close_out_of_band shape: a peer-initiated
// `channel/close` tears the SERVING side down; the consumer's session
// still owns its adopted entry — close/join reaps it (W3 split).
// still owns its adopted entry; close/join reaps it (W3 split).
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
@@ -473,7 +473,7 @@ async fn drop_never_leaks_the_channel_entry() {
);
// Drop with a session-owned pump: the pump aborts too (the reverse
// topology — pump_against is the reverse-flow use).
// topology, where pump_against is the reverse-flow use).
let topo = wire(registry, echo_dial()).await;
let channel_id =
open_reverse_channel(&topo.consumer_call, &params("echo", Substrate::Tcp), None)
@@ -522,7 +522,7 @@ async fn oversize_datagram_rejected_at_frame_time() {
async fn datagram_clean_eof_is_none_not_error() {
// consumer.md's EOF contract for pump-less datagram sessions: a
// clean stream EOF (the channels-level sentinel, mid-frame
// state clear) resolves `Ok(None)` — the caller's shutdown
// state clear) resolves `Ok(None)`, the caller's shutdown
// signal, distinct from both an empty datagram (`Some(b"")`,
// F-2) and a truncated stream (`TruncatedDatagram`).
let registry = ResourceRegistry::new();
@@ -571,7 +571,7 @@ async fn datagram_mid_frame_eof_fails_loud_truncated() {
// The echo's far end forwards the frame but drops the connection
// BEFORE the payload completes (the 2-byte length prefix rides;
// the payload is cut — mid-datagram EOF).
// the payload is cut (mid-datagram EOF).
let err = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
.await
.expect("recv timed out")
@@ -590,7 +590,7 @@ async fn datagram_mid_frame_eof_fails_loud_truncated() {
async fn datagram_data_plane_after_take_halves_is_channel_taken() {
// After the halves were taken, the data plane is gone: datagram
// accessors fail with `ChannelTaken` (the pinned `ChannelTaken`
// surface — the session is still the channel-entry owner).
// surface; the session is still the channel-entry owner).
let registry = ResourceRegistry::new();
registry.register("dns", Substrate::Udp, "in-process").await;
let topo = wire_forward(registry, framed_udp_echo_dial()).await;
@@ -617,7 +617,7 @@ async fn datagram_data_plane_after_take_halves_is_channel_taken() {
assert!(matches!(err, alktunnels::TunnelIoError::ChannelTaken));
// The taken halves carry the WIRE framing (ADR-005's N-3 pin):
// the raw bytes are `[len: u16 BE][payload]` — framing duty
// the raw bytes are `[len: u16 BE][payload]`; framing duty
// transferred to the caller, visible on the wire.
use tokio::io::{AsyncReadExt, AsyncWriteExt};
write
@@ -646,7 +646,7 @@ async fn datagram_data_plane_after_take_halves_is_channel_taken() {
async fn reverse_udp_pump_against_framed_adapter_round_trips() {
// The reverse UDP shape: the session's codec data plane is
// consumed by `pump_against` and the accepted local half speaks
// the FRAMED wire (the N-11 contract — the pump copies raw bytes,
// the FRAMED wire (the N-11 contract: the pump copies raw bytes,
// so the `[len: u16 BE]` framing lives at the boundary; the test's
// local end stands in for the framed adapter, e.g. `local::UdpHalf`).
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -672,7 +672,7 @@ async fn reverse_udp_pump_against_framed_adapter_round_trips() {
let mut local_end = local_end;
// The local end speaks FRAMED bytes end-to-end: a payload frame
// crosses the pump untouched and echoes back framed — decode
// crosses the pump untouched and echoes back framed; decode
// locally with the codec (boundary preserved, F-2 shape intact).
let sent = alktunnels::wire::frame_datagram(b"rev-udp").expect("frame");
local_end.write_all(&sent).await.expect("write frame");
@@ -686,7 +686,7 @@ async fn reverse_udp_pump_against_framed_adapter_round_trips() {
.expect("read");
assert_eq!(framed_back, &sent[..], "framed bytes pass through");
// An EMPTY datagram (`len=0`) also round-trips — never collapsed
// An EMPTY datagram (`len=0`) also round-trips, never collapsed
// to EOF across the pump (the F-2 invariant, reverse direction).
local_end
.write_all(&[0, 0])
@@ -752,8 +752,8 @@ async fn failed_open_never_yields_a_session() {
async fn adopt_failure_is_typed_not_establishment() {
// An adopt failure is `AdoptFailed`-class (post-reply local
// failure: ID collision), not an establishment error. A bogus-id
// adopt SUCCEEDS by design — the manager parks early arrivals for
// any not-yet-seen ID (the adoption race cover, ADR-047 §5) — so
// adopt SUCCEEDS by design, since the manager parks early arrivals
// for any not-yet-seen ID (the adoption race cover, ADR-047 §5);
// the collision path is the honest probe: adopt the same ID twice.
let registry = ResourceRegistry::new();
registry
+30 -30
View File
@@ -1,28 +1,28 @@
//! The end-to-end suite — every validated POC behavior, re-expressed
//! The end-to-end suite: every validated POC behavior, re-expressed
//! against the crate's public API (tests/common/harness.rs, the two
//! POC harnesses' topologies unified). The spec's executable form: the
//! quality gate before the implementation review.
//!
//! Suites:
//! 1. **Forward (`-L`)** — open → establisher dials → pump → round-
//! 1. **Forward (`-L`)**: open → establisher dials → pump → round-
//! trip; 1 MiB backpressure; typed errors; per-call opener identity
//! (CF-006); establishment deadline → `timeout` (ADR-049 §2).
//! 2. **Reverse (`-R`)** — `open_reverse_channel` + `adopt` +
//! 2. **Reverse (`-R`)**: `open_reverse_channel` + `adopt` +
//! `pump_against`; odd-ID allocation (ADR-047 §5); the identity
//! precedence chain (CF-005); half-close (W4); out-of-band close +
//! self-reaping (W3); same-resource concurrency (R-01).
//! 3. **Listen producer** — the accept-queue flow + typed errors.
//! 4. **Datagram (`udp`)** — forward + reverse datagram sessions via
//! 3. **Listen producer**: the accept-queue flow + typed errors.
//! 4. **Datagram (`udp`)**: forward + reverse datagram sessions via
//! the codec; round-trip, empty datagram (F-2), chunk-split
//! survival, TCP+UDP concurrent on one connection.
//! 5. **Teardown matrix** (ADR-005) — close/join/Drop; `channel_ids()`
//! 5. **Teardown matrix** (ADR-005): close/join/Drop; `channel_ids()`
//! asserts no leaks on either side.
//! 6. Spec-conformance assertions — no phantom channel on a failed
//! 6. Spec-conformance assertions: no phantom channel on a failed
//! open, no leaked session on a failed adopt, R-02's
//! JoinHandle-tracks-the-data-plane shape.
//!
//! (`local`-feature real-socket suites live in tests/local_halves.rs
//! — the duplex suites here need no sockets.)
//! (`local`-feature real-socket suites live in tests/local_halves.rs;
//! the duplex suites here need no sockets.)
mod harness;
@@ -45,7 +45,7 @@ fn params(resource: &str, substrate: Substrate) -> TunnelParams {
}
}
/// Only channel 0 (the pre-negotiated call channel) may remain —
/// Only channel 0 (the pre-negotiated call channel) may remain;
/// asserted on both sides of both topologies after every teardown.
trait LeakCheck {
fn no_data_channels(&self) -> bool;
@@ -104,7 +104,7 @@ fn call_error_reason(err: &alktunnels::ReverseOpenError) -> Option<String> {
}
// =====================================================================
// Suite 1 — Forward (`-L`): open → establisher → pump → round-trip
// Suite 1, Forward (`-L`): open → establisher → pump → round-trip
// =====================================================================
/// A 1 MiB patterned payload round-trips through the two-pump shape
@@ -175,7 +175,7 @@ async fn forward_establishment_deadline_maps_to_timeout_typed() {
let producer_op_registry = Arc::new(alkcall::registry::registration::OperationRegistry::new());
// The timeout topology: the default wire() shape with the hanging
// establisher replacing the dial establisher — wired by hand here
// establisher replacing the dial establisher, wired by hand here
// via wire_with's Timeout mode on the reverse topology, then the
// forward direction asserted through the session API on a fresh
// forward wiring below.
@@ -251,7 +251,7 @@ async fn forward_establishment_deadline_maps_to_timeout_typed() {
/// both the transport-identity path and the token path.
#[tokio::test]
async fn forward_opener_identity_witnessed_on_both_paths() {
// Transport identity path (the mTLS posture — no token).
// Transport identity path (the mTLS posture; no token).
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
@@ -299,7 +299,7 @@ async fn forward_opener_identity_witnessed_on_both_paths() {
}
// =====================================================================
// Suite 2 — Reverse (`-R`): open_reverse_channel + adopt + pump_against
// Suite 2 (Reverse, `-R`): open_reverse_channel + adopt + pump_against
// =====================================================================
/// Odd-ID allocation under ADR-047 §5: the serving side (producer,
@@ -328,7 +328,7 @@ async fn reverse_channel_ids_are_odd_and_distinct() {
for id in ids {
// The reverse-open'd channel was never adopted: the manager
// parked its early arrivals. `adopt_channel` drains the park
// and installs the entry — adopt, then close (the session's
// and installs the entry; adopt, then close (the session's
// teardown; teardown_channel alone is UnknownChannel for a
// parked-but-never-adopted id).
let session = TunnelSession::adopt(
@@ -382,8 +382,8 @@ async fn reverse_identity_precedence_chain() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// (b) The token wins over the transport identity: the provider
// resolves the scoped token to a DISTINCT id — the witness proves
// which one the establisher saw.
// resolves the scoped token to a DISTINCT id, and the witness
// proves which one the establisher saw.
struct DistinctTokenProvider;
impl alkcall::core::auth::IdentityProvider for DistinctTokenProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<alkcall::core::auth::Identity> {
@@ -522,7 +522,7 @@ async fn reverse_concurrent_same_resource_no_handoff_race() {
}
// =====================================================================
// Suite 3 — Listen producer (shape 2, the accept-queue flow)
// Suite 3, Listen producer (shape 2, the accept-queue flow)
// =====================================================================
/// The listen-queue flow: FIFO always-before-take across two opens;
@@ -584,7 +584,7 @@ async fn listen_fifo_ordering_two_opens_two_handles() {
/// The listen-shape typed-error table (producer.md's mapping):
/// unknown resource, empty-queue → resource_shortage, closed listener
/// → dial_failed — each leaves no channel (the no-phantom property).
/// → dial_failed; each leaves no channel (the no-phantom property).
#[tokio::test]
async fn listen_typed_error_table_leaves_no_channel() {
// Empty queue (the assembly's fail-fast posture: a bounded accept
@@ -647,11 +647,11 @@ async fn listen_typed_error_table_leaves_no_channel() {
}
// =====================================================================
// Suite 4 — Datagram (`udp`): forward + reverse via the codec
// Suite 4 (Datagram, `udp`): forward + reverse via the codec
// =====================================================================
/// Many datagrams in each direction, boundaries preserved, batched
/// frames survive (one chunk may carry several datagrams — the
/// frames survive (one chunk may carry several datagrams; the
/// pending-queue cover).
#[tokio::test]
async fn datagram_boundaries_preserved_both_directions() {
@@ -663,7 +663,7 @@ async fn datagram_boundaries_preserved_both_directions() {
.await
.expect("udp open");
// Burst 40 datagrams (varied sizes) — they may batch into shared
// Burst 40 datagrams (varied sizes); they may batch into shared
// chunks; each must come back as exactly one datagram.
let mut payloads = Vec::new();
for i in 0..40u16 {
@@ -683,7 +683,7 @@ async fn datagram_boundaries_preserved_both_directions() {
}
assert_eq!(received, payloads, "boundaries preserved under batching");
// The reverse direction: the target echo only repeats — drive the
// The reverse direction: the target echo only repeats, so drive the
// other way by sending again after draining; order holds.
session.send_datagram(b"final").await.expect("send");
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
@@ -748,7 +748,7 @@ async fn reverse_udp_datagram_session_round_trips() {
.await
.expect("adopt");
// No pump_against: the session's codec data plane is driven
// directly (the pump-less datagram shape — pump_bidi copies raw
// directly (the pump-less datagram shape; pump_bidi copies raw
// bytes, so the pump is the consumer's job here only when it
// holds local halves; the direct drive is the session API's).
session.send_datagram(b"query").await.expect("send");
@@ -764,7 +764,7 @@ async fn reverse_udp_datagram_session_round_trips() {
assert!(only_channel_0(&topo));
}
/// TCP + UDP sessions concurrently on ONE connection — the mux is
/// TCP + UDP sessions concurrently on ONE connection; the mux is
/// transparent across substrates (both POCs' concurrent shape).
#[tokio::test]
async fn tcp_and_udp_concurrent_on_one_connection() {
@@ -820,7 +820,7 @@ async fn tcp_and_udp_concurrent_on_one_connection() {
}
// =====================================================================
// Suite 5 — Teardown matrix (ADR-005): close / join / Drop
// Suite 5, Teardown matrix (ADR-005): close / join / Drop
// =====================================================================
/// close on a session WITH a spawned pump: aborts the pump, reaps the
@@ -906,7 +906,7 @@ async fn teardown_join_reports_copy_counts_and_reaps() {
assert!(only_channel_0(&topo));
}
/// Drop after pump_against: the pump aborts AND the entry reaps —
/// Drop after pump_against: the pump aborts AND the entry reaps,
/// the full teardown, not just the pump (the drop matrix's
/// pump-carrying arm).
#[tokio::test]
@@ -940,7 +940,7 @@ async fn teardown_drop_with_pump_reaps_entry() {
}
/// A failed adopt leaks nothing: the manager's parked entry is
/// released (the failed-adopt arm of the matrix) — a bogus-ID adopt
/// released (the failed-adopt arm of the matrix); a bogus-ID adopt
/// SUCCEEDS by design (the manager parks any unseen id), so the
/// collision path is the honest probe; after the probe the queue is
/// clean on both sides.
@@ -985,7 +985,7 @@ async fn teardown_failed_adopt_leaves_no_leak() {
}
// =====================================================================
// Suite 6 — Spec-conformance assertions (kept visible)
// Suite 6, Spec-conformance assertions (kept visible)
// =====================================================================
/// A failed open NEVER returns a channel id (the no-phantom-channel
@@ -1099,7 +1099,7 @@ struct Taken {
}
/// An in-process accepted handle: a duplex pair whose far end echoes
/// (the same shape the dial harness echoes with — a listen producer's
/// (the same shape the dial harness echoes with, a listen producer's
/// "accepted connection").
fn echo_handle(_label: &str) -> TargetHandle {
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
+58 -57
View File
@@ -1,13 +1,13 @@
//! Test harness — the producer↔consumer wiring over a
//! Test harness: the producer↔consumer wiring over a
//! `tokio::io::duplex` carrying the channels 8-byte chunk format.
#![allow(dead_code)]
//!
//! Topology (the reverse POC's shape, mirrored for both directions):
//! - **producer** (connect side of the channels connection — it runs
//! - **producer** (connect side of the channels connection: it runs
//! `ChannelClient::from_connection_with_serving`): dials the
//! duplex, serves the tunnel open op + generic channel ops on its
//! serving registry. Its manager allocates the tunnel channel IDs
//! (`ChannelSide::Connect` — ADR-047 §5: the serving side
//! (`ChannelSide::Connect`; ADR-047 §5: the serving side
//! allocates; the initiator adopts).
//! - **consumer** (accept side): the `ChannelsAdapter` + capturing
//! install hook. The hook captures the consumer's channel-0
@@ -43,7 +43,7 @@ use alktunnels::producer::{
};
/// The consumer identity the transport carries (the mTLS/QUIC
/// analogue — key-based identity resolved out-of-band, attached to
/// analogue: key-based identity resolved out-of-band, attached to
/// the dialing connection before `from_connection_with_serving`).
pub fn consumer_identity() -> Identity {
Identity {
@@ -54,7 +54,7 @@ pub fn consumer_identity() -> Identity {
}
/// The direct-egress identity: `tunnel:direct` ONLY (no
/// `tunnel:open`) — the scope-separation probe's identity.
/// `tunnel:open`); this is the scope-separation probe's identity.
pub fn direct_identity() -> Identity {
Identity {
id: "consumer-direct".to_string(),
@@ -64,7 +64,7 @@ pub fn direct_identity() -> Identity {
}
/// Both scopes: `tunnel:open` AND `tunnel:direct` (a deployment
/// granting both grants both explicitly — ADR-006 Amendment 1).
/// granting both grants both explicitly; ADR-006 Amendment 1).
pub fn both_scopes_identity() -> Identity {
Identity {
id: "consumer-both".to_string(),
@@ -76,7 +76,7 @@ pub fn both_scopes_identity() -> Identity {
}
}
/// The scoped token (the hub-forwarding path) — resolves to
/// The scoped token (the hub-forwarding path) resolves to
/// [`consumer_identity`] via [`TestIdProvider`].
pub const TEST_AUTH_TOKEN: &str = "test-token-with-tunnel-open-scope";
@@ -105,23 +105,23 @@ pub struct Topology {
/// The consumer's manager (accept side). `adopt` installs the
/// producer-allocated IDs here; reaping calls `teardown_channel`.
pub consumer_manager: ChannelManager,
/// The consumer's channel-0 call connection — its surface for
/// The consumer's channel-0 call connection, the surface for
/// calling the producer's open op (and channel/close).
pub consumer_call: Arc<CallConnection>,
/// The producer's serving registry (probes).
pub producer_registry: Arc<OperationRegistry>,
/// The consumer's serving registry (the opener-side op stubs —
/// e.g. the forwarded-op serving stub — pre-register here).
/// The consumer's serving registry (the opener-side op stubs,
/// e.g. the forwarded-op serving stub, pre-register here).
pub consumer_registry: Arc<OperationRegistry>,
/// What the establisher's per-call auth carried as the opener
/// identity id (the CF-006 probe — `None` until an open runs).
/// identity id (the CF-006 probe; `None` until an open runs).
pub identity_witness: Arc<tokio::sync::Mutex<Option<String>>>,
}
/// Wire the topology. `transport_identity` is the key-based identity
/// attached to the dialing connection (CF-005 remediation (b));
/// `serving_identity` is the explicit `ServingConfig.identity`
/// override (CF-005 remediation (a)) — `None` in the primary path.
/// override (CF-005 remediation (a)), `None` in the primary path.
/// `provider` resolves payload tokens (the hub-forwarding path).
/// The open-op registration mode: a dial establisher (shape 1) or a
/// listen establisher over an assembly-fed [`AcceptQueue`] (shape 2).
@@ -130,10 +130,10 @@ pub enum RegistrationMode {
Listen(alktunnels::producer::AcceptFn),
/// A hanging establisher registered with an explicit
/// per-registration establishment timeout (the deadline-expiry
/// probe — ADR-049 §2's bound override; the open fails with reason
/// probe: ADR-049 §2's bound override; the open fails with reason
/// `timeout` and no channel survives).
Timeout(std::time::Duration),
/// The no-witness dial establisher (`tunnel_establisher` — the
/// The no-witness dial establisher (`tunnel_establisher`, the
/// public default shape, no CF-006 probe seam).
DialNoWitness(alktunnels::producer::DialFn),
/// The direct op (`channels/tunnel/direct`, ADR-007): the direct
@@ -146,7 +146,7 @@ pub enum RegistrationMode {
/// establisher + the accept dispatcher over the injected bind +
/// accept loop + forwarded-open caller. The forwarded-op caller
/// rides the producer-side call plane (`producer_client`'s call
/// connection — captured in the topology) and adopts on the
/// connection, captured in the topology) and adopts on the
/// producer-side manager.
BindFirst(
alktunnels::producer::BindFn,
@@ -168,7 +168,7 @@ pub async fn wire_with(
tokio::sync::mpsc::channel::<ChannelManager>(1);
// The consumer's serving registry: empty by default; a test that
// installs opener-side ops (the forwarded-op serving stub — the
// installs opener-side ops (the forwarded-op serving stub, the
// ADR-008 §3 ingress shape) pre-registers them here so the
// dispatch loop serves them from the first inbound call.
let consumer_serving_registry = Arc::new(OperationRegistry::new());
@@ -202,8 +202,8 @@ pub async fn wire_with(
let _ = call_tx.send(Arc::clone(&call_connection)).await;
let _ = manager_tx.send(manager).await;
// The consumer serves the ops the test pre-registered on
// its serving registry (empty in the default topology —
// the dispatcher keeps the call loop alive either way; its
// its serving registry (empty in the default topology;
// the dispatcher keeps the call loop alive either way, and its
// read loop resolves the producer's responses to the
// consumer's pending entries).
let dp = Dispatcher::new(
@@ -252,7 +252,7 @@ pub async fn wire_with(
.expect("producer channel client init");
// The generic channel ops ride the producer's serving registry
// (channel/close tears a channel down out-of-band — the review
// (channel/close tears a channel down out-of-band: the review
// 007 recipe).
let ops = ChannelOperations::with_default_policy(producer_client.manager().clone());
ops.register_on(&producer_op_registry)
@@ -304,12 +304,12 @@ pub async fn wire_with(
// closes over the producer-side call plane + manager (the
// design pin: the per-channel `Connection` the handler
// receives is stream-less and carries no call surface, so
// the registration takes the call-plane handle — here the
// the registration takes the call-plane handle, here the
// serving session's `CallConnection`, which the client
// holds; in production the assembly layer has the same
// handle because it constructed the serving session). The
// harness reaches it via `producer_client.call_open_op` +
// `adopt_channel` — the reverse POC's
// `adopt_channel`, the reverse POC's
// `open_reverse_channel` shape, inline (the reply's extra
// fields are not needed for the forwarded op; the channel
// ID extraction + adopt is the whole dance).
@@ -369,7 +369,7 @@ pub async fn wire_with(
.expect("register tunnel bindfirst openable");
}
RegistrationMode::DialNoWitness(dial) => {
// The public no-witness wrapper (tunnel_establisher) —
// The public no-witness wrapper (tunnel_establisher):
// registration + establisher body identical to the
// witnessed variant minus the CF-006 seam.
let core = alkcall::channels::operations::ChannelCore::new(
@@ -396,7 +396,7 @@ pub async fn wire_with(
// above on the SAME registry are unaffected (per-op
// timeout, ADR-049 §2).
// A fresh ChannelCore over the SAME manager + policy (both
// cores are cheap facades over the same pair — the second
// cores are cheap facades over the same pair; the second
// registration rides the same ledger/cap state; this arm
// is local to the probe so the other arms' `core` binding
// stays untouched).
@@ -442,7 +442,7 @@ pub async fn wire_with(
/// The default topology: the consumer's transport identity is the
/// primary caller-identity path (no token). The provider is
/// noop-shaped — only the identity chain authorizes.
/// noop-shaped; only the identity chain authorizes.
pub async fn wire(registry: ResourceRegistry, dial: alktunnels::producer::DialFn) -> Topology {
wire_with(
registry,
@@ -456,7 +456,7 @@ pub async fn wire(registry: ResourceRegistry, dial: alktunnels::producer::DialFn
/// The direct-op topology (ADR-007): the direct op registered with
/// the CF-006 witness; the consumer's transport identity is
/// [`both_scopes_identity`] (both grants — the opens authorize).
/// [`both_scopes_identity`] (both grants; the opens authorize).
pub async fn wire_direct(dial: alktunnels::producer::DialFn) -> Topology {
wire_with(
ResourceRegistry::new(),
@@ -482,7 +482,7 @@ pub async fn wire_direct_with_identity(identity: Identity) -> Topology {
}
/// A topology with the consumer's transport identity REPLACED by the
/// serving-side override (`ServingConfig.identity` — CF-005 (a) probe
/// serving-side override (`ServingConfig.identity`, CF-005 (a) probe
/// shape) or stripped entirely (the fail-closed probe). The transport
/// identity still rides the dialing connection; the override is what
/// the serving dispatch resolves (the witness proves which one won).
@@ -522,11 +522,11 @@ pub async fn wire_listen(
/// establisher binds via the injected [`BindFn`]; the accept
/// dispatcher runs the injected accept loop and opens forwarded
/// channels back toward the opener (whose serving stub the test
/// installs on the consumer side — see
/// installs on the consumer side; see
/// [`serve_forwarded_on_consumer`]). Same identity posture as
/// [`wire`], plus the forwarded scope (the opener-side ingress grant
/// — the consumer identity must hold `tunnel:forwarded` to receive
/// producer-initiated forwarded opens).
/// [`wire`], plus the forwarded scope (the opener-side ingress
/// grant: the consumer identity must hold `tunnel:forwarded` to
/// receive producer-initiated forwarded opens).
pub async fn wire_bindfirst(
registry: ResourceRegistry,
bind: alktunnels::producer::BindFn,
@@ -544,7 +544,7 @@ pub async fn wire_bindfirst(
/// The forwarded-op opener identity: `tunnel:forwarded` AND
/// `tunnel:open` (the opener receives producer-initiated forwarded
/// opens — the ingress grant — and opens the listen tunnel itself —
/// opens, the ingress grant, and opens the listen tunnel itself,
/// the egress grant; ADR-008 §3 / ADR-006 Amendment 1).
pub fn forwarded_opener_identity() -> Identity {
Identity {
@@ -558,7 +558,7 @@ pub fn forwarded_opener_identity() -> Identity {
}
/// [`forwarded_opener_identity`] minus the forwarded grant (the
/// per-accept isolation probe's identity — the forwarded open fails
/// per-accept isolation probe's identity; the forwarded open fails
/// FORBIDDEN).
pub fn forwarded_scopeless_identity() -> Identity {
Identity {
@@ -569,18 +569,18 @@ pub fn forwarded_scopeless_identity() -> Identity {
}
/// Serve the forwarded op (`channels/tunnel/forwarded`, ADR-008 §3) on
/// the CONSUMER side (the listen opener's serving stub — the test
/// the CONSUMER side (the listen opener's serving stub, the test
/// stand-in for the opener-side registration that lands with
/// `tunnels/forwarded-receipt`): a `register_openable` (no
/// establisher — the data plane starts immediately) whose handler
/// establisher; the data plane starts immediately) whose handler
/// correlates the open by the `listen` token against the test's
/// correlation map, accepts the `BiStream`, and pumps against the
/// "local BIND client" halves the test registered for that token.
///
/// `correlations` maps `listen` token → the pump side to pump the
/// forwarded channel against (the assembly-side bookkeeping posture —
/// forwarded channel against (the assembly-side bookkeeping posture:
/// the protocol provides the op + params, the test provides the map).
/// The registration is post-hoc on the consumer's captured registry —
/// The registration is post-hoc on the consumer's captured registry,
/// but the CONSUMER serves no ops in the default harness (its
/// dispatch registry is empty and the dispatcher runs on it), so this
/// helper must be called BEFORE the topology is wired; hence it is a
@@ -611,7 +611,7 @@ pub type CorrelationMap =
/// The forwarded-op handler (the opener-side serving stub's handler):
/// correlate by `listen` → accept the `BiStream` → pump against the
/// correlated halves (`pump_bidi`, convention 8). A correlation miss
/// is a loud `handler_error`-class open failure (the typed posture —
/// is a loud `handler_error`-class open failure (the typed posture:
/// the accept dies at birth; the loop keeps serving).
pub fn make_forwarded_stub_handler(
correlations: CorrelationMap,
@@ -650,7 +650,7 @@ pub fn make_forwarded_stub_handler(
}
/// The forward topology (the `-L` direction): the consumer dials the
/// transport (connect side — `ChannelClient::from_connection`, a pure
/// transport (connect side, `ChannelClient::from_connection`, a pure
/// consumer serving nothing) and the producer accepts (the adapter +
/// an install hook that registers the channel ops + the tunnel
/// openable on the producer's serving registry). This is the topology
@@ -667,7 +667,7 @@ pub struct ForwardTopology {
/// manager adopted the producer-allocated channel IDs; the
/// session reaps via this manager.
pub consumer: Arc<ChannelClient>,
/// The producer's manager (accept side, serving) — for asserting
/// The producer's manager (accept side, serving), for asserting
/// wrapper-managed reaping.
pub producer_manager: ChannelManager,
/// What the establisher's per-call auth carried (the CF-006 probe).
@@ -740,7 +740,7 @@ pub async fn wire_forward(
let consumer_conn = CoreConnection::from_bidi(consumer_end, b"alk/channels".to_vec(), None);
let producer_conn = CoreConnection::from_bidi(producer_end, b"alk/channels".to_vec(), None);
// The dialer's transport identity (the mTLS/QUIC posture) — set
// The dialer's transport identity (the mTLS/QUIC posture), set
// before from_connection. The accept side resolves it out-of-band
// (modeled in the hook above).
consumer_conn
@@ -777,8 +777,9 @@ pub async fn wire_forward(
}
/// The DIRECT-op forward topology (ADR-007): the producer (accept
/// side, serving) registers the direct op — [`register_tunnel_direct_openable`]
/// over the injected dial, CF-006 witness attached — and the consumer
/// side, serving) registers the direct op
/// ([`register_tunnel_direct_openable`] over the injected dial, CF-006
/// witness attached) and the consumer
/// (connect side, pure client) holds the identity the session tests
/// authorize with. This is the topology `TunnelSession::open_direct`
/// is normative for.
@@ -812,7 +813,7 @@ pub async fn wire_direct_forward_with_identity(
let (writer, reader) = split_single_stream(channel0_bidi);
// The accept-side transport-identity posture: the transport
// authenticated the dialer; attach the RESOLVED identity
// (scopes included — resolved out-of-band) to channel 0.
// (scopes included, resolved out-of-band) to channel 0.
let _ = channel0_conn.set_identity(caller);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
@@ -877,14 +878,14 @@ pub async fn wire_direct_forward_with_identity(
}
}
/// The listen-flow topology (ADR-008 §4 — the `TunnelListener` shape):
/// the OPENER dials the transport (connect side —
/// The listen-flow topology (ADR-008 §4, the `TunnelListener` shape):
/// the OPENER dials the transport (connect side,
/// `ChannelClient::from_connection_with_serving`, so it can both open
/// the listen tunnel and serve the forwarded op), and the producer
/// accepts (the adapter + an install hook registering the bind-first
/// openable over the injected bind + accept loop). The producer-side
/// forwarded-open caller rides the accept-side call plane (the
/// serving session's `CallConnection` — the same surface the serving
/// serving session's `CallConnection`, the same surface the serving
/// dispatch loop runs on; both-sides sanity, ADR-022 §2) and adopts on
/// the producer-side manager. The forwarded op registers post-hoc (W2)
/// on the opener's captured `opener_registry`.
@@ -897,10 +898,10 @@ pub struct ListenerTopology {
/// The opener's serving registry (the forwarded op registers here
/// post-hoc).
pub opener_registry: Arc<OperationRegistry>,
/// The producer's manager (accept side, serving) — for asserting
/// The producer's manager (accept side, serving), for asserting
/// wrapper-managed reaping + for the test's adopt dance.
pub producer_manager: ChannelManager,
/// The producer-side call plane (`Arc<CallConnection>`) — the
/// The producer-side call plane (`Arc<CallConnection>`), the
/// forwarded-op caller's surface (and the test's direct-call
/// surface for the adopt-pin dance).
pub producer_call: Arc<CallConnection>,
@@ -967,9 +968,9 @@ pub async fn wire_listener_bindfirst(
.expect("register channel ops on producer");
// The forwarded-open caller (the call plane): call + adopt
// (the reverse POC's `open_reverse_channel` shape — the
// serving session's `CallConnection` is the per-accept call
// plane; the producer-side manager adopts the
// (the reverse POC's `open_reverse_channel` shape: the
// serving session's `CallConnection` is the per-accept
// call plane, and the producer-side manager adopts the
// opener-allocated ID).
let call_for_opener = Arc::clone(&call_connection);
let manager_for_opener = manager.clone();
@@ -1040,7 +1041,7 @@ pub async fn wire_listener_bindfirst(
let (opener_end, producer_end) = tokio::io::duplex(64 * 1024);
let opener_conn = CoreConnection::from_bidi(opener_end, b"alk/channels".to_vec(), None);
let producer_conn = CoreConnection::from_bidi(producer_end, b"alk/channels".to_vec(), None);
// The dialer's transport identity (the mTLS/QUIC posture) — set
// The dialer's transport identity (the mTLS/QUIC posture), set
// before from_connection_with_serving. The accept side resolves it
// out-of-band (modeled in the hook above).
opener_conn
@@ -1090,7 +1091,7 @@ pub async fn wire_listener_bindfirst(
}
/// An echo dial closure (the ADR-004 injection point under test): an
/// in-process pipe per dial — the "target" echoes bytes until EOF.
/// in-process pipe per dial; the "target" echoes bytes until EOF.
/// The consumer side gets split `DuplexStream` halves; a spawned task
/// echoes the other end. No `local` feature, no sockets.
pub fn echo_dial() -> alktunnels::producer::DialFn {
@@ -1142,7 +1143,7 @@ pub fn all_substrate_dial() -> alktunnels::producer::DialFn {
}
/// A framed UDP echo dial whose target end CLOSES after the first
/// echoed datagram — the EOF-path harness. `payload_completes`
/// echoed datagram, the EOF-path harness. `payload_completes`
/// controls the truncation shape: `true` forwards the whole frame
/// (echo + payload intact) then drops → the clean-EOF path;
/// `false` forwards only the 2-byte length prefix then drops → the
@@ -1164,7 +1165,7 @@ pub fn closing_udp_echo_dial(payload_completes: bool) -> alktunnels::producer::D
// echo-closes-after-first shape): forward it
// whole (clean-EOF shape) or forward only its
// length prefix (truncated shape), then drop
// the far end — the channel read half EOFs.
// the far end; the channel read half EOFs.
let n = match t_read.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
@@ -1182,7 +1183,7 @@ pub fn closing_udp_echo_dial(payload_completes: bool) -> alktunnels::producer::D
let _ = t_write.flush().await;
} else {
// The truncated shape: forward ONLY
// the 2-byte length prefix, then drop —
// the 2-byte length prefix and drop;
// the declared payload never arrives.
if t_write.write_all(&framed[..2]).await.is_err() {
return;
@@ -1219,7 +1220,7 @@ pub fn failing_dial(message: &'static str) -> alktunnels::producer::DialFn {
})
}
/// A framed UDP echo dial (the ADR-003 codec at the target boundary —
/// A framed UDP echo dial (the ADR-003 codec at the target boundary,
/// the in-process stand-in for the real `local` UDP adapter): the
/// dial's halves carry the `[len: u16 BE]` wire framing; a spawned
/// echo task decodes frames from the channel side and re-frames the
+26 -25
View File
@@ -1,6 +1,6 @@
//! Tests for the `local` feature's BIND-FIRST half (shape 3, ADR-008):
//! the real-socket `BindFn` sources (`bind_tcp_first` /
//! `bind_unix_first` — the `bound` reply field's data, port 0
//! `bind_unix_first`, the `bound` reply field's data, port 0
//! resolved), the accept-pair loops (peer + halves per accept), and
//! the assembly convenience closures (`local_bind_first` +
//! `local_bindfirst_accept_loop`), through the real bind-first
@@ -10,7 +10,7 @@
//! OS-chosen one (a fake `bound` would strand the test's dials), two
//! clients dial → two accepts → two forwarded channels pumping
//! independently (peer fidelity: each forwarded open carries the REAL
//! accepted peer), unix bind-first parity (OQ-TN-14 — the bind path is
//! accepted peer), unix bind-first parity (OQ-TN-14; the bind path is
//! the same shape as the dial path), and the udp bind-first refusal
//! (a UDP socket has no accept).
@@ -37,14 +37,14 @@ fn init_tracing() {
/// The forwarded-op recording stub's peer log: the `peer` param of
/// every forwarded open the producer's dispatcher made (the
/// reply#2-fidelity probe surface — ADR-008 §3).
/// reply#2-fidelity probe surface (ADR-008 §3).
type PeerLog = Arc<tokio::sync::Mutex<Vec<SubstrateAddr>>>;
/// The forwarded-op opener-side serving stub (the queue-fed shape):
/// each forwarded open pops the NEXT app-side pair (the "local BIND
/// client" halves the test registered) and pumps the forwarded
/// channel against it, recording the open's `peer` param (the
/// reply#2-fidelity probe surface — ADR-008 §3). One listener session
/// reply#2-fidelity probe surface (ADR-008 §3). One listener session
/// = one token = MANY forwarded opens (per accept), so the
/// correlation is a queue, not a one-shot map.
fn recording_forwarded_handler(app_sides: AppSideQueue, peers: PeerLog) -> OpenHandler {
@@ -80,13 +80,13 @@ fn recording_forwarded_handler(app_sides: AppSideQueue, peers: PeerLog) -> OpenH
}
/// The app-side pairs the stub pops per forwarded open (the "local
/// BIND client" halves — the stub's pump target; the far end is the
/// BIND client" halves, the stub's pump target; the far end is the
/// test's echo task).
type AppSideQueue =
Arc<tokio::sync::Mutex<std::collections::VecDeque<alktunnels::producer::TargetHandle>>>;
/// Install the recording stub on the opener (consumer) side (post-hoc
/// on the captured serving registry — W2).
/// on the captured serving registry (W2).
async fn install_recording_stub(topo: &harness::Topology) -> (AppSideQueue, PeerLog) {
let app_sides: AppSideQueue = Arc::new(tokio::sync::Mutex::new(Default::default()));
let peers: PeerLog = Arc::new(tokio::sync::Mutex::new(Vec::new()));
@@ -105,7 +105,7 @@ async fn install_recording_stub(topo: &harness::Topology) -> (AppSideQueue, Peer
}
/// Call the producer's open op on channel 0 with the FULL reply (the
/// bind-first reply projection probe — `bound` + `listen`).
/// bind-first reply projection probe (`bound` + `listen`).
async fn open_listen_with_reply(
topo: &harness::Topology,
resource: &str,
@@ -130,7 +130,7 @@ async fn open_listen_with_reply(
}
/// One app-side session: a raw duplex pair whose FAR end echoes (the
/// app-side session shape) — the stub pumps the forwarded channel
/// app-side session shape); the stub pumps the forwarded channel
/// against the near end (pushed into the stub's queue); the test
/// drives the REAL accepted socket at the tunnel's other side, whose
/// bytes echo through the app-side session.
@@ -161,7 +161,7 @@ fn echo_app_side() -> alktunnels::producer::TargetHandle {
/// THE bind-first flow over REAL TCP sockets (the `local` shape-3
/// gate): `bind_tcp_first` binds port 0 → the reply's `bound` is the
/// REAL OS-chosen address (the test dials it — a fake bound would
/// REAL OS-chosen address (the test dials it; a fake bound would
/// strand the dial) → two clients dial → two accepts → two forwarded
/// channels → both pump independently, and each forwarded open
/// carries the REAL accepted peer (peer fidelity, reply#2 class).
@@ -197,7 +197,7 @@ async fn bindfirst_tcp_real_sockets_bound_fidelity_and_two_pumps() {
let (app_sides, peers) = install_recording_stub(&topo).await;
// The opener adopts the LISTEN channel (the idle side). The
// halves are HELD — dropping them EOFs the idle stream and tears
// halves are HELD; dropping them EOFs the idle stream and tears
// the listener down (the ADR-008 sentinel is live even here).
let (_idle_send, _idle_recv) = topo
.consumer_manager
@@ -251,7 +251,7 @@ async fn bindfirst_tcp_real_sockets_bound_fidelity_and_two_pumps() {
assert_eq!(ports.len(), 2, "two accepts carried distinct real peers");
}
/// Unix bind-first parity (OQ-TN-14 — the bind path ships with the
/// Unix bind-first parity (OQ-TN-14; the bind path ships with the
/// dial path): `bind_unix_first` binds a temp-path listener → the
/// reply's `bound` is the REAL bound socket path → a unix client
/// (bound to its own path, so the peer is representable) connects →
@@ -295,7 +295,7 @@ async fn bindfirst_unix_real_sockets_bound_and_pumped() {
let (app_sides, peers) = install_recording_stub(&topo).await;
// The opener adopts the LISTEN channel (the idle side) — held for
// The opener adopts the LISTEN channel (the idle side), held for
// the test's lifetime (dropping the halves EOFs the idle stream
// and tears the listener down, the ADR-008 sentinel).
let (_idle_send, _idle_recv) = topo
@@ -309,7 +309,7 @@ async fn bindfirst_unix_real_sockets_bound_and_pumped() {
// The unix client binds its own socket at a named path FIRST (an
// unnamed peer cannot be represented in the forwarded `peer`
// param — ADR-008 §3; the named-peer shape is the parity case).
// param (ADR-008 §3); the named-peer shape is the parity case).
// socket2: std has no `UnixStream::bind`; a socket2 socket binds
// the sockaddr at the path, then connects (the peer address is
// the client's own bound path).
@@ -350,7 +350,7 @@ async fn bindfirst_unix_real_sockets_bound_and_pumped() {
}
/// The udp bind-first refusal: a UDP socket has no accept, so the
/// bind closure fails loud (`handler_error`-class) — the shape is not
/// bind closure fails loud (`handler_error`-class); the shape is not
/// defined there, never a silent fake listener.
#[tokio::test]
async fn udp_bind_first_fails_loud() {
@@ -383,7 +383,7 @@ async fn udp_bind_first_fails_loud() {
/// The accept-pair loop's mismatch probe: a listener NOT produced by
/// this module's bind closures fails loud inside the closure (the
/// downcast is the assembly side of the type-erased injection — a
/// downcast is the assembly side of the type-erased injection; a
/// foreign listener type is `handler_error`, never a phantom accept).
#[tokio::test]
async fn accept_pair_loop_downcast_miss_fails_loud() {
@@ -401,27 +401,28 @@ async fn accept_pair_loop_downcast_miss_fails_loud() {
);
}
/// The unnamed-peer/unnamed-bind fail-loud arms (G-5 — ADR-008 §3's
/// The unnamed-peer/unnamed-bind fail-loud arms (G-5, ADR-008 §3's
/// two pinned sentences: a producer that cannot determine the peer
/// does not serve a bind-first listener over that substrate):
///
/// - **Unnamed bind** — an empty-path `bind_unix` is the kernel's
/// - **Unnamed bind**: an empty-path `bind_unix` is the kernel's
/// autobind (the kernel assigns an abstract name, addlen ==
/// offsetof(sun_path)); the bound addr has no filesystem path, so
/// `local_addr` (and thus `bind_unix_first`) fails loud with the
/// "no filesystem path" handler error instead of serving an
/// unrepresentable `bound`.
/// - **Unnamed peer** — an addressless client (socket2 autobind
/// - **Unnamed peer**: an addressless client (socket2 autobind
/// here; a plain unbound client behaves identically through tokio
/// accept — mio maps any abstract-namespace peer to unnamed) is
/// accept; mio maps any abstract-namespace peer to unnamed) is
/// accepted by a NAMED listener; `accept_pair` fails loud because
/// the forwarded op's `peer` param is required on the wire.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn unix_unnamed_bind_and_unnamed_peer_fail_loud() {
// Arm 1: the autobind listener — `bind_unix("")`. tokio passes the
// empty path through to `bind(2)` with addlen == offsetof(sun_path):
// the kernel autobinds an abstract name, no filesystem path exists.
// Arm 1 probes the autobind listener: `bind_unix("")`. tokio
// passes the empty path through to `bind(2)` with addlen ==
// offsetof(sun_path): the kernel autobinds an abstract name, no
// filesystem path exists.
let halves = alktunnels::local::bind_unix("")
.expect("autobind bind succeeds (the kernel assigns the abstract name)");
let err = halves
@@ -436,9 +437,9 @@ async fn unix_unnamed_bind_and_unnamed_peer_fail_loud() {
"unnamed bind maps to the handler error: {err:?}"
);
// Arm 2: the unnamed peer — a socket2 autobind client connects to a
// NAMED listener; `accept_pair` must refuse (the forwarded `peer`
// param requires an address).
// Arm 2 probes the unnamed peer: a socket2 autobind client
// connects to a NAMED listener; `accept_pair` must refuse (the
// forwarded `peer` param requires an address).
let dir = std::env::temp_dir().join(format!("alktunnels-g5-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let listen_path = dir.join("named.sock");
+50 -50
View File
@@ -5,8 +5,8 @@
//!
//! The forward topology (`wire_forward`) carries the full path: the
//! consumer opens (`TunnelSession::open`), the producer's establisher
//! dials via `local_dial`, the pump handler pumps — real sockets at
//! both ends.
//! dials via `local_dial`, and the pump handler pumps. Real sockets
//! sit at both ends.
#![cfg(feature = "local")]
@@ -115,7 +115,7 @@ async fn udp_datagram_round_trip_through_the_framed_adapter() {
async fn direct_udp_through_local_dial_round_trips_framed() {
// The DIRECT op's DialFn reuse pin (the local-bind-halves task):
// `local_dial` is the ONE dial closure the base op and the direct
// op share — the UDP wrap (the FRAMED `UdpHalf` adapter, ADR-003)
// op share. The UDP wrap (the FRAMED `UdpHalf` adapter, ADR-003)
// happens INSIDE the closure (review 001 U-1), so direct-udp gets
// datagram-boundary framing free. Driven against a REAL UDP echo
// socket: the session sends the codec frame, the direct establisher
@@ -219,14 +219,14 @@ async fn truncation_fails_loud_oq_tn_13() {
// The fail-loud posture (OQ-TN-13) at the FRAMED adapter: the
// socket-facing recv uses the full 65535-byte scratch (try_recv
// truncates silently at the buffer size), so a well-formed
// >caller-buffer datagram is received whole and STAGED — never
// >caller-buffer datagram is received whole and STAGED, never
// silently truncated into a shorter frame. The staging surface is
// pinned by `oversized_frame_stages_across_reads` (below); this
// test pins the fail-loud path directly at the unit level: a
// scratch smaller than the datagram WOULD have corrupted the
// framing (the U-3 re-validation's warning), so the scratch must
// be full-size. A datagram at the IP/UDP max (65507) is within
// the u16 frame bound (65535) — it must arrive intact.
// the u16 frame bound (65535); it must arrive intact.
let sock_a = tokio::net::UdpSocket::bind(("127.0.0.1", 0))
.await
.expect("bind a");
@@ -244,14 +244,14 @@ async fn truncation_fails_loud_oq_tn_13() {
// The producer-side adapter (what the establisher dials): sock_a
// wrapped as halves. A 65507-byte datagram (the UDP theoretical
// max — a well-formed wire's largest possible frame) from the
// max, a well-formed wire's largest possible frame) from the
// wire side.
let (read, _write) = alktunnels::local::UdpHalf::split(sock_a);
let mut read = read;
let payload: Vec<u8> = (0..65507u32).map(|i| (i % 251) as u8).collect();
sock_b.send(&payload).await.expect("send max-size");
// The pump reads with the codec's chunk size (16 KiB) — far
// The pump reads with the codec's chunk size (16 KiB), far
// smaller than the datagram: the adapter must STAGE, never
// truncate. Drain through small reads and reassemble the frame.
use tokio::io::AsyncReadExt;
@@ -269,12 +269,12 @@ async fn truncation_fails_loud_oq_tn_13() {
assert_eq!(
&got[2..],
&payload[..],
"full payload intact — no truncation"
"full payload intact; no truncation"
);
}
/// The >caller-buffer (but within-frame-bound) datagram: the framed
/// adapter STAGES the frame and hands it out across reads — the pump's
/// adapter STAGES the frame and hands it out across reads; the pump's
/// far-side de-framer reassembles the true length (no silent
/// truncation, no shorter-datagram misread). The reassembly is pinned
/// by the end-to-end gates below; here the staging surface is pinned
@@ -301,7 +301,7 @@ async fn oversized_frame_stages_across_reads() {
let payload: Vec<u8> = (0..20000u32).map(|i| (i % 251) as u8).collect();
sock_b.send(&payload).await.expect("send 20k");
// Read through a small caller buffer (16 KiB — smaller than the
// Read through a small caller buffer (16 KiB, smaller than the
// ~20002-byte frame): the first read fills it completely, the
// second hands out the staged remainder.
use tokio::io::AsyncReadExt;
@@ -333,7 +333,7 @@ fn len_lo(n: usize) -> u8 {
/// A RECORDING UDP "target" (review 001's verification-gate probe
/// shape, made permanent): it records every datagram it receives
/// (byte-for-byte, to the shared log) and replies with canned,
/// configurable datagrams — including EMPTY and RAW-UNFRAMED ones the
/// configurable datagrams, including EMPTY and RAW-UNFRAMED ones the
/// echo server can't produce.
struct RecordingTarget {
addr: std::net::SocketAddr,
@@ -376,7 +376,7 @@ fn params(resource: &str, substrate: Substrate) -> TunnelParams {
}
}
/// GATE 1a (review 001): the target receives PAYLOAD BYTES ONLY — no
/// GATE 1a (review 001): the target receives PAYLOAD BYTES ONLY, no
/// `[len: u16 BE]` codec prefix (the U-1 defect's primary face).
#[tokio::test]
async fn gate_target_receives_payload_bytes_only() {
@@ -408,7 +408,7 @@ async fn gate_target_receives_payload_bytes_only() {
session.close().await;
}
/// GATE 1b: an EMPTY datagram round-trips over the REAL socket path —
/// GATE 1b: an EMPTY datagram round-trips over the REAL socket path:
/// `recv_datagram` yields `Some(b"")` and the channel stays alive
/// (the F-2 fix end-to-end; the raw adapter collapsed it to EOF).
#[tokio::test]
@@ -434,7 +434,7 @@ async fn gate_empty_datagram_round_trips_over_the_real_socket_path() {
assert!(dg.is_empty(), "the empty datagram round-trips");
// The channel is still alive: a second exchange works (the
// canned reply cycles — the target sends b"still-alive" back).
// canned reply cycles; the target sends b"still-alive" back).
session.send_datagram(b"second").await.expect("send 2");
let dg2 = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
.await
@@ -446,7 +446,7 @@ async fn gate_empty_datagram_round_trips_over_the_real_socket_path() {
}
/// GATE 2: a >8 KiB datagram round-trips as ONE datagram at the
/// target (the 8 KiB copy-buffer split is gone — the frame reassembles
/// target (the 8 KiB copy-buffer split is gone; the frame reassembles
/// at the adapter before crossing the pump).
#[tokio::test]
async fn gate_large_datagram_arrives_as_one_datagram() {
@@ -460,7 +460,7 @@ async fn gate_large_datagram_arrives_as_one_datagram() {
let mut session = TunnelSession::open(&topo.consumer, params("big", Substrate::Udp))
.await
.expect("udp open");
// 9000 bytes — the review's probe size that split as 8192 + 810.
// 9000 bytes, the review's probe size that split as 8192 + 810.
let payload: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
session.send_datagram(&payload).await.expect("send 9000");
for _ in 0..100 {
@@ -482,10 +482,10 @@ async fn gate_large_datagram_arrives_as_one_datagram() {
/// GATE 3: an unframed target-initiated datagram resolves at the
/// session (the session codec decodes the framed bytes the adapter
/// produced — real-target traffic is not parked mid-frame).
/// produced; real-target traffic is not parked mid-frame).
#[tokio::test]
async fn gate_unframed_target_datagram_resolves_at_the_session() {
// A raw "HELLO" — a real server's greeting the raw adapter parked
// A raw "HELLO": a real server's greeting the raw adapter parked
// mid-frame (the probe's 2s timeout).
let target = recording_udp_target(vec![b"HELLO".to_vec()]).await;
let registry = ResourceRegistry::new();
@@ -500,14 +500,14 @@ async fn gate_unframed_target_datagram_resolves_at_the_session() {
session.send_datagram(b"ping").await.expect("send");
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
.await
.expect("recv timed out — unframed target traffic was parked/dropped")
.expect("recv timed out; unframed target traffic was parked/dropped")
.expect("io ok")
.expect("datagram");
assert_eq!(&dg[..], b"HELLO");
session.close().await;
}
/// U-2 (review 001): `connect_udp`'s bind is family-correct — a v6
/// U-2 (review 001): `connect_udp`'s bind is family-correct. A v6
/// target dials from a v6-bound socket (the hardcoded `0.0.0.0` bind
/// broke every v6-only target at connect). A real ::1 target
/// round-trips through the full path.
@@ -583,7 +583,7 @@ async fn listen_producer_over_a_real_tcp_listener() {
});
// A local client connects (the accepted connection's source). The
// driving end comes from `try_clone` — the pump owns the accepted
// driving end comes from `try_clone`; the pump owns the accepted
// handle; the driver pushes/reads the echo through the same real
// socket.
// std-level try_clone BEFORE converting to tokio streams: one fd
@@ -629,7 +629,7 @@ async fn listen_producer_over_a_real_tcp_listener() {
.await
.expect("adopt");
// The producer's pump (wrapper-managed) copies channel <-> the
// accepted handle; the accepted handle IS the client socket — so
// accepted handle; the accepted handle IS the client socket, so
// the session's borrowed halves drive it directly: the driver end
// writes into the client socket, the pump carries it over the
// channel, and the session halves read it (and back).
@@ -653,13 +653,13 @@ async fn listen_producer_over_a_real_tcp_listener() {
// UdpHalf write-side machinery (review 001's U-1 invariants, the
// never-blocks paths the end-to-end gates don't reach): WouldBlock
// stashing, the accepted-byte high-water backpressure, the flush
// drain, and the shutdown best-effort drain — driven with poll_fn
// drain, and the shutdown best-effort drain, driven with poll_fn
// over a shrunken-SO_SNDBUF socket (deterministic, no timing).
// =====================================================================
/// An UNCONNECTED UdpHalf write half: `try_send` fails
/// deterministically with `EDESTADDRREQ` ("destination address
/// required") — the deterministic probe for the write path's ERROR
/// required"), the deterministic probe for the write path's ERROR
/// arms on this host, where no UDP sender-blocking shape was
/// reachable (loopback/veth drop silently; see the host-notes on the
/// drain test).
@@ -671,7 +671,7 @@ fn unconnected_write_half() -> Box<dyn tokio::io::AsyncWrite + Send + Sync + Unp
write
}
/// Drive one `poll_write` with the given buffer (one poll — the
/// Drive one `poll_write` with the given buffer (one poll, the
/// pump's copy shape).
async fn poll_write_once<W: tokio::io::AsyncWrite + Unpin>(
write: &mut W,
@@ -700,11 +700,11 @@ async fn udp_half_write_queue_drains_on_flush_and_shutdown() {
// accepts frames (the de-framer's queue + accepted accounting),
// flush drains them onto the socket (Ready(Ok)), and shutdown's
// best-effort drain reports Ready(Ok) (UDP has no shutdown).
// The peer reads (a real probe) — the datagrams land.
// The peer reads (a real probe), and the datagrams land.
//
// Host-notes (probed 2026-09-09): (a) a fresh-socket first
// `try_send` may report WouldBlock spuriously on this kernel
// (one readiness+retry cycle clears it, ~20% of runs) — the
// (one readiness+retry cycle clears it, ~20% of runs); the
// flush loop tolerates it, which also exercises the `sending`
// stash + re-offer path whenever it fires. (b) The test runs
// multi-thread: on a current-thread runtime the kernel's
@@ -746,7 +746,7 @@ async fn udp_half_write_queue_drains_on_flush_and_shutdown() {
);
}
// Flush drains the queue onto the socket — bounded cycles: the
// Flush drains the queue onto the socket (bounded cycles): the
// quirk's stash+Pending resolves on the next flush (poll_send_ready
// is Ok), a saturated socket stays queued (not this host's case).
let mut flushed = false;
@@ -792,12 +792,12 @@ async fn udp_half_write_queue_drains_on_flush_and_shutdown() {
#[tokio::test]
async fn udp_half_write_error_maps_loud_on_unconnected_socket() {
// The ERROR arms: an unconnected socket's `try_send` fails
// deterministically (`EDESTADDRREQ`) — poll_write's drain maps
// deterministically (`EDESTADDRREQ`), so poll_write's drain maps
// the socket error loud (the `pump_queue_to_socket` Err arm),
// and flush re-raises it (the `poll_flush` error surface). The
// high-water backpressure and the WouldBlock stash remain
// host-dependent (see connected_udp_pair's note) — covered by the
// leftover-coverage task.
// host-dependent (see connected_udp_pair's note); coverage lives
// with the leftover-coverage task.
let mut write = unconnected_write_half();
let frame = [0, 3, b'x', b'y', b'z'];
@@ -822,9 +822,9 @@ async fn udp_half_write_error_maps_loud_on_unconnected_socket() {
#[tokio::test]
async fn udp_half_poll_shutdown_drains_best_effort_and_is_idempotent() {
// UDP has no shutdown: poll_shutdown drains the queue best-effort
// and reports Ready(Ok) — the drop-is-close posture. On a
// and reports Ready(Ok), the drop-is-close posture. On a
// saturated socket the drain is best-effort too (the error is
// swallowed — shutdown never fails the pump).
// swallowed; shutdown never fails the pump).
let mut write = unconnected_write_half();
use tokio::io::AsyncWriteExt;
write
@@ -843,7 +843,7 @@ async fn udp_half_flush_drains_repeatedly_until_the_queue_is_empty() {
// The flush loop's pump-again shape (tokio's copy polls flush
// after every window drain): each flush drains that window's
// queued datagrams; five write+flush cycles → five datagrams at
// the peer (the fresh-socket quirk tolerated per cycle — see the
// the peer (the fresh-socket quirk tolerated per cycle; see the
// host-notes on the drain test; multi-thread runtime for the same
// delivery-timing reason).
let peer = tokio::net::UdpSocket::bind(("127.0.0.1", 0))
@@ -874,7 +874,7 @@ async fn udp_half_flush_drains_repeatedly_until_the_queue_is_empty() {
for i in 0..5u8 {
let frame = [0, 1, i];
poll_write_once(&mut write, &frame).await.expect("accept");
// Each cycle flushes (bounded retries — the fresh-socket
// Each cycle flushes (bounded retries: the fresh-socket
// quirk's stash clears on the second readiness cycle).
let mut flushed = false;
for _ in 0..8 {
@@ -906,7 +906,7 @@ async fn udp_half_flush_drains_repeatedly_until_the_queue_is_empty() {
}
/// The resolve arm (`connect_udp`'s no-addresses mapping): a target
/// whose name cannot be resolved (an RFC 6761 `.invalid` name —
/// whose name cannot be resolved (an RFC 6761 `.invalid` name,
/// guaranteed NXDOMAIN, resolved instantly and without network I/O)
/// maps to `dial_failed` (the resolve step's error posture, ADR-004's
/// `DialFn` contract). Rootless, deterministic.
@@ -928,14 +928,14 @@ async fn connect_udp_unresolvable_target_maps_to_dial_failed() {
// =====================================================================
// The sender-block machinery (the coverage-weak-spots task's option 1,
// CLOSED — the recipe was found): a veth fixture with the peer
// CLOSED; the recipe was found): a veth fixture with the peer
// address UNASSIGNED and the neighbor table flushed puts the
// connected UDP sender into a PERMANENT WouldBlock/ENOSPC state
// (the unresolved neighbor never resolves; the blocked write path's
// skb stays charged to the socket's wmem with a shrunken SO_SNDBUF).
// Assigning the peer address + binding the peer resolves ARP; the
// queued/stashed datagrams drain and DELIVER. Validated on this host
// (kernel 6.8.0-110-generic, tokio 1.53.1, socket2 0.6.5) — 3/3
// (kernel 6.8.0-110-generic, tokio 1.53.1, socket2 0.6.5): 3/3
// deterministic probe runs, 2026-09-09.
//
// Root-gated (`ip link add`): auto-skipped without root. Never
@@ -944,7 +944,7 @@ async fn connect_udp_unresolvable_target_maps_to_dial_failed() {
/// The veth fixture (created and torn down per run): `alktx/alktx-p`
/// with 10.240.0.1/24 (the sender side) and 10.240.0.9 (the peer
/// side, UNASSIGNED during the block phase — its absence is what
/// side, UNASSIGNED during the block phase; its absence is what
/// pins the sender). The peer address is assigned mid-test (the
/// resolve step). A dead-simple guard skips the test when `ip` is
/// unavailable or the command fails (non-root hosts).
@@ -992,7 +992,7 @@ impl VethFixture {
return None;
}
let _ = ip_cmd(&["addr", "add", "10.240.0.1/24", "dev", "alktx"]).await;
// A jumbo MTU (65536): a 60 KB datagram crosses as ONE frame —
// A jumbo MTU (65536): a 60 KB datagram crosses as ONE frame,
// no IP fragmentation, so no fragment-reassembly races at the
// peer (fragment-ID collisions under the drain burst merged
// datagrams on an MTU-1500 veth during the probe runs).
@@ -1039,14 +1039,14 @@ impl Drop for VethFixture {
/// A sender socket blocked on the unresolved neighbor: connect to the
/// UNASSIGNED peer address, flush the neighbor table, shrunken
/// SO_SNDBUF. `try_send` on a 60 KB datagram (truesize beyond the
/// buffer) then reports WouldBlock/ENOSPC persistently — the state
/// buffer) then reports WouldBlock/ENOSPC persistently; the state
/// the write-side machinery is for. Returns the tokio socket ready
/// for `UdpHalf::split`.
async fn blocked_sender_socket(peer_port: u16) -> Option<tokio::net::UdpSocket> {
// The BLOCK-STATE PROBE runs on a THROWAWAY socket: an oversized
// probe datagram's partially-built skb chain stays corked in
// sk_write_queue on EAGAIN (the kernel flushes it on the next
// successful send) — poisoning the socket with a stale partial
// successful send), poisoning the socket with a stale partial
// datagram. The test socket below is created fresh afterwards.
let probe = socket2::Socket::new(
socket2::Domain::IPV4,
@@ -1065,7 +1065,7 @@ async fn blocked_sender_socket(peer_port: u16) -> Option<tokio::net::UdpSocket>
// Confirm the block actually holds (the host invariant): with a
// 64 KiB SO_SNDBUF, the FIRST 60 KB datagram queues on the
// unresolved neighbor (Ok — its skb is charged to wmem); every
// unresolved neighbor (Ok; its skb is charged to wmem); every
// SUBSEQUENT send EAGAINs (the charge exceeds the buffer) until
// the neighbor resolves. That persistent EAGAIN/ENOBUFS pair IS
// the block state. A resolved neighbor would send Ok throughout.
@@ -1101,10 +1101,10 @@ async fn blocked_sender_socket(peer_port: u16) -> Option<tokio::net::UdpSocket>
Some(snd)
}
/// THE sender-block machinery, end to end (option 1 — one test, the
/// THE sender-block machinery, end to end (option 1: one test, the
/// coherent behavior): the WouldBlock stash (`poll_write`'s
/// opportunistic drain stashes the mid-send datagram and registers
/// the send waker — `pump_queue_to_socket`'s Pending arms), the
/// the send waker, `pump_queue_to_socket`'s Pending arms), the
/// accepted-byte high-water backpressure (`poll_write` Pends when the
/// queue stays over [`alktunnels::local::WRITE_QUEUE_HIGH_WATER`]),
/// the flush re-pump (readiness → drain), and the post-resolve
@@ -1185,7 +1185,7 @@ async fn udp_half_write_side_blocks_stashes_and_drains_on_resolve() {
}
assert!(pending_flushes > 0, "the blocked socket Pends the flush");
// Phase B: resolve — assign the peer address (the neighbor
// Phase B: resolve. Assign the peer address (the neighbor
// completes), bind the peer, wait for ARP; the queued datagrams
// drain and DELIVER.
assert!(veth.assign_peer(), "assign the peer address");
@@ -1212,10 +1212,10 @@ async fn udp_half_write_side_blocks_stashes_and_drains_on_resolve() {
assert!(flushed, "the queued datagrams drained after the resolve");
// The peer received the queued datagrams (delivery across the
// blocked→resolved window — the stash/queue never lost them; at
// least the stashed tail must arrive — the periodic
// INCOMPLETE→FAILED drops may consume some queued ones, which
// is kernel-side loss, not adapter loss).
// blocked→resolved window): the stash/queue never lost them, and
// at least the stashed tail must arrive. The periodic
// INCOMPLETE→FAILED drops may consume some queued ones; that
// loss is kernel-side, not adapter loss).
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
let mut got = 0usize;
let mut buf = vec![0u8; 65535];
+28 -28
View File
@@ -1,8 +1,8 @@
//! Integration tests for the bind-first establisher + accept
//! dispatcher (shape 3 — ADR-008 §1): the producer whose establisher
//! dispatcher (shape 3, ADR-008 §1): the producer whose establisher
//! BINDS (via the injected `BindFn`), mints the correlation token,
//! contributes `bound` + `listen` to the open reply, and whose pump
//! handler is the accept dispatcher — per accept it opens
//! handler is the accept dispatcher, which per accept opens
//! `channels/tunnel/forwarded` back toward the listen opener and pumps
//! the accepted halves against the adopted channel.
//!
@@ -48,7 +48,7 @@ fn listen_params(resource: &str) -> TunnelParams {
}
/// Call the producer's open op on channel 0 with the FULL reply
/// (the consumer's bind-first surface — the reply projection probe).
/// (the consumer's bind-first surface, the reply projection probe).
async fn open_listen_with_reply(
topo: &Topology,
params: &TunnelParams,
@@ -112,7 +112,7 @@ async fn push_accept(
}
/// The assembly's bind: produces a "listener" (the queue's identity)
/// and the OS-chosen bound address (the test's fake — a real
/// and the OS-chosen bound address (the test's fake; a real
/// `bind_tcp` variant lives in the `local` feature).
fn test_bind() -> BindFn {
Arc::new(|_substrate, _backing| {
@@ -128,7 +128,7 @@ fn test_bind() -> BindFn {
})
}
/// A failing bind (the `dial_failed`-class probe — the bind IS the
/// A failing bind (the `dial_failed`-class probe, since the bind IS the
/// dial here).
fn failing_bind(message: &'static str) -> BindFn {
Arc::new(move |_substrate, _backing| {
@@ -150,7 +150,7 @@ fn only_channel_0(topo: &Topology) -> bool {
}
/// The bind-first topology keeps the LISTEN channel open (the
/// dispatcher holds it) — the "no stray channels" probe excludes it
/// dispatcher holds it); the "no stray channels" probe excludes it
/// (and channel 0, the call plane) on both sides.
fn no_stray_channels(topo: &Topology, listen_id: u32) -> bool {
topo.consumer_manager
@@ -208,7 +208,7 @@ async fn bindfirst_full_flow_end_to_end() {
)
.await;
// The opener opens the listen tunnel (with-reply surface — the
// The opener opens the listen tunnel (with-reply surface, the
// bind-first reply projection).
let (channel_id, reply) = open_listen_with_reply(&topo, &listen_params("bind"))
.await
@@ -232,7 +232,7 @@ async fn bindfirst_full_flow_end_to_end() {
let correlations = install_forwarded_stub(&topo).await;
// The LOCAL BIND client (the app-server-side session on the
// opener): a raw duplex pair — the map's halves are the stub's
// opener): a raw duplex pair; the map's halves are the stub's
// pump target, the far end is the test's client end. The stub's
// `pump_bidi` bridges the forwarded channel ↔ these halves, so
// bytes the test writes on the far end traverse the whole path
@@ -247,7 +247,7 @@ async fn bindfirst_full_flow_end_to_end() {
},
);
// The opener adopts the LISTEN channel (the idle side — its EOF
// The opener adopts the LISTEN channel (the idle side; its EOF
// is the teardown signal).
topo.consumer_manager
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
@@ -259,7 +259,7 @@ async fn bindfirst_full_flow_end_to_end() {
// The dispatcher opens forwarded; the opener's stub handler
// correlates by token and pumps against the local halves. The
// forwarded channel ID arrives on the opener's manager — find it
// forwarded channel ID arrives on the opener's manager; find it
// (the only non-0, non-listen channel on the opener side after
// the adopt + forwarded open settle).
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
@@ -291,7 +291,7 @@ async fn bindfirst_full_flow_end_to_end() {
// Teardown follows the accepted side: the far echo closes when the
// accepted handle's pump sees the test client's EOF. (No channel
// assertions here — the listen channel legitimately stays open.)
// assertions here: the listen channel legitimately stays open.)
}
/// The listen-channel EOF teardown: drop the listener session (close
@@ -323,7 +323,7 @@ async fn listen_channel_eof_cancels_loop_and_tears_listener_down() {
.expect("token");
// The opener adopts the listen channel and then DROPS its stream
// (the listener session is dropped — the zero-byte EOF sentinel).
// (the listener session is dropped, hence the zero-byte EOF sentinel).
let (send, _recv) = topo
.consumer_manager
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
@@ -338,7 +338,7 @@ async fn listen_channel_eof_cancels_loop_and_tears_listener_down() {
"the listen channel reaped after the session dropped"
);
// A follow-on accept attempt: the loop is gone — the accept queue
// A follow-on accept attempt: the loop is gone, so the accept queue
// accepts nothing new (the listener was torn down with the plan).
// Observable via the queue: nothing pumps it anymore.
let _ = token;
@@ -358,7 +358,7 @@ async fn listen_channel_eof_cancels_loop_and_tears_listener_down() {
async fn forwarded_open_failure_isolates_the_accept() {
init_tracing();
// The topology's consumer identity holds `tunnel:open` but NOT
// `tunnel:forwarded` — every forwarded open fails FORBIDDEN at the
// `tunnel:forwarded`, so every forwarded open fails FORBIDDEN at the
// opener's ACL. The loop must keep serving (a second accept
// produces a second forwarded-open attempt).
let registry = ResourceRegistry::new();
@@ -383,7 +383,7 @@ async fn forwarded_open_failure_isolates_the_accept() {
.await
.expect("listen open");
// First accept: the forwarded open fails (scope-less opener) —
// First accept: the forwarded open fails (scope-less opener),
// the accepted connection drops, the loop continues.
push_accept(&queue, &notify, (198, 55233)).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
@@ -392,7 +392,7 @@ async fn forwarded_open_failure_isolates_the_accept() {
"a failed forwarded open leaves no channel beyond the listen channel"
);
// Second accept: the loop is still alive (per-accept isolation) —
// Second accept: the loop is still alive (per-accept isolation);
// this one fails the same way, but the LOOP's health is what the
// test pins: the accept was CONSUMED (the queue drained).
push_accept(&queue, &notify, (198, 55234)).await;
@@ -459,7 +459,7 @@ async fn establishment_errors_are_typed_on_the_wire() {
/// Token discipline: two opens on the same producer mint distinct
/// tokens; each dispatcher stamps ITS OWN plan's token (the second
/// open's forwarded opens carry the second token — pinned via the
/// open's forwarded opens carry the second token, pinned via the
/// correlation stub's map: the FIRST token's entry is left
/// unconsumed).
#[tokio::test]
@@ -507,7 +507,7 @@ async fn token_discipline_two_opens_distinct_tokens() {
// Both dispatchers are live. Correlate BOTH tokens (each opener
// session keys its own), but only register the local halves for
// t1 — a forwarded open stamped t2 would correlation-miss. Push
// t1; a forwarded open stamped t2 would correlation-miss. Push
// one accept AFTER open 2: whichever dispatcher consumed it, the
// STAMPED token is the plan's own. To pin "each dispatcher stamps
// ITS OWN plan's token", correlate both but track which token the
@@ -541,7 +541,7 @@ async fn token_discipline_two_opens_distinct_tokens() {
/// The forwarded-op serving stub's forwarded-substrate log: the
/// `substrate` param of every forwarded open (the M-1 remediation
/// probe surface — the substrate's SOURCE: the plan, not a re-parse).
/// probe surface, the substrate's SOURCE: the plan, not a re-parse).
pub type SubstrateLog = Arc<tokio::sync::Mutex<Vec<alktunnels::params::Substrate>>>;
/// The forwarded-op serving stub variant that records each forwarded
@@ -610,16 +610,16 @@ async fn install_substrate_recording_stub(
/// M-1 remediation pin: the forwarded opens' `substrate` comes from
/// the PLAN (stamped at establisher time), not a re-parse of wire
/// input — a UDP bind-first listen produces forwarded opens carrying
/// input: a UDP bind-first listen produces forwarded opens carrying
/// `substrate: udp` even though the re-parse-and-fallback shape would
/// have stamped `tcp` if it ever fell back. (The fallback itself is
/// REMOVED — this test pins the plan as the source by observing the
/// REMOVED, so this test pins the plan as the source by observing the
/// forwarded params through a real dispatcher round trip.)
#[tokio::test]
async fn forwarded_opens_carry_plan_stamped_substrate() {
init_tracing();
// A UDP-flavored bind-first registry: the open is
// `{resource: "bind", substrate: "udp"}` — the plan must stamp
// `{resource: "bind", substrate: "udp"}`, so the plan must stamp
// Udp (the old fallback shape would have stamped Tcp on any
// parse failure).
let registry = ResourceRegistry::new();
@@ -639,7 +639,7 @@ async fn forwarded_opens_carry_plan_stamped_substrate() {
let correlations = install_substrate_recording_stub(&topo, Arc::clone(&substrates)).await;
// The listen open over the UDP substrate (the harness's
// open-call helper hardcodes "tcp" — call with the udp input
// open-call helper hardcodes "tcp", so call with the udp input
// directly; the schema gate accepts the registered UDP resource).
let payload = serde_json::json!({
"operationId": "channels/tunnel/sub",
@@ -668,7 +668,7 @@ async fn forwarded_opens_carry_plan_stamped_substrate() {
.await
.expect("adopt listen channel");
// One accept: the dispatcher opens forwarded — the forwarded
// One accept: the dispatcher opens forwarded, and the forwarded
// params' `substrate` must be the PLAN's (Udp), not a fallback.
push_accept(&queue, &notify, (198, 55236)).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
@@ -682,7 +682,7 @@ async fn forwarded_opens_carry_plan_stamped_substrate() {
}
/// A plain dial open sees NEITHER reply field (the projection
/// property — the reserved-key guard upstream guarantees `channel_id`
/// property; the reserved-key guard upstream guarantees `channel_id`
/// stays wrapper-owned; a dial establisher contributes nothing).
#[tokio::test]
async fn dial_open_reply_has_neither_field() {
@@ -721,7 +721,7 @@ fn harness_queue_loop(
let notify = Arc::clone(&notify);
Box::pin(async move {
// The queue-driven accept: check once, wait for a push,
// check again (the Notify lost-wakeup pattern — the
// check again (the Notify lost-wakeup pattern: the
// `Notified` future is created BEFORE the state check on
// the waiting path). One accepted pair per closure call
// (the dispatcher invokes the closure per accept).
@@ -747,7 +747,7 @@ fn harness_queue_loop(
})
}
/// A never-accepting loop (the establishment-error probes — the loop
/// A never-accepting loop (the establishment-error probes; the loop
/// never runs: the open fails at the establisher).
fn noop_loop() -> BindFirstAcceptLoop {
Arc::new(|_listener| {
@@ -756,7 +756,7 @@ fn noop_loop() -> BindFirstAcceptLoop {
}
/// The "local BIND client" pair (the opener-side pump target): a raw
/// duplex pair — the map's halves are the stub's pump target, the far
/// duplex pair; the map's halves are the stub's pump target, the far
/// end is the test's client end (driven directly; the accepted side's
/// echo task closes the loop).
fn bind_client_pair() -> (
+12 -12
View File
@@ -2,12 +2,12 @@
//! ADR-007) end to end over the duplex harness: the params → spec →
//! establisher → registration → `TunnelSession::open_direct` chain.
//!
//! Covered: e2e round-trips through `pump_bidi` (tcp + udp — the
//! Covered: e2e round-trips through `pump_bidi` (tcp + udp, with the
//! framed adapter wraps at the dial closure, datagram boundary
//! preserved), the schema-class failure for malformed targets (no
//! phantom session), the `NOT_FOUND` posture on a producer without
//! the direct op registered, the structural scope separation
//! (`tunnel:open` does NOT imply `tunnel:direct` — FORBIDDEN, and
//! (`tunnel:open` does NOT imply `tunnel:direct`: FORBIDDEN, and
//! vice versa), and the CF-006 per-call opener identity on the
//! direct establisher (witness).
@@ -68,7 +68,7 @@ async fn adopt_and_pump(
(l_read, l_write, pump)
}
/// Only channel 0 may remain — a failed open leaves no data channel
/// Only channel 0 may remain: a failed open leaves no data channel
/// on either side (the phantom-channel property).
fn no_data_channels(topo: &Topology) -> bool {
let consumer = topo.consumer_manager.channel_ids();
@@ -128,7 +128,7 @@ async fn direct_tcp_round_trips_through_pump_bidi() {
#[tokio::test]
async fn direct_udp_round_trips_framed() {
// The UDP dial closure wraps the framed adapter (ADR-003) at the
// boundary — the establisher stays substrate-agnostic; the
// boundary, and the establisher stays substrate-agnostic; the
// session's datagram plane sees boundary-preserved frames.
let topo = wire_direct(all_substrate_dial()).await;
@@ -168,7 +168,7 @@ async fn direct_udp_round_trips_framed() {
async fn malformed_target_is_schema_class_and_leaves_no_channel() {
// The registry's input-schema gate runs before the establisher: a
// unix target without `path` (and a tcp target without `port`)
// fails as `INVALID_INPUT` — never a phantom session, never a
// fails as `INVALID_INPUT`; never a phantom session, never a
// establisher dial.
let topo = wire_direct(echo_dial()).await;
@@ -228,7 +228,7 @@ async fn dial_failure_passes_through_typed() {
#[tokio::test]
async fn producer_without_direct_op_rejects_loudly() {
// An old producer (only the base op registered) rejects the
// unknown op loudly — NOT_FOUND, the SSH "unknown channel type"
// unknown op loudly (NOT_FOUND, the SSH "unknown channel type"
// posture.
let registry = alktunnels::producer::ResourceRegistry::new();
registry
@@ -256,7 +256,7 @@ async fn producer_without_direct_op_rejects_loudly() {
#[tokio::test]
async fn tunnel_open_scope_does_not_imply_tunnel_direct() {
// ADR-006 Amendment 1: the scopes are structurally separate
// grants — an identity holding `tunnel:open` (but not
// grants (an identity holding `tunnel:open` but not
// `tunnel:direct`) is FORBIDDEN on the direct op.
let topo = wire_direct_with_identity(consumer_identity()).await;
@@ -308,7 +308,7 @@ async fn direct_scope_does_not_imply_tunnel_open() {
async fn direct_establisher_witnesses_per_call_identity() {
// CF-006: the direct establisher's per-call auth carried the END
// CALLER's identity (the direct op is the arbitrary-egress
// capability — identity visibility matters more, not less).
// capability, so identity visibility matters more, not less).
let topo = wire_direct(echo_dial()).await;
let out = call_direct(
@@ -334,7 +334,7 @@ async fn direct_establisher_witnesses_per_call_identity() {
async fn establisher_parse_backstop_maps_handler_error() {
// The establisher's serde parse is the backstop behind the schema
// gate: feed an input the schema would have rejected but a direct
// establisher call never schema-checks (the raw-call shape —
// establisher call never schema-checks (the raw-call shape:
// assembly variants, or a registry whose schema gate is looser)
// and observe the typed `handler_error` mapping (ADR-007 §4).
// An unknown substrate value fails the enum parse.
@@ -360,7 +360,7 @@ async fn establisher_parse_backstop_maps_handler_error() {
async fn direct_establisher_renders_target_to_backing_string() {
// The design pin: the establisher renders the target to the SAME
// backing-string form the registry backings use and calls the
// injected DialFn with it — the dial closure observes the exact
// injected DialFn with it, and the dial closure observes the exact
// rendered form ("host:port" / path), per ADR-007 §4.
let observed: Arc<tokio::sync::Mutex<Vec<(Substrate, String)>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
@@ -508,8 +508,8 @@ async fn open_direct_malformed_target_no_phantom_session() {
#[tokio::test]
async fn open_direct_scope_separated_session_level() {
// Session-level scope separation (ADR-006 Amendment 1): a
// tunnel:open-only consumer client is FORBIDDEN on open_direct —
// the scopes are separate grants, and the session constructor
// tunnel:open-only consumer client is FORBIDDEN on open_direct
// (the scopes are separate grants, and the session constructor
// states which capability it invokes.
let topo = harness::wire_direct_forward_with_identity(echo_dial(), consumer_identity()).await;
+28 -28
View File
@@ -1,6 +1,6 @@
//! Integration tests for the forwarded op's SERVING side (the
//! listen-opener half — ADR-008 §3, the `tunnels/forwarded-receipt`
//! shape): `register_tunnel_forwarded_openable` — the
//! listen-opener half: ADR-008 §3, the `tunnels/forwarded-receipt`
//! shape): `register_tunnel_forwarded_openable`, the
//! `register_openable` shape (no establisher; the data plane starts
//! immediately), scope-gated `tunnel:forwarded`, whose handler parses
//! `TunnelForwardedParams`, hands the correlation material (parsed
@@ -14,8 +14,8 @@
//! `tunnel:forwarded` → FORBIDDEN, never implied by
//! `tunnel:open`/`tunnel:direct`), malformed params (unknown field,
//! `listen: 0`, missing `peer` → loud rejection; the serde-gated shape
//! tears the channel down at birth — no phantom), and the
//! correlation-miss teardown (the assembly's rejection — drop the
//! tears the channel down at birth (no phantom), and the
//! correlation-miss teardown (the assembly's rejection: drop the
//! stream, the channel reaps).
//!
//! The reply reader's unit tests land in `src/params.rs` (this task's
@@ -24,7 +24,7 @@
//!
//! Topology (the harness's producer-leg stub, mirrored): the producer
//! leg is the connect side (it holds the `ChannelClient` whose
//! `call_open_op` is the per-accept call plane — the reverse POC's
//! `call_open_op` is the per-accept call plane (the reverse POC's
//! `open_reverse_channel` shape, reduced); the opener leg is the
//! accept side whose install-hook serving registry carries the
//! forwarded op under test. The test plays the producer: per probe it
@@ -33,7 +33,7 @@
//! producer-side manager, and pumps the adopted channel against a
//! driving duplex (the dispatcher-pump dance, test-driven). The
//! opener-side assembly correlates by the `listen` token and pumps the
//! forwarded channel against the local "BIND client" halves — bytes
//! forwarded channel against the local "BIND client" halves; bytes
//! the test writes on the client end traverse the whole path and echo
//! back.
@@ -50,7 +50,7 @@ use alktunnels::producer::{register_tunnel_forwarded_openable, TargetHandle};
use harness::{wire_with, RegistrationMode};
/// The opener-side identity holding ONLY `tunnel:forwarded` (the
/// ingress grant — pinned separate from `tunnel:open`/`tunnel:direct`,
/// ingress grant, pinned separate from `tunnel:open`/`tunnel:direct`,
/// never implied by either; this identity holds neither).
fn forwarded_only_identity() -> Identity {
Identity {
@@ -60,7 +60,7 @@ fn forwarded_only_identity() -> Identity {
}
}
/// A plain dial-egress identity (`tunnel:open` only — the
/// A plain dial-egress identity (`tunnel:open` only, the
/// never-implied probe's identity).
fn open_only_identity() -> Identity {
Identity {
@@ -83,10 +83,10 @@ type ReceivedWitness = Arc<tokio::sync::Mutex<Received>>;
/// Serve the forwarded op on the opener side with the injected
/// assembly handler: record the parsed params, pop the correlated
/// halves for the token, and `pump_bidi` the channel `BiStream`
/// against them (convention 8 — the assembly owns the pump; the
/// against them (convention 8: the assembly owns the pump; the
/// protocol-side built-in handler only bridges wrapper→assembly). A
/// correlation miss (no halves for the token) is the assembly's
/// rejection: drop the stream, return — teardown-at-birth reaps the
/// rejection: drop the stream, return; teardown-at-birth reaps the
/// forwarded channel.
fn serve_forwarded_on(
consumer_registry: &Arc<alkcall::registry::registration::OperationRegistry>,
@@ -121,7 +121,7 @@ fn serve_forwarded_on(
}
/// A topology for the forwarded-op serving probes: the producer leg
/// (connect side) serves only the unused base op (failing dial — the
/// (connect side) serves only the unused base op (a failing dial, the
/// base op is not what's under test); the opener leg is the accept
/// side whose serving registry carries the forwarded op. The test
/// plays the producer: `topo.producer.call_open_op(OP_TUNNEL_FORWARDED,
@@ -147,7 +147,7 @@ fn forwarded_input(listen: u32, peer: (u32, u16)) -> serde_json::Value {
}
/// Call the forwarded op on the producer's per-accept call plane
/// (`call_open_op` — the producer-side initiator's surface; the
/// (`call_open_op`, the producer-side initiator's surface; the
/// opener's wrapper allocates the channel ID, the producer adopts).
async fn call_forwarded(
producer: &alkcall::channels::client::ChannelClient,
@@ -160,7 +160,7 @@ async fn call_forwarded(
}
/// The accepted-side echo pair (the producer-side pump target): a
/// duplex pair — the driving end an echo task (the app server's
/// duplex pair; the driving end an echo task (the app server's
/// socket stand-in), the far end the pump's halves.
fn driving_halves() -> TargetHandle {
let (drive_end, target_end) = tokio::io::duplex(64 * 1024);
@@ -187,7 +187,7 @@ fn driving_halves() -> TargetHandle {
}
/// The "local BIND client" pair (the opener-side pump target): a raw
/// duplex pair — the map's halves are the assembly pump's target, the
/// duplex pair; the map's halves are the assembly pump's target, the
/// far end is the test's client end (driven directly).
fn bind_client_pair() -> (
tokio::io::ReadHalf<tokio::io::DuplexStream>,
@@ -226,7 +226,7 @@ async fn forwarded_serving_round_trip_end_to_end() {
Arc::clone(&local_halves),
);
// Correlate FIRST (the waiting listener session's bookkeeping —
// Correlate FIRST (the waiting listener session's bookkeeping:
// the assembly pre-registered the local halves for the token
// before the forwarded open arrives; a late push is the
// correlation-miss shape, probed separately below).
@@ -266,7 +266,7 @@ async fn forwarded_serving_round_trip_end_to_end() {
// The producer-side adopted pump (the test drives the accepted
// side's echo): adopt the opener-allocated ID on the
// producer-side manager and pump the adopted channel against a
// driving duplex — the dispatcher-pump dance, test-driven.
// driving duplex (the dispatcher-pump dance, test-driven).
let (send, recv) = topo
.producer
.manager()
@@ -298,8 +298,8 @@ async fn forwarded_serving_round_trip_end_to_end() {
assert_eq!(&buf, b"forwarded-ping");
}
/// The scope gate: an identity WITHOUT `tunnel:forwarded` is FORBIDDEN
/// — never implied by `tunnel:open` (this identity holds exactly that
/// The scope gate: an identity WITHOUT `tunnel:forwarded` is FORBIDDEN,
/// never implied by `tunnel:open` (this identity holds exactly that
/// grant; ADR-006 Amendment 1's separation).
#[tokio::test]
async fn forwarded_scope_gate_is_independent_grant() {
@@ -342,7 +342,7 @@ async fn forwarded_scope_gate_is_independent_grant() {
}
/// Malformed params: `listen: 0` and a missing `peer` fail the
/// registry's schema gate LOUD (`INVALID_INPUT`, before the wrapper —
/// registry's schema gate LOUD (`INVALID_INPUT`; before the wrapper,
/// no channel ever); an unknown top-level field passes the schema (the
/// additive posture) but fails the serde parse (the loud backstop) →
/// the wrapper logs + returns early → the channel tears down at birth
@@ -360,7 +360,7 @@ async fn forwarded_malformed_params_tear_down_loud() {
local_halves,
);
// `listen: 0` — the schema gate (minimum: 1) fires first.
// `listen: 0`: the schema gate (minimum: 1) fires first.
match call_forwarded(
&topo.producer,
serde_json::json!({
@@ -377,7 +377,7 @@ async fn forwarded_malformed_params_tear_down_loud() {
}
}
// Missing `peer` — the schema gate again (all three required).
// Missing `peer`, the schema gate again (all three required).
match call_forwarded(
&topo.producer,
serde_json::json!({"listen": 7, "substrate": "tcp"}),
@@ -390,10 +390,10 @@ async fn forwarded_malformed_params_tear_down_loud() {
}
}
// Unknown top-level field — passes the schema (additive posture),
// Unknown top-level field: passes the schema (additive posture),
// fails the serde parse (deny-unknown backstop) → the wrapper
// returns early → teardown-at-birth (the channel was allocated —
// the reply IS the channel_id — but reaps promptly).
// returns early → teardown-at-birth (the channel was allocated;
// the reply IS the channel_id, but reaps promptly).
let out = call_forwarded(
&topo.producer,
serde_json::json!({
@@ -415,18 +415,18 @@ async fn forwarded_malformed_params_tear_down_loud() {
assert!(received.lock().await.params.is_none());
}
/// The correlation-miss teardown (the assembly's rejection — it holds
/// The correlation-miss teardown (the assembly's rejection; it holds
/// the map): a valid-shaped open whose `listen` token matches no
/// waiting session → the assembly's error path drops the stream and
/// returns → teardown-at-birth reaps the forwarded channel. The
/// producer's accept loop drops the accepted connection on the open's
/// failure (the shapes mirror — pinned producer-side in
/// failure (the shapes mirror; pinned producer-side in
/// `tunnels/bindfirst-producer`).
#[tokio::test]
async fn forwarded_correlation_miss_tears_channel_down() {
let topo = wire_forwarded_serving(forwarded_only_identity()).await;
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
// NO correlated halves for the token — the map is empty.
// NO correlated halves for the token (the map is empty).
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
serve_forwarded_on(
@@ -445,7 +445,7 @@ async fn forwarded_correlation_miss_tears_channel_down() {
!topo.consumer_manager.has_channel(channel_id),
"a correlation miss tears the forwarded channel down (teardown-at-birth)"
);
// The assembly handler RAN (the invocation was witnessed — the
// The assembly handler RAN (the invocation was witnessed; the
// rejection is the assembly's) and its map missed.
let witnessed = received.lock().await.params.clone();
assert_eq!(witnessed.expect("invoked").listen, 42113);
+586
View File
@@ -0,0 +1,586 @@
//! Integration tests for the forwarded op's establisher variant
//! (ADR-008 Amendment 2:
//! `register_tunnel_forwarded_openable_with_establisher` + the
//! injected [`ForwardedGate`]): the four pins plan 003 §W1's
//! "Tests:" bullet asks for, mirroring `tests/producer_forwarded.rs`
//! (the base serving shape is that file's; these probes cover the
//! establisher-registered delta).
//!
//! Covered:
//! - the gate MISS: a `listen` token with no waiting session → the
//! open fails as a typed `channel:open_failed` with
//! `details.reason == "unknown_resource"`; the transiently
//! allocated channel is torn down by the wrapper (ADR-049 §5; the
//! reply is the typed error, not a phantom channel); the pump
//! handler NEVER runs.
//! - the gate PASS: the existing serving flow is unchanged; the
//! injected handler receives the parsed params + the `BiStream`,
//! the byte round-trip works end to end.
//! - the residual race: gate passes, route gone at handler time →
//! the handler's error path drops the stream → teardown-at-birth
//! reaps the forwarded channel (re-pinned under the establisher
//! variant; the establisher-pass→handler-run window is the residual
//! `correlation-miss` class).
//! - the ordering: an identity WITHOUT `tunnel:forwarded` → FORBIDDEN
//! and the gate closure is never invoked (the registry runs the ACL
//! before the open op's establishment phase (schema → ACL →
//! allocate → establisher → handler).
//! - the establisher-variant malformed-params delta: an unknown
//! top-level field (passes the schema's additive posture, fails the
//! establisher's serde parse) → a typed `channel:open_failed` with
//! `details.reason == "handler_error"`, NOT the base shape's
//! success-then-birth-teardown; the parse precedes the gate (the
//! gate never sees malformed input).
mod harness;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use alkcall::channels::manager::ChannelManager;
use alkcall::core::auth::Identity;
use alkcall::protocol::wire::CallError;
use alktunnels::params::{TUNNEL_FORWARDED_SCOPE, TUNNEL_OPEN_SCOPE};
use alktunnels::producer::{
register_tunnel_forwarded_openable_with_establisher, ForwardedGate, TargetHandle,
TunnelEstablishError,
};
use harness::{wire_with, RegistrationMode};
/// The opener-side identity holding ONLY `tunnel:forwarded` (the
/// ingress grant; `tests/producer_forwarded.rs`'s shape).
fn forwarded_only_identity() -> Identity {
Identity {
id: "opener-forwarded-only".to_string(),
scopes: vec![TUNNEL_FORWARDED_SCOPE.to_string()],
resources: std::collections::HashMap::new(),
}
}
/// A plain dial-egress identity (`tunnel:open` only, no
/// `tunnel:forwarded`; the ordering probe's identity).
fn open_only_identity() -> Identity {
Identity {
id: "opener-open-only".to_string(),
scopes: vec![TUNNEL_OPEN_SCOPE.to_string()],
resources: std::collections::HashMap::new(),
}
}
/// What the assembly's test handler received per forwarded open (the
/// invocation probe; unmoved = the handler never ran).
#[derive(Default)]
struct Received {
params: Option<alktunnels::params::TunnelForwardedParams>,
}
type ReceivedWitness = Arc<tokio::sync::Mutex<Received>>;
/// The gate-invocation witness: the gate sets it on entry; an
/// ordering assertion (`assert !moved`) pins "the gate never saw the
/// open" (the scope-gate precedence pin, task AC 4).
type GateWitness = Arc<AtomicBool>;
/// The waiting-session map: `listen` token → present. The gate's
/// sync in-memory lookup surface (convention 11's "the gate is an
/// in-memory map lookup"): a token present at gate time = a waiting
/// session; absent = the miss (`TunnelEstablishError::UnknownResource`,
/// the canonical stale-token reason ADR-008 Amendment 2 pins).
type WaitingTokens = Arc<Mutex<HashSet<u32>>>;
/// The gate closure under test: set the witness on entry (even for
/// opens it will reject, because the ordering probe needs the untouched
/// signal), then the token lookup.
fn gate_with_tokens(waiting: WaitingTokens, invoked: GateWitness) -> ForwardedGate {
Arc::new(move |params: &alktunnels::params::TunnelForwardedParams| {
invoked.store(true, Ordering::SeqCst);
if waiting
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains(&params.listen)
{
Ok(())
} else {
Err(TunnelEstablishError::UnknownResource(format!(
"no waiting session for listen token {}",
params.listen
)))
}
})
}
/// Serve the forwarded op on the opener side through the establisher
/// variant: the same injected handler shape as
/// `tests/producer_forwarded.rs`'s `serve_forwarded_on` (record the
/// parsed params, pop the correlated halves for the token, `pump_bidi`
/// against them; a handler-time miss drops the stream and returns,
/// teardown-at-birth) with the gate establisher wrapped around it.
fn serve_forwarded_establisher_on(
consumer_registry: &Arc<alkcall::registry::registration::OperationRegistry>,
manager: ChannelManager,
received: ReceivedWitness,
local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>>,
gate: ForwardedGate,
) {
let handler: alktunnels::producer::ForwardedHandler = Arc::new(move |forwarded, bidi| {
let received = Arc::clone(&received);
let local_halves = Arc::clone(&local_halves);
tokio::spawn(async move {
*received.lock().await = Received {
params: Some(forwarded),
};
let target = local_halves.lock().await.pop();
if let Some(target) = target {
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
}
})
});
let core = alkcall::channels::operations::ChannelCore::new(
manager,
alkcall::channels::policy::default_policy(),
);
register_tunnel_forwarded_openable_with_establisher(
&core,
consumer_registry,
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
gate,
handler,
)
.expect("register forwarded openable with establisher");
}
/// A topology for the establisher-variant probes (the
/// `wire_forwarded_serving` shape): the producer leg is the connect
/// side (the base op fails-dial; not what's under test); the opener
/// leg is the accept side whose serving registry carries the
/// establisher-registered forwarded op. The test plays the producer,
/// calling the forwarded op on the producer's call plane; the
/// opener-allocated `channel_id` in the reply is what the producer
/// adopts.
async fn wire_forwarded_establisher(identity: Identity) -> harness::Topology {
wire_with(
alktunnels::producer::ResourceRegistry::new(),
RegistrationMode::Dial(harness::failing_dial(
"base op unused in forwarded-gate probes",
)),
Some(identity),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await
}
fn forwarded_input(listen: u32, peer: (u32, u16)) -> serde_json::Value {
serde_json::json!({
"listen": listen,
"substrate": "tcp",
"peer": {"host": peer.0.to_string(), "port": peer.1},
})
}
/// Call the forwarded op on the producer's per-accept call plane.
async fn call_forwarded(
producer: &alkcall::channels::client::ChannelClient,
input: serde_json::Value,
) -> Result<serde_json::Value, CallError> {
producer
.call_open_op(alktunnels::params::OP_TUNNEL_FORWARDED, input)
.await
.result
}
/// The accepted-side echo pair (the producer-side pump target).
fn driving_halves() -> TargetHandle {
let (drive_end, target_end) = tokio::io::duplex(64 * 1024);
let (mut t_read, mut t_write) = tokio::io::split(target_end);
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = vec![0u8; 8192];
loop {
match t_read.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
if t_write.write_all(&buf[..n]).await.is_err() {
return;
}
}
}
}
});
let (d_read, d_write) = tokio::io::split(drive_end);
TargetHandle {
read: Box::new(d_read),
write: Box::new(d_write),
}
}
/// The "local BIND client" pair (the opener-side pump target).
fn bind_client_pair() -> (
tokio::io::ReadHalf<tokio::io::DuplexStream>,
tokio::io::WriteHalf<tokio::io::DuplexStream>,
TargetHandle,
) {
let (a, b) = tokio::io::duplex(64 * 1024);
let (a_read, a_write) = tokio::io::split(a);
let (b_read, b_write) = tokio::io::split(b);
(
b_read,
b_write,
TargetHandle {
read: Box::new(a_read),
write: Box::new(a_write),
},
)
}
/// The gate MISS: a `listen` token with no waiting session → the open
/// fails as a typed `channel:open_failed` with
/// `details.reason == "unknown_resource"`; the transiently allocated
/// channel is torn down by the wrapper (no phantom channel, ADR-049
/// §5); the pump handler NEVER runs. The gate DID run (its witness is
/// set; the miss is AT the gate, not before it).
#[tokio::test]
async fn gate_miss_fails_typed_no_phantom_no_handler() {
let topo = wire_forwarded_establisher(forwarded_only_identity()).await;
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
let waiting: WaitingTokens = Arc::new(Mutex::new(HashSet::new()));
let gate_invoked: GateWitness = Arc::new(AtomicBool::new(false));
serve_forwarded_establisher_on(
&topo.consumer_registry,
topo.consumer_manager.clone(),
Arc::clone(&received),
local_halves,
gate_with_tokens(waiting, Arc::clone(&gate_invoked)),
);
// The open with a token the gate rejects: typed failure, not a
// success + post-open decline.
let err = call_forwarded(&topo.producer, forwarded_input(42113, (198, 55231)))
.await
.expect_err("a gate miss must fail the open");
assert_eq!(
err.code, "channel:open_failed",
"the establisher's typed failure shape"
);
let details = err.details.expect("typed error details");
assert_eq!(
details["reason"], "unknown_resource",
"a stale/missing token maps through the existing table"
);
assert!(
details["message"]
.as_str()
.is_some_and(|m| m.contains("42113")),
"the gate's message names the missed token: {}",
details["message"]
);
// No phantom channel: the wrapper's transient allocation is torn
// down with the establisher's failure (ADR-049 §5).
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert!(
topo.consumer_manager
.channel_ids()
.iter()
.all(|&id| id == 0),
"a gate-miss open leaves no channel on the opener side"
);
// The gate ran (its witness is set); the pump handler NEVER did.
assert!(
gate_invoked.load(Ordering::SeqCst),
"the miss happened at the gate"
);
assert!(
received.lock().await.params.is_none(),
"a gate-miss open must not reach the pump handler"
);
}
/// The gate PASS: the registration serves exactly like the base shape
/// (`tests/producer_forwarded.rs`'s e2e): the injected handler
/// receives the parsed params + the `BiStream`, and the byte
/// round-trip works end to end through the establisher-registered
/// variant.
#[tokio::test]
async fn gate_pass_serves_the_unchanged_serving_flow() {
let topo = wire_forwarded_establisher(forwarded_only_identity()).await;
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
let waiting: WaitingTokens = Arc::new(Mutex::new(HashSet::new()));
serve_forwarded_establisher_on(
&topo.consumer_registry,
topo.consumer_manager.clone(),
Arc::clone(&received),
Arc::clone(&local_halves),
gate_with_tokens(Arc::clone(&waiting), Arc::new(AtomicBool::new(false))),
);
// The waiting session's bookkeeping: token in the gate map + the
// correlated halves pre-registered (the correlate-first posture).
const TOKEN: u32 = 918273;
waiting
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(TOKEN);
let (mut b_read, mut b_write, local) = bind_client_pair();
local_halves.lock().await.push(local);
// The gate-pass open resolves with the wrapper's channel_id.
let out = call_forwarded(&topo.producer, forwarded_input(TOKEN, (198, 55231)))
.await
.expect("the gate-pass open");
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
assert!(channel_id > 0);
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
assert!(
topo.consumer_manager.has_channel(channel_id),
"the forwarded channel is live on the opener side"
);
// The injected handler saw the parsed params verbatim.
let got = received.lock().await.params.clone().expect("invoked");
assert_eq!(got.listen, TOKEN, "the token rides `listen`");
assert_eq!(got.substrate, alktunnels::params::Substrate::Tcp);
assert_eq!(
got.peer,
alktunnels::params::SubstrateAddr::HostPort {
host: "198".to_string(),
port: 55231,
}
);
// The producer adopts the opener-allocated ID and drives the
// accepted side's echo.
let (send, recv) = topo
.producer
.manager()
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
.await
.expect("producer adopts the opener-allocated ID");
let driving = driving_halves();
let _producer_pump = tokio::spawn(alkcall::channels::pump::pump_bidi(
alkcall::core::types::BiStream::from_joined(recv, send),
driving.read,
driving.write,
));
// THE round-trip: the test writes on the BIND client's end → the
// assembly's pump → the forwarded channel → the producer's pump →
// the driving echo → back.
use tokio::io::{AsyncReadExt, AsyncWriteExt};
b_write.write_all(b"gate-pass-ping").await.expect("write");
b_write.flush().await.expect("flush");
let mut buf = vec![0u8; 14];
tokio::time::timeout(
std::time::Duration::from_secs(5),
b_read.read_exact(&mut buf),
)
.await
.expect("round trip")
.expect("read");
assert_eq!(&buf, b"gate-pass-ping");
}
/// The residual race (the base shape's pinned semantics, re-pinned
/// under the establisher variant): the gate passes (waiting token
/// present) but the route is GONE at handler time (no correlated
/// halves) → the injected handler's error path drops the stream and
/// returns → teardown-at-birth reaps the forwarded channel. The
/// establisher-pass→handler-run window is the residual
/// `correlation-miss` class; the handler-side fallback is what reaps
/// it, under both shapes.
#[tokio::test]
async fn residual_race_still_reaps_cleanly() {
let topo = wire_forwarded_establisher(forwarded_only_identity()).await;
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
let waiting: WaitingTokens = Arc::new(Mutex::new(HashSet::new()));
serve_forwarded_establisher_on(
&topo.consumer_registry,
topo.consumer_manager.clone(),
Arc::clone(&received),
local_halves,
gate_with_tokens(Arc::clone(&waiting), Arc::new(AtomicBool::new(false))),
);
// The gate passes (the waiting token is present at establisher
// time), but nothing was correlated for the handler: the race.
const TOKEN: u32 = 42114;
waiting
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(TOKEN);
// The open itself STILL RESOLVES (the gate passed; the call-plane
// success is what makes this a residual, post-open race).
let out = call_forwarded(&topo.producer, forwarded_input(TOKEN, (198, 55232)))
.await
.expect("the gate-pass open");
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
// The handler ran (the invocation was witnessed; the rejection is
// the handler's and the stream was dropped), and its miss reaps
// the channel (teardown-at-birth).
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let witnessed = received.lock().await.params.clone();
assert_eq!(witnessed.expect("invoked").listen, TOKEN);
assert!(
!topo.consumer_manager.has_channel(channel_id),
"a residual-race handler-miss reaps the forwarded channel"
);
}
/// The ordering: an identity WITHOUT `tunnel:forwarded` → FORBIDDEN
/// and the gate closure is NEVER invoked (a witness the gate sets on
/// entry stays unmoved): the registry runs the ACL before the open
/// op's establishment phase, so the gate never sees an unauthorized
/// open.
#[tokio::test]
async fn scope_gate_precedes_the_establisher() {
let topo = wire_forwarded_establisher(open_only_identity()).await;
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
let waiting: WaitingTokens = Arc::new(Mutex::new(HashSet::new()));
waiting.lock().unwrap_or_else(|e| e.into_inner()).insert(77);
let gate_invoked: GateWitness = Arc::new(AtomicBool::new(false));
serve_forwarded_establisher_on(
&topo.consumer_registry,
topo.consumer_manager.clone(),
Arc::clone(&received),
local_halves,
gate_with_tokens(waiting, Arc::clone(&gate_invoked)),
);
// The scope-less open is FORBIDDEN (even though the token WOULD
// have passed if the gate had run first; the tie-break is the
// order, not the gate's answer).
match call_forwarded(&topo.producer, forwarded_input(77, (10, 1))).await {
Ok(_) => panic!("a scope-less opener must not reach forwarded establishes"),
Err(e) => {
assert_eq!(
e.code, "FORBIDDEN",
"missing `tunnel:forwarded` → FORBIDDEN"
);
assert!(
e.message.contains("tunnel:forwarded"),
"the denial names the missing scope: {}",
e.message
);
}
}
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
// The establishment phase never ran: no gate invocation, no
// channel allocation, no handler execution.
assert!(
!gate_invoked.load(Ordering::SeqCst),
"the ACL precedes the establisher; the gate never saw the open"
);
assert!(
topo.consumer_manager
.channel_ids()
.iter()
.all(|&id| id == 0),
"a FORBIDDEN open leaves no channel on the opener side"
);
assert!(received.lock().await.params.is_none());
}
/// The establisher-variant malformed-params delta (the base shape's
/// `forwarded_malformed_params_tear_down_loud` covers the base
/// posture; this pins the delta): the schema gates (`listen: 0`,
/// missing `peer`) fire first exactly as before (`INVALID_INPUT`),
/// but an unknown top-level field (passes the schema's additive
/// posture, fails the establisher's serde parse) becomes a TYPED
/// `channel:open_failed` with `details.reason == "handler_error"`.
/// The parse precedes the gate (the gate's witness is never set), the
/// wrapper tears the transient allocation down, and the pump handler
/// never runs.
#[tokio::test]
async fn malformed_params_fail_typed_at_the_establisher() {
let topo = wire_forwarded_establisher(forwarded_only_identity()).await;
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
let gate_invoked: GateWitness = Arc::new(AtomicBool::new(false));
serve_forwarded_establisher_on(
&topo.consumer_registry,
topo.consumer_manager.clone(),
Arc::clone(&received),
local_halves,
gate_with_tokens(
Arc::new(Mutex::new(HashSet::new())),
Arc::clone(&gate_invoked),
),
);
// The schema gates are unchanged by the establisher (they precede
// it, the wrapper order's schema step).
match call_forwarded(
&topo.producer,
serde_json::json!({
"listen": 0,
"substrate": "tcp",
"peer": {"host": "10", "port": 53},
}),
)
.await
{
Ok(_) => panic!("`listen: 0` must fail the open"),
Err(e) => assert_eq!(e.code, "INVALID_INPUT", "schema gate unchanged"),
}
match call_forwarded(
&topo.producer,
serde_json::json!({"listen": 7, "substrate": "tcp"}),
)
.await
{
Ok(_) => panic!("missing `peer` must fail the open"),
Err(e) => assert_eq!(e.code, "INVALID_INPUT", "schema gate unchanged"),
}
// THE DELTA: an unknown top-level field fails the establisher's
// serde parse → a typed open failure (vs the base shape's
// success-with-channel_id + birth-teardown; the typed posture is
// the improvement ADR-008 Amendment 2 pins).
let err = call_forwarded(
&topo.producer,
serde_json::json!({
"listen": 918273,
"substrate": "tcp",
"peer": {"host": "198", "port": 55233},
"sneaky": true
}),
)
.await
.expect_err("the malformed parse must fail the open as a typed error");
assert_eq!(err.code, "channel:open_failed");
let details = err.details.expect("typed error details");
assert_eq!(
details["reason"], "handler_error",
"an establisher parse failure maps through the existing table"
);
// The establisher's parse precedes the gate; the wrapper tears
// the transient allocation down; the pump handler never runs.
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert!(
!gate_invoked.load(Ordering::SeqCst),
"the establisher parses BEFORE the gate; the gate never ran"
);
assert!(
topo.consumer_manager
.channel_ids()
.iter()
.all(|&id| id == 0),
"a parse-failed open leaves no channel on the opener side"
);
assert!(received.lock().await.params.is_none());
}
+14 -14
View File
@@ -1,9 +1,9 @@
//! Integration tests for the listen establisher (shape 2 — producer.md
//! Integration tests for the listen establisher (shape 2, producer.md
//! §The Establisher): a producer whose resource is a LISTENER. The
//! assembly layer owns the listener + its accept loop and feeds an
//! [`AcceptQueue`]; the establisher pops one accepted handle during
//! the open op's establishment phase. Same open op, same params, same
//! typed errors — the pump handler is unchanged.
//! typed errors; the pump handler is unchanged.
//!
//! Covered: the listen flow end-to-end (in-process listener queue fed
//! by a test accept loop; consumer opens toward it; the accepted
@@ -127,7 +127,7 @@ async fn listen_flow_round_trips() {
#[tokio::test]
async fn pop_during_establishment_is_always_before_take() {
// The always-before-take guarantee: two accepts pushed BEFORE an
// open are popped in FIFO order across two opens — each open's
// open are popped in FIFO order across two opens; each open's
// establisher sees the handle accepted before it (the plan flow:
// every open's result flows to ITS pump).
let queue = AcceptQueue::new();
@@ -179,10 +179,10 @@ async fn pop_during_establishment_is_always_before_take() {
async fn empty_queue_is_resource_shortage() {
// producer.md's error mapping: an empty queue during establishment
// → `resource_shortage`. The QUEUE's pop waits (a late accept is
// legitimate — see accept_wait_resolves_when_push_arrives_late);
// legitimate; see accept_wait_resolves_when_push_arrives_late);
// the assembly layer decides its own posture. An assembly that
// knows nothing is coming (a drained listener, a bounded accept
// budget) closes its queue or maps empty-pop to the typed error —
// budget) closes its queue or maps empty-pop to the typed error;
// here, the closure maps it directly (fail-fast posture).
let queue = AcceptQueue::new();
queue.close().await;
@@ -194,7 +194,7 @@ async fn empty_queue_is_resource_shortage() {
let queue = queue.clone();
Box::pin(async move {
// Bounded wait (the assembly's own deadline, not the
// wrapper's): a closed queue is an empty listen backlog —
// wrapper's): a closed queue is an empty listen backlog,
// resource_shortage per producer.md's table.
match tokio::time::timeout(std::time::Duration::from_millis(50), queue.pop()).await {
Ok(Some(handle)) => Ok(handle),
@@ -288,7 +288,7 @@ async fn accept_wait_resolves_when_push_arrives_late() {
// Pop-during-establishment ordering under a LIVE accept loop: the
// establisher's pop waits; the accept loop pushes after a tick;
// the open resolves once the push lands (the wrapper's bounded
// wait — ADR-049 §2's deadline applies above us).
// wait; ADR-049 §2's deadline applies above us).
let queue = AcceptQueue::new();
let accept_loop = tokio::spawn({
let queue = queue.clone();
@@ -323,7 +323,7 @@ async fn accept_wait_resolves_when_push_arrives_late() {
#[tokio::test]
async fn push_after_close_returns_the_handle_and_pop_resolves_none() {
// The queue contract: a push after close is `Err(handle)` — the
// The queue contract: a push after close is `Err(handle)`, the
// accept loop's signal to stop feeding (the accepted connection is
// returned untouched; nothing is queued); a closed-and-empty pop
// resolves `None` immediately.
@@ -340,7 +340,7 @@ async fn push_after_close_returns_the_handle_and_pop_resolves_none() {
.push(handle)
.await
.expect_err("push after close must be Err");
// The handle comes back (the caller drops it — the accepted
// The handle comes back (the caller drops it, so the accepted
// connection closes; nothing leaks into the queue).
drop(dropped);
drop(target_side);
@@ -356,7 +356,7 @@ async fn push_after_close_returns_the_handle_and_pop_resolves_none() {
async fn accept_loop_stops_feeding_after_close_and_maps_the_drop() {
// The accept loop's queue-closed mapping (the `local` feature's
// shape): a `push` after close errors the loop with the handler
// error — the assembly layer aborts the loop task on teardown;
// error; the assembly layer aborts the loop task on teardown;
// here the loop itself exits on the mapped error. The unaccepted
// far end is dropped (the loop accepted it; nothing pumps it once
// the queue is closed).
@@ -384,9 +384,9 @@ async fn accept_loop_stops_feeding_after_close_and_maps_the_drop() {
);
}
/// The unix accept loop's queue-closed mapping (G-4 — the unix parity
/// The unix accept loop's queue-closed mapping (G-4, the unix parity
/// twin of the TCP test above): the same loop shape over
/// `UnixListenerHalves` — a `push` after close errors the loop with
/// `UnixListenerHalves`: a `push` after close errors the loop with
/// the handler error, the loop exits on the mapped error, and the
/// unaccepted far end is dropped.
#[tokio::test]
@@ -429,7 +429,7 @@ async fn unix_accept_loop_stops_feeding_after_close_and_maps_the_drop() {
/// runs the pop's check and the future's first poll back to back),
/// hence the multi-thread flavor. The fix creates the `Notified`
/// future BEFORE the state check on every iteration (tokio's
/// documented notify_waiters pattern — the counter comparison at
/// documented notify_waiters pattern (the counter comparison at
/// first poll captures a pre-registration broadcast); the naive
/// create-after-check shape loses it. Structural: no choreography can
/// force the window deterministically, so this test pins the pattern
@@ -444,7 +444,7 @@ async fn pop_wakeup_survives_push_landing_in_the_registration_window() {
let popper = tokio::spawn(async move {
tokio::time::timeout(std::time::Duration::from_millis(500), queue_popper.pop())
.await
.expect("pop timed out — lost wakeup")
.expect("pop timed out (lost wakeup)")
.expect("queue closed early")
});
// The push races the popper's check→register window on the
+11 -11
View File
@@ -1,13 +1,13 @@
//! Integration tests for the producer half — the open-op path
//! Integration tests for the producer half: the open-op path
//! (establisher + pump handler + registration) over the duplex
//! harness, ported from the reverse POC's suite (16 tests rode the
//! topology) with the dial closure standing in for real sockets.
//!
//! Covered: establishment success + the typed-error table
//! (`unknown_resource` / `dial_failed` / `FORBIDDEN`), the plan flow
//! under same-resource concurrency (R-01 — no handoff race), the
//! under same-resource concurrency (R-01, no handoff race), the
//! per-call opener identity (CF-006 witness), late-registration
//! visibility (W2), pump round-trip through `pump_bidi` (R-02 — the
//! visibility (W2), pump round-trip through `pump_bidi` (R-02, the
//! wrapper-managed pump, no spawn-and-forget), and out-of-band
//! `channel/close`.
@@ -67,7 +67,7 @@ async fn call_open_params(
}
/// Adopt the producer-allocated channel and pump it against in-process
/// halves (the consumer-side data plane — the session's take_halves
/// halves (the consumer-side data plane, the session's take_halves
/// shape, driven manually until the consumer session task lands).
async fn adopt_and_pump(
topo: &Topology,
@@ -90,7 +90,7 @@ async fn adopt_and_pump(
(l_read, l_write, pump)
}
/// Only channel 0 (the pre-negotiated call channel) may remain — a
/// Only channel 0 (the pre-negotiated call channel) may remain; a
/// failed open leaves no data channel on either side (the
/// phantom-channel property).
fn no_data_channels(topo: &Topology) -> bool {
@@ -183,7 +183,7 @@ async fn dial_failure_is_typed_and_leaves_no_channel() {
#[tokio::test]
async fn schema_gate_rejects_missing_substrate_before_the_establisher() {
// The registry's input-schema gate runs before the establisher:
// a missing substrate is `INVALID_INPUT` with no reason code —
// a missing substrate is `INVALID_INPUT` with no reason code, and
// the establisher's own `handler_error` parse branch is
// unreachable past this gate (the schema pins both fields).
let registry = ResourceRegistry::new();
@@ -251,7 +251,7 @@ async fn open_on_transport_identity_alone() {
#[tokio::test]
async fn open_token_overrides_transport_identity() {
// CF-005 precedence: the payload token wins. The provider resolves
// the scoped token to the consumer identity — the witness proves
// the scoped token to the consumer identity; the witness proves
// the establisher saw it end-to-end.
let registry = ResourceRegistry::new();
registry
@@ -277,7 +277,7 @@ async fn open_token_overrides_transport_identity() {
#[tokio::test]
async fn concurrent_opens_same_resource_no_plan_race() {
// The R-01 plan flow under concurrency: two opens of the SAME
// resource — the establisher returns each dialed handle via ITS
// resource; the establisher returns each dialed handle via ITS
// plan; no handoff map to race. Each round-trips its own marker.
let registry = ResourceRegistry::new();
registry
@@ -312,7 +312,7 @@ async fn concurrent_opens_same_resource_no_plan_race() {
async fn late_registration_is_visible() {
// W2: the harness registers the openable AFTER
// from_connection_with_serving. If the dispatcher cached
// registrations, opens would fail with unknown-operation — the
// registrations, opens would fail with unknown-operation, which
// successful opens elsewhere prove it; assert registry state too.
let registry = ResourceRegistry::new();
registry
@@ -370,7 +370,7 @@ async fn producer_outbound_calls_still_resolve_while_serving() {
#[tokio::test]
async fn registry_lookup_respects_substrate_key() {
// The registry is keyed by (resource, substrate): a resource
// produced for TCP is unknown for UDP — the same name on a
// produced for TCP is unknown for UDP: the same name on a
// different substrate is a different resource (ADR-001).
let registry = ResourceRegistry::new();
registry.register("svc", Substrate::Tcp, "in-process").await;
@@ -386,7 +386,7 @@ async fn registry_lookup_respects_substrate_key() {
#[tokio::test]
async fn tunnel_establisher_no_witness_wrapper_opens() {
// The no-witness wrapper (`tunnel_establisher` — the public
// The no-witness wrapper (`tunnel_establisher`, the public
// default shape, producer.rs's witness-less establisher body):
// a full open through the wrapper round-trips. The wrapper
// differs from `tunnel_establisher_with_witness` ONLY in the