21 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
glm-5.3-flash 9cca6ed3c4 release: v0.4.1 — the dependency-patch (alkcall 0.10.1)
- Cargo.toml: alkcall 0.10.0 -> 0.10.1 (crates.io, the
  adoption-window ordering fix — review 013, routed from alkgit's
  wave-2 gate; install + park-drain are one atomic section so a
  demux-routed chunk can no longer overtake earlier-parked early
  arrivals, plus the parked-forever loss variant closed) and
  0.4.0 -> 0.4.1 (no alktunnels API change, no wire change, backwards-
  compatible dep bump -> patch)
- CHANGELOG: Unreleased cut to 0.4.1 (the alkcall 0.10.1 ride + the
  compare links updated)
- README: the 0.4.1 note (alkcall 0.10.1 adoption-window ordering fix)
- docs/architecture/overview.md: dep tree alkcall 0.10.1

Verification: cargo test (136 pass, default + all-features),
clippy --all-targets -D warnings (default, all-features,
wasm32-unknown-unknown), fmt --check, doc, wasm32 check, and cargo
publish --dry-run --allow-dirty all green
2026-10-22 06:24:41 +00:00
glm-5.3-flash 200d7d3835 release: v0.4.0 — the dependency-major (alkcall 0.10.0)
- Cargo.toml: alkcall 0.9.0 -> 0.10.0 (crates.io, the gateway
  ownership-seam release) and 0.3.0 -> 0.4.0 (breaking dep bump +
  public alkcall re-exports -> minor, matching the 0.3.0 ride on
  alkcall 0.9.0)
- Zero source changes: the 0.10.0 breaking surface (the loud
  ownership-wiring default on the GatewayDispatch spine, behind the
  opt-in gateway feature) is off this crate's paths — tunnel opens
  serve via ChannelCore::register_openable* registry dispatch, which
  0.10.0 leaves untouched
- fuzz/shared/Cargo.toml: alkcall 0.10.0 (path-linked alktunnels must
  see one alkcall version)
- CHANGELOG: 0.4.0 section + compare links
- README + docs/architecture/overview.md + AGENTS.md: the 0.10.0
  notes / dep tree / alkcall refs

Verification: cargo test (136 default / 163 with local, all green),
fuzz/shared corpus replay (5/5), clippy --all-targets -D warnings
(native + wasm32-unknown-unknown), fmt --check, wasm32 check, and
cargo publish --dry-run --allow-dirty all green
2026-10-19 03:43:15 +00:00
glm-5.3-flash 3c6fc29271 docs: AGENTS.md alkcall refs — v0.7.x/0.7.0 pin -> v0.9.x/0.9.0
Stale current-state claims from two versions back: the §11 pin
("alkcall = \"0.7.0\"") and the "alkcall is v0.7.x" statement, plus the
Architecture Context bullet (v0.7.x). The 0.6.0/0.7.0-surface prose
(establishment, CF-005/CF-006 identity seam) keeps its historical
version qualifiers — those describe where upstream landed, not current
state. Dated records in docs/research|reviews, tasks/, and the
CHANGELOG are untouched by design.
2026-10-14 05:25:02 +00:00
glm-5.3-flash 1365272146 release: v0.3.0 — the dependency-major (alkcall 0.9.0)
- Cargo.toml: alkcall 0.8.1 -> 0.9.0 (crates.io, the stub-sweep
  release) and 0.2.1 -> 0.3.0 (breaking dep bump + public alkcall
  re-exports -> minor, matching the 0.7.1 -> 0.8.0 ride on 0.2.0)
- fuzz/shared/Cargo.toml: alkcall 0.9.0 (path-linked alktunnels must
  see one alkcall version)
- src/producer.rs + tests/producer_direct_op.rs: the only code contact
  with the 0.9.0 breaking surface — Establishment no longer derives
  Debug; two expect_err calls -> .err().expect, one untyped
  panic!("{other:?}") fall-through -> typed Err/Ok arms
- CHANGELOG: 0.3.0 section + compare links
- README: the 0.3.0 note
- docs/architecture/overview.md: dep tree alkcall 0.9.0

Verification: cargo test (136 default / 163 with local), fuzz/shared
corpus replay (0 pass, empty target set — no regressions), clippy
--all-targets -D warnings (native + wasm32-unknown-unknown), fmt
--check, wasm32 check, and cargo publish --dry-run --allow-dirty all
green
2026-10-14 05:23:08 +00:00
glm-5.3-flash 05b25465aa release: v0.2.1 — the hardening (fuzzing adopted + alkcall 0.8.1)
- CHANGELOG: Unreleased cut to 0.2.1 (the fuzz/ workspace: three
  cargo-fuzz targets + stable-toolchain corpus replay in fuzz/shared;
  the two wave-2 diagnostics datagram_backlog/retained_bytes; the
  alkcall 0.8.0 -> 0.8.1 bump — duplicate adopt no longer destroys the
  live channel; compare links updated)
- README: the 0.2.1 hardening note (fuzzing + alkcall 0.8.1 + the two
  additive diagnostics)
- docs/architecture/overview.md: dep tree alkcall 0.8.1
- Cargo.toml: 0.2.0 -> 0.2.1

Verification: cargo test (136 default / 163 with local), fuzz/shared
corpus replay (5 pass), clippy --all-targets -D warnings (default,
all-features, wasm32-unknown-unknown, fuzz/shared), fmt --check, doc,
wasm32 check, and cargo publish --dry-run --allow-dirty all green
(42 packaged files — fuzz/, docs/research|reviews|plans/, tasks/,
.opencode/, AGENTS.md all excluded)
2026-09-28 10:21:48 +00:00
glm-5.3-flash e3c6f6c9f8 fuzz: wave 2 — session_pumps stateful two-pump target
The wave-2 stateful target from docs/research/fuzzing.md 3.3: op
sequences (arbitrary SessionSequence) 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). The harness mirrors TunnelSession's data-plane
shapes: datagram planes (codec reader + pending, the
DatagramHalves mirror) and stream planes (pump_bidi against
in-process peer halves, the pump_against mirror).

Invariants (3.3): no-panic/no-wedge (5s reconcile join); two-pump
shutdown-on-completion; lossless p2c copy count; EOF-sentinel
teardown semantics (drain-before-EOF, map empties); FIFO datagram
reconciliation through the real demux path (stateful framing walk,
the wave-1 walk carried across ops); the 1.3 DatagramHalves.pending
backlog bound (<= routed-but-unpulled remainder); decoder-state
retained-bytes bound re-asserted per op.

Harness-model findings (all six wave findings are harness defects,
not crate bugs - the fuzzer-as-harness-checker pattern):
- duplicate adopt is a legal ChannelExists no-op (documented table)
- the p2c-EOF pump join is the DESIGNED half-close shape; the pump
  completes once the c2p source EOFs too (teardown), not before
- route_payload routes into the LOCAL manager (demux direction);
  the producer pump writes the channel send half - the first
  campaign's bytes never reached the consumer; rewritten to the
  real protocol shape (Open allocates, session-index addressing,
  stateful framing model, Pull op, EOF sentinels only from
  teardown/sender-drop)
- clear_all's return is the opener-ledger records, NOT the reaped
  ids (adopted channels carry no ledger entry - the alkcall 7.7
  lesson re-learned); the map-empties assert is the ground truth

Campaign (10-min detached, 6 runner): 228,676 execs, 32 fork jobs,
oom/timeout/crash 0/0/0, exit 0. Coverage 3427 edges / 17.5k
features still expanding at budget end. No crate bugs found - the
two-pump contract, EOF-sentinel semantics, and the pending backlog
bound are demonstrated properties.

Crate addition: TunnelSession::datagram_backlog() (the
DatagramHalves.pending diagnostic the 3.3 bound asserts against, in
the retained_bytes() style) + a suite test pinning its rest states.

Verification: main suite 136 passed; corpus replay (8 committed
seeds + 2 semantic fixtures) green; clippy -D warnings + fmt clean
(main, shared, wasm); wasm check clean; cargo fuzz build clean.
2026-09-28 10:12:09 +00:00
glm-5.3-flash 57285d23bd fuzz: adopt fuzzing (wave 1) + alkcall 0.8.1 bump
Bump the substrate dependency to alkcall 0.8.1 — the bug-fix release
whose fix (duplicate adopt/open no longer destroying the live channel)
is directly relevant to this crate's reverse-path adopt flow.

Adopt fuzzing per docs/research/fuzzing.md, scoped smaller than
alkcall's (the envelope/chunk/demux/manager surfaces are upstream and
now fuzzed there; this crate fuzzes its own named downstream target —
the ADR-003 datagram codec — plus the params/reply parsers):

- fuzz/ workspace per the alkcall pattern: nightly-pinned subtree
  (rust-toolchain.toml), thin fuzz_target! binaries, and a
  stable-toolchain shared crate carrying the invariant logic so the
  committed corpus replays as plain `cargo test` (the fuzz gate in
  the release checklist — no hosted CI, alkcall §7.9 posture).
- Targets (both over already-pub APIs; no #[cfg(fuzzing)] exposure):
  - datagram_codec — DatagramReader::feed + frame_datagram: no-panic,
    infallible decode, size bound, F-2 sentinel layering (empty
    datagram only from a length-0 header at a frame boundary),
    3-byte-slice splitting transparency, re-frame round-trip,
    oversize rejection at the bound, is_mid_datagram framing state,
    and the decoder-state retained-bytes bound.
  - params_parse — serde params/reply parsers + establishment_reason:
    no-panic on raw bytes and bounded structured Values, clean typed
    failure, round-trips, listen: 0 pin, loud/lenient projection-pair
    agreement, pass-through reason extraction model.
- New public diagnostic: DatagramReader::retained_bytes() (decoder-
  state byte count; the fuzz bound asserts against it).
- 96 committed seeds (gen_fuzz_seeds.py) + the campaign-found crash
  input promoted as datagram_codec/seed-900; grown corpora gitignored.
- run-detached.sh: setsid+nohup runner so campaigns never share fate
  with the agent session process tree (fuzzing.md §5).

Campaign findings this session were harness-model defects, not crate
bugs (both invisible to example-based review; the alkcall
"fuzzer as harness-checker" pattern confirmed):

- the F-2 empty-datagram invariant first counted [00 00] pairs in the
  raw stream — a pair inside a payload region is payload, not a
  header; corrected to walk the framing (crash artifact saved).
- establishment_reason is a pass-through extractor of details.reason,
  not a validated table (harness first asserted a closed set).
- is_mid_datagram means a partial datagram, not buffered header bytes
  (a 1-byte header fragment is buffered, not in flight).

Verification: cargo test 135 passed; clippy -D warnings and fmt clean
(main + fuzz/shared); corpus replay green; cargo fuzz build clean;
wasm32-unknown-unknown check clean. Wave-1 campaigns (10-min detached,
fork mode): params_parse 3.14M execs / datagram_codec 827k execs,
0 crashes/OOMs/timeouts; coverage saturated on the codec state space.
2026-09-28 08:09:11 +00:00
209 changed files with 8391 additions and 2566 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
+83 -67
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,63 +160,63 @@ just spawned implementation agents.
backpressure; datagram-boundary preservation, if required, is a
wire-format decision for an ADR, not an accident.
11. **Vendored core types come from alkcall** — `Connection`,
11. **Vendored core types come from alkcall**: `Connection`,
`ProtocolHandler`, `BiStream`, `BidiStreamSource`, `AuthContext`,
`Identity`, `IdentityProvider`, `AccessControl`,
`OwnershipProvider`, `HandlerError`, `StreamError` come from
`alkcall::core`. Do not vendor copies into this crate. alkcall is
v0.7.x — breaking changes are expected at this major-zero stage;
v0.10.x, where breaking changes are expected at this major-zero stage;
this is an early consumer, so we find and fix issues upstream
rather than working around them. Pin `alkcall = "0.7.0"` and bump
rather than working around them. Pin `alkcall = "0.10.0"` and bump
deliberately. The 0.6.0 establishment surface (ADR-049 + amendment
2) is load-bearing for this crate: tunnel opens use
`register_openable_with_establisher` so a refused target dial is
a typed `channel:open_failed` call error, never a phantom channel,
and the establisher returns the dialed handle via
`Establishment::new(plan)` (typed-opaque `ChannelPlan` — its
`Establishment::new(plan)` (typed-opaque `ChannelPlan`, whose
`Send + Sync` bound constrains plan payloads; socket handles carry
`+ Sync`) — no side-channel handoff. The 0.7.0 identity surface
`+ Sync`); no side-channel handoff. The 0.7.0 identity surface
(ledger CF-005/CF-006) is load-bearing for the serving side: a
connect-side serving op resolves the caller identity in
precedence order — payload `auth_token` >
precedence order (payload `auth_token` >
`ServingConfig.identity` > transport identity (set via
`Connection::set_identity` before dialing) — and the establisher/
`Connection::set_identity` before dialing), and the establisher/
pump handler receive the per-call opener identity, not the
install-time context.
12. **BAST document for the wire format** — when the tunnel wire format
12. **BAST document for the wire format**: when the tunnel wire format
gains binary framing (if any beyond pass-through), it carries a
BAST (Binary Abstract Syntax Tree) document as its machine-readable
spec under `docs/architecture/`, conforming to the BAST meta-schema
at `https://alk.dev/bast/v1/schema`. BAST is plain JSON — no
at `https://alk.dev/bast/v1/schema`. BAST is plain JSON; no
dependency required to author or consume it. alktunnels does not
depend on alktype; hand-rolled codecs are fine for trivial formats,
with the BAST doc as the contract. Do not roll your own offset map
or validator for complex formats — use alktype (as an optional dep)
or validator for complex formats: use alktype (as an optional dep)
when that surfaces.
13. **Access control** — the producer's openable channels get
13. **Access control**: the producer's openable channels get
authorization for free via `ChannelCore::register_openable`, which
wires `AccessControl` into the operation spec (the registry runs
the ACL before the open handler). Scope-gate tunnel opens (e.g.
`TUNNEL_OPEN_SCOPE`, shape following alktty's `TTY_OPEN_SCOPE`) and
optionally consult `OwnershipProvider` for resource ownership
(`provider.owns(id_ref, kind, &id, "tunnel")` — the 4-arg shape;
(`provider.owns(id_ref, kind, &id, "tunnel")`, the 4-arg shape;
`OwnershipStore::record` is the 3-arg shape). Tunnels reach local
networks — treat the open gate as the security boundary. See
networks; treat the open gate as the security boundary. See
alknet ADR-024 (registry layering, alkcall ADR-019), alknet
ADR-050 (ownership, alkcall ADR-011).
14. **Feature flags** — substrate backends may be feature-gated if the
14. **Feature flags**: substrate backends may be feature-gated if the
need arises. The base crate should compile lean (no socket/platform
deps unless the feature is on). Verify both `cargo test` (default)
and `cargo test --all-features` pass if features are added.
15. **Naming** — Rust standard: `snake_case` for functions/variables/
15. **Naming**: Rust standard, `snake_case` for functions/variables/
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
constants.
16. **Module structure** — one module per file under `src/`, re-exported
16. **Module structure**: one module per file under `src/`, re-exported
from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The
shape is pinned by the architecture spec (ADR-004, ADR-005): a
producer half (adapter / open-handler for `alk/tunnel`-family
@@ -225,12 +225,28 @@ just spawned implementation agents.
modules are feature-gated and never imported from the
shared/producer/consumer modules.
17. **ALPN naming** — this crate owns the `alk/tunnel`-family ALPN(s).
17. **ALPN naming**: this crate owns the `alk/tunnel`-family ALPN(s).
One ALPN per protocol per alkcall ADR-004; keep the `alk/` prefix
convention. If multiple tunnel flavors need distinct ALPNs (e.g.
stream vs datagram), decide via ADR before the first consumer —
stream vs datagram), decide via ADR before the first consumer;
ALPN strings are wire-stable once published.
18. **No em dashes in prose.** Do not use the em dash (U+2014) in
source doc comments, markdown docs (including `docs/` and `tasks/`),
agent prompts under `.opencode/agents/`, or commit messages.
Restructure instead: split the sentence, use parentheses, commas, a
colon, or a semicolon, whichever reads naturally, and vary the
choice rather than converting every instance the same way. A spaced
hyphen (` - `) is not an acceptable substitute; it reads as a
different but equally recognizable writing tell. Inside fenced code
blocks the rule applies to prose-y content only: line comments,
ASCII-diagram label separators, and template placeholders are
rewritten too, while wire data, JSON/BAST examples, and tool
transcripts survive verbatim. The same rule covers en dashes used as
clause separators, though en dashes inside numeric ranges keep their
normal meaning. Hyphens in compound words (`wasm-clean`,
`tty-wire`, `alk/tunnel`) are untouched.
## Verification Commands
Run these before committing. All must pass.
@@ -247,70 +263,70 @@ cargo publish --dry-run --allow-dirty # before a release
```
The wasm check is the structural guard for the "default crate is
wasm-clean" invariant (same as alktty) — run it whenever a
wasm-clean" invariant (same as alktty), and run it whenever a
non-backend module changes.
## Architecture Context
- `docs/architecture/` — the authoritative spec (Phase 1 of the SDD
- `docs/architecture/` holds the authoritative spec (Phase 1 of the SDD
process, complete): overview/producer/consumer/wire spec docs, ADRs
001..006 (all Accepted), the wire-format BAST document, and
`open-questions.md` (the authoritative OQ tracker). Read it before
non-trivial changes. Phase 0 research remains in `docs/research/`
(its OQ ledger is superseded by the tracker).
- The prior art for this crate:
- **alknet-channels POC** —
- **alknet-channels POC**, with its summary at
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
(§POC Target 3: tunnel handler). Validates the demux→Connection→
handler→mux path and the two-pump tunnel shape over a stand-in
transport. POC code at `/workspace/alknet-channels-poc/`.
- **alkcall** — `/workspace/@alkdev/alkcall` (v0.7.x, crates.io).
- **alkcall** (`/workspace/@alkdev/alkcall`, v0.10.x, crates.io).
The substrate: call protocol + channels multiplexing. The tunnel
crate consumes `alkcall::core` types and the channels
`ChannelCore`/`ChannelClient`/`register_openable` surface.
- **alktty** — `/workspace/@alkdev/alktty` (v0.1, crates.io). The
- **alktty**, found at `/workspace/@alkdev/alktty` (v0.1, crates.io). The
sibling pattern to follow: a producer/consumer protocol crate on
alkcall channels, with the backend inversion point, wasm-clean
default crate, and feature-gated local backend.
- Key upstream ADRs that inform this crate's design (alknet numbers;
alkcall's ports live in `/workspace/@alkdev/alkcall/docs/architecture/
decisions/`):
- alknet ADR-093 / alkcall ADR-035 — channels pure channel
- alknet ADR-093 / alkcall ADR-035: channels pure channel
multiplexing (8-byte header, no `stream_type`); the tunnel payload
is raw bytes inside the `BiStream`
- alknet ADR-078 / alkcall ADR-050 — two-pump shutdown-on-completion
- alknet ADR-078 / alkcall ADR-050: two-pump shutdown-on-completion
(the tunnel handler pattern); the helper is pinned upstream as
`alkcall::channels::pump_bidi` — use it, do not hand-roll
- alkcall ADR-049 (amendment 2) — the establishment phase; the
`alkcall::channels::pump_bidi` (use it, do not hand-roll)
- alkcall ADR-049 (amendment 2): the establishment phase; the
establisher returns the dialed handle via `Establishment::new`
(typed-opaque `ChannelPlan`), replacing the POC's side-channel
handoff
- alknet ADR-074 / alkcall ADR-038 — `ChannelConnection` as a
- alknet ADR-074 / alkcall ADR-038: `ChannelConnection` as a
`BidiStreamSource`; every handler (TTY, tunnel, call) receives a
`Connection`
- alknet ADR-075 / alkcall ADR-039 — `ChannelsAdapter` /
- alknet ADR-075 / alkcall ADR-039: `ChannelsAdapter` /
`ChannelManager` (substrate-agnostic demux; `params` is
ALPN-specific — for tunnels, the target resource)
- alknet ADR-071 / alkcall ADR-034 — channels wire format (one-way
ALPN-specific, i.e. for tunnels the target resource)
- alknet ADR-071 / alkcall ADR-034: channels wire format (one-way
door)
- alkcall ADR-037 — channel lifecycle operations (`channel/open` on
- alkcall ADR-037: channel lifecycle operations (`channel/open` on
channel 0; `params` carries the tunnel target)
- alkcall ADR-042 — hub relay (byte-for-byte data-channel forwarding
with ID rewrite — tunnels traverse relays transparently)
- alkcall ledger CF-005/CF-006 (0.7.0) — the connect-side serving
- alkcall ADR-042: hub relay (byte-for-byte data-channel forwarding
with ID rewrite, so tunnels traverse relays transparently)
- alkcall ledger CF-005/CF-006 (0.7.0), the connect-side serving
identity seam: caller identity precedence (token >
`ServingConfig.identity` > transport), per-call opener identity
on the open-op hooks (the reverse-flow POC's W1, resolved)
- alknet ADR-085 — workspace scope: `alknet/tunnel` was flagged
- alknet ADR-085, workspace scope: `alknet/tunnel` was flagged
"POC-validated, minimal spec needed, not yet specced"; this crate
is that spec
- What the POCs settled (Phase 0 complete — 2026-09-07): both POCs
- What the POCs settled (Phase 0 complete as of 2026-09-07): both POCs
ran clean (forward UDP POC + reverse-flow POC; see
`docs/research/poc-summary.md` and `reverse-poc-summary.md`). Phase 1
is complete (2026-09-07): the OQ ledger is promoted, and the params,
ALPN, codec, and API-surface decisions are pinned by ADR-001..006
(including consumer-session teardown ownership — reverse POC W3).
(including consumer-session teardown ownership from reverse POC W3).
Phase 2 (implementation) is decomposed in `tasks/`.
- If a TODO references a design direction that an ADR has since decided
against, the TODO is stale — remove it and align with the ADR. Do not
against, the TODO is stale; remove it and align with the ADR. Do not
implement the rejected design.
+206 -48
View File
@@ -6,6 +6,159 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.5.0] - 2026-11-03
The correlation-gate release: the forwarded op's serving half gains an
establishment phase (review 003 W1 absorbed in-repo, ADR-008 Amendment
2), prompted by alkssh's consumer input on the composed `-R`
deployment's failure vocabulary. Additive public API only (the minor
bump, not a patch); the wire delta is empty and the base registration
stays byte-for-byte. The rest of the wave is doc-only: the em-dash
prose sweep across repo docs, spec docs, doc comments, and task files
(no code-path change), and review 003's doc trail closure (W2/W3 are
the sibling repos' adoption waves and remain booked there).
### Added
- **The forwarded-op establisher variant (`register_tunnel_forwarded_openable_with_establisher` + `ForwardedGate` + `ForwardedPlan`, ADR-008 Amendment 2)**: the serving side of `channels/tunnel/forwarded` gains an establishment phase: the assembly injects a correlation gate (`ForwardedGate`, a sync closure over the parsed `TunnelForwardedParams`) and the crate wraps it into the alkcall `OpenEstablisher` dance, so a stale/missing `listen` token fails the open as a typed `channel:open_failed` with `details.reason == "unknown_resource"` BEFORE any channel is born (no phantom channel, extending ADR-049's posture to the forwarded op's serving side; the wire vocabulary is unextended). An establisher parse failure is likewise typed (`handler_error`), an improvement over the base shape's success-then-silent-birth-teardown for malformed ingress. Gate order pinned: the registry runs the `tunnel:forwarded` ACL before the establisher (pinned by test with a gate-invocation witness). The base `register_tunnel_forwarded_openable` stays byte-for-byte compatible as the compatibility + residual-race-fallback posture (the narrow gate-pass→handler-run `correlation-miss` race keeps teardown-at-birth); the establisher variant is the recommended shape and new assemblies take it by default. The wire delta is empty. Five gate tests pin the typed miss/no-phantom/no-handler, the unchanged gate-pass serving flow (full byte round-trip), the residual-race reap, the scope-gate ordering, and the malformed-params delta.
## [0.4.1] - 2026-10-22
The dependency-patch release: the alkcall substrate bumped 0.10.0 →
0.10.1 (the adoption-window ordering fix). No alktunnels API change,
no wire change; the only file-level contacts with the bump are
`Cargo.toml`/`Cargo.lock`.
### Changed
- **alkcall 0.10.0 → 0.10.1** (crates.io). The bug-fix release
(review 013, routed from alkgit's wave-2 gate): the
`adopt_channel` install→drain window is now one atomic section, so
a chunk routed by the demux inside the window can no longer be
delivered ahead of earlier-parked early arrivals (the observed
downstream shape was pkt-line desync in alkgit's open-op
integration test under multi-process load), and a related loss
variant (a chunk parked after the drain removed the queue, parked
forever) is closed. Relevant in principle to this crate's
reverse-path adopt flow (the bind-first establisher surfaces
channels through the same adopt path), though no change in this
crate's own surfaces was needed. No API, wire-format
(`EventEnvelope`, the 8-byte chunk header), or documented-cap
behavior change upstream: the fix surface is private; the behavior
only strengthens. 136 tests pass; wasm-clean verified (check +
clippy on `wasm32-unknown-unknown`).
## [0.4.0] - 2026-10-19
The dependency-major release: the alkcall substrate bumped 0.9.0 →
0.10.0 (the gateway ownership-seam release). No alktunnels API
change, no wire change, and zero source changes; the only file-level
contacts with the bump are `Cargo.toml`/`Cargo.lock`. Minor rather
than patch for the 0.3.0 reason: the semver-incompatible dependency
bump plus this crate's public re-exports of alkcall types
(`ChannelOpenError`; `Establishment`/`EstablishmentError` shapes in
the producer API) make it semver-visible to downstream consumers.
### Changed
- **alkcall 0.9.0 → 0.10.0** (crates.io). The gateway
ownership-seam release (review 012): `GatewayDispatch` gains
`with_ownership_provider` / `with_static_ownership_fallback`, and
the previously-silent static fallback becomes loud: an unwired
spine refuses (an error envelope, `FORBIDDEN`, pre-dispatch) an op
whose spec carries `access_control.resource_type`. None of it is on
this crate's paths: alktunnels does not use the `gateway` feature
or `GatewayDispatch`; tunnel opens serve via
`ChannelCore::register_openable`/`register_openable_with_establisher`
registry dispatch on the native wire, which 0.10.0 leaves
untouched. All of this crate's alkcall contact points
(`register_openable`/`register_openable_with_establisher`,
`Establishment::new`, `OpenHandler`,
`ChannelClient::open_channel_with_reply`,
`ManagerError::ChannelExists`, `ChannelOpenError`) are unchanged
between 0.9.0 and 0.10.0. 136 tests pass; wasm-clean verified
(check + clippy on `wasm32-unknown-unknown`).
## [0.3.0] - 2026-10-14
The dependency-major release: the alkcall substrate bumped 0.8.1 →
0.9.0 (the stub-sweep release). No alktunnels API change, no wire
change; the only code contact with the 0.9.0 breaking surface was
test-side (`Establishment` no longer derives `Debug`; three
`expect_err`/`panic!` sites restructured), which is why this is a
minor bump rather than a patch: the semver-incompatible dependency
bump plus this crate's public re-exports of alkcall types make it
semver-visible to downstream consumers (matching the 0.7.1 → 0.8.0
ride on 0.2.0).
### Changed
- **alkcall 0.8.1 → 0.9.0** (crates.io). The stub-sweep release: the
marked-pub open contract (S-01), `channel/control` (S-02),
`channel/resources/subscribe` (S-03), and `op/register`
forward-through (S-04) replace the four always-failing stubs.
None of the four stubs were on this crate's paths (no marked-pub
registration, no control-handle subscription, no resource
enumerator use, no generic `op/register` announcements), so no
behavior change here. Of the 0.9.0 breaking list,
`Establishment` dropping its `Debug` derive was the only surface
this crate touched: two `bindfirst_establisher` unit tests'
`expect_err` calls and one integration test's untyped
`panic!("{other:?}")` fall-through restructured (the error type
itself remains `Debug`; the `Ok` arm now formats via `panic!("got
Ok")`). `ManagerError::InvalidAdopt` (additive),
`RelayError::PubTypedOpen` removal, and the
`register_imported`/`register_imported_all` return change are
untouched by this crate's API use. 136 tests pass; wasm-clean
verified.
## [0.2.1] - 2026-09-28
The hardening release: fuzzing adopted from the alkcall pattern
(`fuzz/` workspace, excluded from the published package) and the
alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change.
### Added
- **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)**: three
cargo-fuzz targets over this crate's own attacker-reachable surfaces
(the envelope/chunk/manager layer is upstream and fuzzed there per
alkcall 0.8.1): `datagram_codec` (the ADR-003 codec: no-panic,
infallible decode, size bound, F-2 sentinel layering, re-frame
round-trip, retained-bytes bound), `params_parse` (the serde
params/reply parsers: no-panic on raw bytes and bounded structured
values, clean typed failure, round-trips), and `session_pumps` (the
wave-2 stateful target: op sequences over a live channels stack per
exec, the `TunnelSession` data planes mirrored, no-wedge +
shutdown-on-completion + lossless p2c + EOF-sentinel teardown + the
`pending` backlog bound asserted). Invariant logic lives in
`fuzz/shared/`: a stable-toolchain crate replayed as plain
`cargo test`, so the committed corpora (96 + 8 seeds, plus the
campaign-promoted `seed-900`) stay executable without nightly.
`fuzz/` is excluded from the workspace and from the published
package. Campaigns (detached, fork mode): ~25M+228k execs across
waves 1–2, zero crashes/hangs/OOMs; all findings were
harness-model defects, no crate bugs.
- **`TunnelSession::datagram_backlog()`**: the UDP `pending` count
(the datagrams decoded from a chunk beyond the one returned), the
wave-2 harness's asserted backlog bound made caller-visible;
`None` for stream sessions or after the halves were taken.
- **`DatagramReader::retained_bytes()`**: the decoder-state byte
count (leftover buffer + the in-flight partial datagram); post-feed
bounded by one frame (≤ `MAX_DATAGRAM_LEN` + 2), pinned by the
`datagram_codec` target.
### Changed
- **alkcall 0.8.0 → 0.8.1** (crates.io). The bug-fix release: a
duplicate `adopt_channel`/`open_channel` no longer destroys the
live channel it collided with (found by alkcall's own
`manager_routing` fuzz target), directly relevant to this crate's
reverse-path adopt flow. No alktunnels API or wire change in this
bump; the full suite passes unchanged.
## [0.2.0] - 2026-09-20
The graduation release: the direct op + the bind-first/forwarded
@@ -14,98 +167,98 @@ alksocks (the SOCKS5 layer) and the planned `alknodes` assembly.
### Added
- **`TunnelListener` — the listen-flow consumer session (ADR-008 §4)** —
- **`TunnelListener`: the listen-flow consumer session (ADR-008 §4)**,
the listen-flow sibling of `TunnelSession`: `TunnelListener::open`
(rides `ChannelClient::open_channel_with_reply`, alkcall 0.8.0's
U-2 surface — the full reply arrives and the bind-first `bound`/
U-2 surface: the full reply arrives and the bind-first `bound`/
`listen` fields project through the lenient
`listen_reply_fields`; adopt-free construction — the client already
adopted the channel), `bound()`/`listen()` (the Option posture —
`listen_reply_fields`; adopt-free construction, since the client already
adopted the channel), `bound()`/`listen()` (the Option posture:
`None` for dial/accept-first producers and old producers), and the
listen channel's teardown ownership (ADR-005's posture extended to
the listen flow): `close` (the graceful EOF sentinel via
`shutdown`, then the reap) and `Drop` (the held write half drops →
the mux pump's implicit-EOF chunk → the far accept dispatcher's
teardown signal; sync reap — never leaks). The held `BiStream`'s
teardown signal; sync reap, so nothing leaks). The held `BiStream`'s
write half stays IDLE and private (a listen channel has no
data-plane role; pumping one is meaningless). No `Clone`
(compile-asserted). Forwarded sessions are NOT owned by the
listener — they arrive as ordinary adopted-side `TunnelSession`s on
listener; they arrive as ordinary adopted-side `TunnelSession`s on
the opener's manager (`adopt` gains nothing, pinned by test); the
assembly correlates them via the `listen()` token (the receipt
queue is assembly-side bookkeeping).
- **The `local` feature's bind-first substrate half (ADR-008 §1,
shape 3)** — the real-socket sources behind the committed
shape 3)**, the real-socket sources behind the committed
`BindFn`/`BindFirstAcceptLoop` contracts (behind `local`; the
default crate stays wasm-clean): `bind_tcp_first` (port 0 resolved
via `local_addr` — the `bound` reply field's data) +
`TcpListenerHalves::accept_pair` (one accept → `(peer, halves)` —
via `local_addr`, the `bound` reply field's data) +
`TcpListenerHalves::accept_pair` (one accept → `(peer, halves)`;
the peer rides `accept()`'s return, the forwarded op's `peer`
carrier); unix bind-first parity shipped (OQ-TN-14): `bind_unix` +
`UnixListenerHalves` (`local_addr`, the queue-feeding `accept_loop`,
the bind-first `accept_pair` — an unnamed (autobind) peer fails
the bind-first `accept_pair`, where an unnamed (autobind) peer fails
loud: the forwarded `peer` param requires an address) +
`bind_unix_first`; the assembly conveniences `local_bind_first()`
(tcp/unix dispatch; **udp fails loud — a UDP socket has no accept,
(tcp/unix dispatch; **udp fails loud: a UDP socket has no accept,
the bind-first shape is not defined there, never a fake listener**)
and `local_bindfirst_accept_loop()` (downcasts the plan's opaque
listener to the module's wrappers; a foreign listener type fails
loud as `handler_error`). The accept-FIRST shapes (`accept_loop` +
`AcceptQueue`) are untouched. The direct-op `DialFn` reuse is
verified + pinned: `local_dial` is shared unchanged (the UDP framed
wrap happens inside the closure — review 001 U-1); a
wrap happens inside the closure, review 001 U-1); a
`TunnelSession::open_direct` + `Substrate::Udp` test drives a REAL
UDP echo socket through the direct op's establisher path.
- **The forwarded op's SERVING side + the lenient reply projection
(ADR-008 §3 as amended)** — the listen-opener half, the receipt
(ADR-008 §3 as amended)**, the listen-opener half, the receipt
counterpart to the bind-first producer (the opener SERVES the op;
the producer CALLS it per accept): `ForwardedHandler` (the injected
assembly handler — the parsed params + the channel `BiStream`; the
assembly handler taking the parsed params + the channel `BiStream`; the
assembly owns the pump, since the local halves are assembly state)
+ `make_forwarded_op_handler` (the built-in wrapper: deny-unknown
params parse (a malformed open fails LOUD → the wrapper returns
early → teardown-at-birth reaps the channel — no phantom, the
early → teardown-at-birth reaps the channel; no phantom, the
correct shape for a malformed ingress), `accept_bi` FIRST (R-02
accepted-flag telemetry), then wrapper→assembly bridge, the
injected handler's `JoinHandle` awaited inline); +
`register_tunnel_forwarded_openable` (`register_openable` — no
`register_tunnel_forwarded_openable` (`register_openable`, so no
establisher, the data plane starts immediately; scope gate
`tunnel:forwarded`, ingress — a separate grant from
`tunnel:forwarded`, ingress; a separate grant from
`tunnel:open`/`tunnel:direct`, never implied by either). Plus
`listen_reply_fields` (the lenient, infallible session-surface
projection of the bind-first reply — unknown extra reply fields
projection of the bind-first reply: unknown extra reply fields
tolerated, a malformed shape logs loud and surfaces both-`None`;
shared with `TunnelListener`). Correlation-miss teardown semantics
documented + test-pinned: the assembly holds the map, its error
path drops the stream, the channel reaps (the producer's accept
loop drops the accepted connection on the open's failure — the
loop drops the accepted connection on the open's failure, and the
shapes mirror).
- **The bind-first establisher + accept dispatcher (shape 3, ADR-008
as amended)** — the `-R`/ingress establisher whose establishment
as amended)**, the `-R`/ingress establisher whose establishment
phase BINDS (the address-first-vs-accept-first deadlock dissolves;
the accept wait lives in the teardown-governed pump phase):
`BindFn` (the substrate bind function — the platform boundary, the
`BindFn` (the substrate bind function, the platform boundary; the
protocol never binds) + `BoundListener` (the opaque listener + the
real OS-chosen bound address); `bindfirst_establisher` (registry
namespace gate → token mint → bind → `bound` + `listen` reply
fields via `Establishment::with_reply_field` → the `BindFirstPlan`
plan payload; the token mint is the pinned discipline — non-zero-
plan payload; the token mint is the pinned discipline: non-zero-
seeded `AtomicU32`, per-open fresh, per-producer unique, token 0
never minted); `BindFirstAcceptLoop` (the type-erased accept
closure — receives the opaque listener, yields `(peer, halves)` per
accept); `ForwardedOpener` (the per-accept forwarded-open caller —
closure; it receives the opaque listener, yields `(peer, halves)` per
accept); `ForwardedOpener` (the per-accept forwarded-open caller,
the call plane: `call_with_payload` on channel 0 +
`manager.adopt_channel`, the reverse POC's shape);
`make_bindfirst_accept_handler` (the dispatcher: `accept_bi` first
per R-02, the idle listen-stream EOF cancels the loop and tears the
listener down, per-accept `pump_bidi` tasks tracked by the handler's
`JoinHandle` — a forwarded pump never outlives its listener;
forwarded-open failures are per-accept isolated — log + drop, the
`JoinHandle`, meaning a forwarded pump never outlives its listener;
forwarded-open failures are per-accept isolated: log + drop, the
loop continues, v1 no retry); `register_tunnel_bindfirst_openable`
(same spec + `tunnel:open` scope gate as the dial/listen variants —
(same spec + `tunnel:open` scope gate as the dial/listen variants,
the per-registration establisher posture; the injected seams ride
`BindFirstInjections {bind, accept_loop, open_forwarded}` + the
optional CF-006 identity witness). The opener-side forwarded-op
@@ -113,36 +266,36 @@ alksocks (the SOCKS5 layer) and the planned `alknodes` assembly.
`tunnels/forwarded-receipt`.
- **The forwarded op + the bind-first params surface (ADR-008 as
amended)** — the wire-stable types pin first (the ordering
amended)**: the wire-stable types pin first (the ordering
`tunnels/params` used for the base op): `OP_TUNNEL_FORWARDED` /
`TUNNEL_FORWARDED_SCOPE` constants; `TunnelForwardedParams
{listen, substrate, peer}` (deny-unknown; `listen` is the
producer-minted correlation token, positive by wire contract —
schema `minimum: 1` + a serde-level pin) with a per-substrate
producer-minted correlation token, positive by wire contract,
i.e. schema `minimum: 1` + a serde-level pin) with a per-substrate
`peer` sub-schema (malformed peers fail the registry's schema gate
before the opener-side handler); `tunnel_forwarded_spec()`
(`Sub`-typed, External, `alk/tunnel` ALPN marker, no establisher —
(`Sub`-typed, External, `alk/tunnel` ALPN marker, no establisher, so
the forwarded data plane starts immediately); `TunnelListenReply` +
`parse_listen_reply` (the typed projection of the bind-first
listen open's additive `bound`/`listen` reply fields — unknown
listen open's additive `bound`/`listen` reply fields: unknown
reply fields tolerated, malformed ones fail loud via the new
`TunnelError::MalformedReply`); `BindFirstPlan` (the opaque
assembly-typed listener + the token — the plan payload the
assembly-typed listener + the token, the plan payload the
bind-first establisher/accept-dispatcher task consumes).
- **The direct op (`channels/tunnel/direct`, ADR-007)** — dynamic-
- **The direct op (`channels/tunnel/direct`, ADR-007)**, dynamic-
target egress (the ssh `direct-tcpip` / SOCKS5 CONNECT shape),
riding the same `alk/tunnel` ALPN: `SubstrateAddr` (the untagged
substrate-shaped address object, also the forwarded op's `peer`
shape) + `TunnelDirectParams {substrate, target}` with a
per-substrate `input_schema` (malformed targets fail the registry's
schema gate — `INVALID_INPUT` — before the establisher),
schema gate as `INVALID_INPUT` before the establisher),
`tunnel_direct_spec()`, `direct_establisher` (+ witness variant;
no registry lookup — `unknown_resource` can never fire; targets
no registry lookup, so `unknown_resource` can never fire; targets
render to the same backing-string form the registry backings use
and ride the SAME injected `DialFn`), `register_tunnel_direct_
openable` (same pump handler + plan-flow as the base op), and
`TunnelSession::open_direct` (separate constructor — `tunnel:direct`
`TunnelSession::open_direct` (separate constructor; `tunnel:direct`
is a separate grant from `tunnel:open`, never implied by it; the
session type, data planes, and teardown API are identical). An old
producer rejects the unknown op loudly (`NOT_FOUND`).
@@ -152,20 +305,20 @@ alksocks (the SOCKS5 layer) and the planned `alknodes` assembly.
- **alkcall 0.7.1 → 0.8.0** (crates.io). The two upstream asks the
graduation ADRs depend on landed (alkcall review 008): the
establisher → reply projection (`Establishment::with_reply_field`,
ADR-049 amendment 3 — the bind-first `bound` carrier) and
flavor-form open-op ids in discovery (ADR-047 amendment 3 —
ADR-049 amendment 3, the bind-first `bound` carrier) and
flavor-form open-op ids in discovery (ADR-047 amendment 3:
`channels/tunnel/direct`-shaped ops reconstruct their
`channel_open` marker through discovery, so hub relay wraps them as
channels, not plain forwarding stubs). Plus the bonus:
`ChannelRelay` + `HubLegTemplate` (ADR-051) — the hub re-produce
`ChannelRelay` + `HubLegTemplate` (ADR-051), the hub re-produce
machinery the graduation's re-produce story assumed would be
assembly-layer is now in-tree, with `bound` flowing end-to-end
assembly-layer, is now in-tree, with `bound` flowing end-to-end
per-hop-truthful (ADR-051 §6, ADR-008 §6's sentence realized).
No alktunnels API or wire change in this bump; 71/91 tests pass,
wasm-clean verified.
- **`BindFirstPlan` gains `substrate: Substrate` (review 002 M-1
remediation)** — the accept dispatcher's silent
remediation)**; the accept dispatcher's silent
`unwrap_or(Substrate::Tcp)` fallback (the second
`TunnelParams` re-parse of wire input) is removed: the establisher
stamps the parsed substrate into the plan (one parse, one source
@@ -192,8 +345,8 @@ complete (Phase 2, review 001 findings remediated).
- **Data-plane codec** (ADR-003): raw pass-through for stream
substrates (`tcp`, `unix`); mandatory `[len: u16 BE]` boundary-
preserving framing for `udp` (`len = 0` a legal empty datagram,
never EOF; oversize rejected at frame time; truncation fails loud
— OQ-TN-13). The BAST contract: `docs/architecture/bast.md`.
never EOF; oversize rejected at frame time; truncation fails loud per
OQ-TN-13). The BAST contract: `docs/architecture/bast.md`.
- **Producer half** (ADR-004): `ResourceRegistry`,
`tunnel_establisher` (dial shape) and `listen_establisher` (the
`-R` far-side accept shape) over alkcall's establishment surface
@@ -201,7 +354,7 @@ complete (Phase 2, review 001 findings remediated).
contract, alkcall ADR-050), and the
`register_tunnel_openable` / `register_tunnel_listen_openable`
registration helpers.
- **Consumer half** (ADR-005): `TunnelSession` — `open` (forward) /
- **Consumer half** (ADR-005): `TunnelSession` with `open` (forward) /
`adopt` (reverse), substrate-shaped data planes (`stream_halves`,
`take_halves`, `send_datagram`/`recv_datagram`), pump ownership
(`pump_against`), teardown ownership (`close`/`join`/`Drop`);
@@ -209,9 +362,9 @@ complete (Phase 2, review 001 findings remediated).
- **`local` feature** (non-wasm): `dial_tcp`, `bind_tcp` +
`TcpListenerHalves` accept loop, `connect_udp` wrapped in the
FRAMED `UdpHalf` adapter (the codec composes at the substrate
boundary — review 001 U-1), `dial_unix`, and the all-substrate
boundary, per review 001 U-1), `dial_unix`, and the all-substrate
`local_dial` closure. Stdio intentionally out of scope (alktty's
pipe mode owns process stdio — OQ-TN-14).
pipe mode owns process stdio; OQ-TN-14).
- **Identity seam** (alkcall 0.7.0 CF-005/CF-006): payload-token >
`ServingConfig.identity` > transport identity precedence proven
end-to-end; the per-call opener identity reaches the establisher.
@@ -221,6 +374,11 @@ complete (Phase 2, review 001 findings remediated).
matrix). Wasm-clean default crate verified (`wasm32-unknown-unknown`
check + clippy).
[Unreleased]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.0...HEAD
[Unreleased]: https://git.alk.dev/alkdev/alktunnels/compare/v0.5.0...HEAD
[0.5.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.4.1...v0.5.0
[0.4.1]: https://git.alk.dev/alkdev/alktunnels/compare/v0.4.0...v0.4.1
[0.4.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.3.0...v0.4.0
[0.3.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.1...v0.3.0
[0.2.1]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.0...v0.2.1
[0.2.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.alk.dev/alkdev/alktunnels/releases/tag/v0.1.0
Generated
+3 -3
View File
@@ -27,9 +27,9 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.8.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8695d47cee5b5a5fe68471f04a505bc257a7e7ed9e64d827de33ff83adf4db12"
checksum = "690d747f42baf385dc22cd120f79d43cf1ea239bc426675e0e1e225b57fc031a"
dependencies = [
"async-trait",
"bytes",
@@ -48,7 +48,7 @@ dependencies = [
[[package]]
name = "alktunnels"
version = "0.2.0"
version = "0.5.0"
dependencies = [
"alkcall",
"bytes",
+7 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "alktunnels"
version = "0.2.0"
version = "0.5.0"
edition = "2021"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
@@ -9,7 +9,11 @@ repository = "https://git.alk.dev/alkdev/alktunnels"
readme = "README.md"
keywords = ["tunnel", "proxy", "channels", "alkcall", "network"]
categories = ["network-programming", "asynchronous"]
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/research/", "docs/plans/", "tasks/", "docs/sdd_process.md"]
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/research/", "docs/plans/", "tasks/", "docs/sdd_process.md", "fuzz/"]
[workspace]
members = ["."]
exclude = ["fuzz"]
[lib]
name = "alktunnels"
@@ -19,7 +23,7 @@ default = []
local = ["tokio/net"]
[dependencies]
alkcall = "0.8.0"
alkcall = "0.10.1"
tokio = { version = "1", default-features = false, features = ["rt", "sync", "io-util", "macros", "time"] }
bytes = "1"
futures = "0.3"
+46 -53
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,36 +288,19 @@ datagrams. Truncation fails loud, never silent (OQ-TN-13).
### WASM target
The default crate (no features) compiles to `wasm32-unknown-unknown`
— the protocol layer for a sandboxed TS/Python adapter. The `local`
The default crate (no features) compiles to `wasm32-unknown-unknown`,
the protocol layer for a sandboxed TS/Python adapter. The `local`
feature (real sockets) needs a real OS and is non-wasm by design;
enabling it on wasm is a compile error.
## Version 0.2 — the graduation
0.2 rides alkcall 0.8.0 and graduates the proxy surface
(`docs/research/tunnels-graduation.md`, ADRs 007/008):
- **The direct op** (`channels/tunnel/direct`, scope `tunnel:direct`)
— dynamic-target egress; `TunnelSession::open_direct`.
- **The bind-first establisher + the forwarded op**
(`channels/tunnel/forwarded`, scope `tunnel:forwarded`) — the
composed `-R` metadata flow: `bound` + correlation token in the
listen reply, `{listen, substrate, peer}` per accept;
`TunnelListener` + `parse_listen_reply`/`listen_reply_fields`.
- **alkcall 0.7.1 → 0.8.0** — `Establishment::with_reply_field` +
`open_channel_with_reply` (the bind-first carriers) and
flavor-form op ids in discovery (hub relay wraps them as channels);
bonus: in-tree `ChannelRelay`/`HubLegTemplate` (alkcall ADR-051).
## Features
| Feature | Contents | wasm |
|---------|----------|------|
| *(default)* | params, wire codec, open-op specs (`sub`/`direct`/`forwarded`), establishers (dial + accept-first listen + bind-first), `TunnelSession`, `TunnelListener`, registration helpers — protocol only | yes |
| *(default)* | params, wire codec, open-op specs (`sub`/`direct`/`forwarded`), establishers (dial + accept-first listen + bind-first), `TunnelSession`, `TunnelListener`, registration helpers (protocol only) | yes |
| `local` | [`dial_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_tcp.html) / [`bind_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp.html) / [`connect_udp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.connect_udp.html) (the framed `UdpHalf`) / [`dial_unix`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_unix.html) / [`local_dial`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_dial.html), the TCP accept loop; the bind-first halves: [`bind_tcp_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp_first.html) / [`bind_unix_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_unix_first.html) + [`local_bind_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_bind_first.html) + [`local_bindfirst_accept_loop`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_bindfirst_accept_loop.html) | no |
Stdio is intentionally out of scope — a spawned process's stdio is
Stdio is intentionally out of scope: a spawned process's stdio is
alktty's pipe mode (exit codes + signals are tty semantics); remote
command execution composes via alktty (OQ-TN-14).
+25 -25
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
+43 -43
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)
├── alkcall 0.8.0 (core types, channels: ChannelCore/ChannelClient/manager,
│ pump_bidi, ServingConfig identity seam — CF-005/006,
alktunnels (default: wasm-clean)
├── alkcall 0.10.1 (core types, channels: ChannelCore/ChannelClient/manager,
│ pump_bidi, the ServingConfig identity seam (CF-005/006),
│ establisher reply projection + flavor-form discovery
│ derivation + ChannelRelay/HubLegTemplate — review 008/ADR-051)
│ derivation + ChannelRelay/HubLegTemplate (review 008/ADR-051))
├── tokio (wasm-clean subset: rt, sync, io-util, macros, time)
├── bytes, futures, serde/serde_json, thiserror, tracing, async-trait
└── (no backend deps — sockets/process live in feature-gated modules)
└── (no backend deps: sockets/process live in feature-gated modules)
alktunnels (local feature) — non-wasm by design
└── adds: tokio/net (TCP dial, UDP connect, unix sockets — unix per OQ-TN-14)
alktunnels (local feature): non-wasm by design
└── adds: tokio/net (TCP dial, UDP connect, unix sockets; unix per OQ-TN-14)
```
Same posture as alktty: the default crate is protocol-only and
wasm-clean (AGENTS.md convention 4); substrate I/O is feature-gated
(ADR-004). The wire format carries no alkcall-internal types
(alktty ADR-006's self-containment rule applies to the tunnel codec —
trivial here, since the codec is a 2-byte length prefix).
(alktty ADR-006's self-containment rule applies to the tunnel codec,
though it is trivial here, since the codec is a 2-byte length prefix).
## Feature Gates
| Feature | Contents | Wasm |
|---------|----------|------|
| *(default)* | params, wire codec, open-op spec, establisher shapes, `TunnelSession` — protocol only | yes |
| `local` | TCP/UDP/unix dial + listen helpers — halves-producing dial/listen functions (ADR-004). Unix ships (OQ-TN-14); stdio does NOT — a spawned process's stdio is alktty's pipe mode (exit codes, signals), not a tunnel | no |
| *(default)* | params, wire codec, open-op spec, establisher shapes, `TunnelSession` (protocol only) | yes |
| `local` | TCP/UDP/unix dial + listen helpers, i.e. halves-producing dial/listen functions (ADR-004). Unix ships (OQ-TN-14); stdio does NOT: a spawned process's stdio is alktty's pipe mode (exit codes, signals), not a tunnel | no |
## Module Map
```
src/
lib.rs — re-exports; crate docs
params.rs — TunnelParams {resource, substrate} + open-op input schema (ADR-001);
lib.rs:re-exports; crate docs
params.rs:TunnelParams {resource, substrate} + open-op input schema (ADR-001);
TunnelTarget {substrate, target} + direct-op schema (ADR-007);
TunnelForwardedParams {listen, substrate, peer} (ADR-008 as amended);
the three scope constants
wire.rs — the data-plane codec: frame_datagram / DatagramReader (ADR-003)
producer.rs — the open ops: tunnel_open_spec + direct/forwarded spec builders
wire.rs:the data-plane codec: frame_datagram / DatagramReader (ADR-003)
producer.rs:the open ops: tunnel_open_spec + direct/forwarded spec builders
(scope-gated), establisher shapes (dial + listen + bind-first),
pump handler (pump_bidi / accept dispatcher),
register_tunnel_*_openable (ADR-004/007/008 shapes)
consumer.rs — TunnelSession: open / open_direct / adopt / stream_halves /
consumer.rs:TunnelSession: open / open_direct / adopt / stream_halves /
take_halves / send_datagram / recv_datagram / pump_against /
close / join / Drop (ADR-005); TunnelListener: open / bound /
forwarded-receipt / close / Drop (ADR-008)
error.rs — TunnelError + the typed open-error surface (ADR-049 §4)
local/ — the `local` feature module: real socket halves functions
error.rs:TunnelError + the typed open-error surface (ADR-049 §4)
local/:the `local` feature module: real socket halves functions
```
Public API surface is `lib.rs` re-exports (AGENTS.md convention 16).
@@ -164,14 +164,14 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [001](decisions/001-open-params-layout.md) | Open-op params: resource + substrate | `{resource, substrate}` — the producer's stable name, not an address; wire-stable from the first consumer |
| [001](decisions/001-open-params-layout.md) | Open-op params: resource + substrate | `{resource, substrate}`, the producer's stable name (not an address); wire-stable from the first consumer |
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN | Substrate in params selects the framing; no ALPN split, no API bifurcation |
| [003](decisions/003-codec-and-udp-framing.md) | Data-plane codec | Raw pass-through (stream) / mandatory `[len: u16 BE]` (UDP — F-2 mandate); `len=0` is a legal empty datagram |
| [003](decisions/003-codec-and-udp-framing.md) | Data-plane codec | Raw pass-through (stream) / mandatory `[len: u16 BE]` (UDP; F-2 mandate); `len=0` is a legal empty datagram |
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend trait | Halves functions at the assembly layer; listen is an establisher shape; trait re-evaluated at the 3-consumer threshold |
| [005](decisions/005-consumer-session-owns-teardown.md) | Consumer session owns teardown | `TunnelSession` with close/join/Drop — the W3 gap closes structurally |
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1; amended — `tunnel:direct`/`tunnel:forwarded` scopes + the hub re-produce policy sentence |
| [007](decisions/007-direct-open-op.md) | The direct open op | `channels/tunnel/direct`: dynamic-target egress, `{substrate, target}` params, `tunnel:direct` scope, dial-only — the ssh `direct-tcpip` capability with structural ACL separation |
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen metadata + the forwarded op | Bind-first establisher (reply `bound` field = BND.ADDR fidelity; `listen` = the correlation token), `channels/tunnel/forwarded` accept-as-open (params carry `{listen, substrate, peer}` = reply#2 fidelity) — the `forwarded-tcpip` shape; amended — producer-minted correlation token supersedes the channel-ID key |
| [005](decisions/005-consumer-session-owns-teardown.md) | Consumer session owns teardown | `TunnelSession` with close/join/Drop; the W3 gap closes structurally |
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1; amended with the `tunnel:direct`/`tunnel:forwarded` scopes + the hub re-produce policy sentence |
| [007](decisions/007-direct-open-op.md) | The direct open op | `channels/tunnel/direct`: dynamic-target egress, `{substrate, target}` params, `tunnel:direct` scope, dial-only (the ssh `direct-tcpip` capability with structural ACL separation) |
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen metadata + the forwarded op | Bind-first establisher (reply `bound` field = BND.ADDR fidelity; `listen` = the correlation token), `channels/tunnel/forwarded` accept-as-open (params carry `{listen, substrate, peer}` = reply#2 fidelity); this is the `forwarded-tcpip` shape; amended so that a producer-minted correlation token supersedes the channel-ID key |
## Open Questions
@@ -179,13 +179,13 @@ Open questions are tracked in [open-questions.md](open-questions.md).
Key questions affecting this document:
- **OQ-TN-11**: resource naming collision domain + lifecycle (partially resolved)
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope) —
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope);
the policy sentence is pinned: the hub's ACL governs the final dial)
- **OQ-TN-13**: UDP truncation semantics (resolved — fail-loud, ADR-003)
- **OQ-TN-14**: unix/stdio placement (resolved — unix in via
- **OQ-TN-13**: UDP truncation semantics (resolved, fail-loud, ADR-003)
- **OQ-TN-14**: unix/stdio placement (resolved; unix in via
`local`, stdio out: alktty's pipe mode owns process stdio)
- **OQ-TN-15**: the direct op (resolved — ADR-007)
- **OQ-TN-16**: listen metadata (resolved — ADR-008)
- **OQ-TN-15**: the direct op (resolved; ADR-007)
- **OQ-TN-16**: listen metadata (resolved; ADR-008)
## References
+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)
+596
View File
@@ -0,0 +1,596 @@
# Fuzzing alktunnels: Proposal and Plan
**Status:** adopted; wave-1 complete (`datagram_codec` +
`params_parse` implemented, seeds committed, two 10-min campaigns
clean, two harness-model findings fixed; see §7); wave-2
(`session_pumps`) implemented and campaigning (see §3.3/§7).
Campaigns run via the detached runner (§6), **never as a foreground
child of an agent session** (§5).
**Date:** 2026-09-28
**Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
(0.8.0 to 0.8.1 campaign; one real finding: duplicate adopt/open destroying
the live channel, fixed in 0.8.1). Tooling, corpus policy, runner, and the
no-hosted-CI posture are inherited from that doc wholesale: this doc does
not re-litigate them; it scopes them to this crate.
---
## 1. Should this crate be fuzzed?
**Yes, but scoped smaller than alkcall's.** The decision follows from
three lines, all pointing the same way:
1. **alkcall's fuzz doc already assigned this to us.** Its §5
"non-surfaces (deliberately)" note ends: *the channels layer carries
opaque `Bytes` and the handler owns sub-stream framing (ADR-035);
those downstream parsers are downstream crates' fuzz targets.* Our
`wire.rs` datagram codec IS that named downstream target. Fuzzing it
completes alkcall's fuzz scope rather than duplicating it.
2. **We own one hand-rolled parse surface of exactly the covered class.**
`DatagramReader::feed` (`src/wire.rs:100`) is an incremental
length-prefix state machine reassembling `[len: u16 BE][payload]`
frames across arbitrary chunk boundaries: length arithmetic and
boundary handling, the dominant rust-fuzz trophy-case classes. It is
sync, pure, allocation-light, and infallible **by contract** (the
decode direction cannot fail; oversize is rejected at frame time),
and a fuzz target is how that contract is *demonstrated* rather than
believed.
3. **The stateful DoS class applies to us directly.** One pre-fuzz
code-review candidate, the analog of alkcall §6.2-1 (the
early-arrival key-space): `DatagramHalves.pending`
(`src/consumer.rs:78`) queues every datagram decoded from a chunk
beyond the one returned to the caller. A single 16 MiB chunk of
`[00 00]` pairs = ~8.4 M empty datagrams → ~8.4 M `VecDeque`
entries (≈ 400+ MiB) from ONE peer chunk, a ~25–50× memory
amplification riding a bounded channel buffer. Example-based tests
structurally cannot find this; a byte-sequence target with a
pending-bytes assertion will.
**The honest caveat** (mirroring alkcall §1): the highest-value alkcall
targets (envelope framing, chunk header, demux, manager) are upstream
and now fuzzed (0.8.1). Our params parsing is serde + registry-side
jsonschema (upstream's `spec_parse` target covers attacker-shaped
schemas compiling at registration). Expected yield here: datagram-codec
pathologies, the pending-queue bound (§1.3), serde/untagged-enum edge
cases in params, and lifecycle invariants in the two-pump shape. That
is worthwhile, not five-targets-worth of new machinery on day one,
hence the two-wave scope in §4. If the first campaigns find nothing,
that is the successful-negative-result posture alkcall's §1 codified:
"we think the codec is robust" becomes a demonstrated property, and the
targets become regression protection for a wire-stable format
(ADR-003).
## 2. Toolchain (inherited, not re-decided)
Identical to alkcall §4: **cargo-fuzz 0.13.2 + `arbitrary` 1.4.x**,
nightly confined to the `fuzz/` subtree via `fuzz/rust-toolchain.toml`,
the crate itself stays stable at MSRV 1.88. AFL optional/deferred;
bolero deferred (same async-friction argument); libafl/honggfuzz/OSS-Fuzz
not adopted. Sanitizer: `--sanitizer=address` with debug assertions
(cargo-fuzz defaults); no MSAN (no `unsafe` in this crate).
**Layout (the alkcall pattern, load-bearing):**
```
fuzz/
├── Cargo.toml alktunnels-fuzz (nightly-only bins; own [workspace])
├── rust-toolchain.toml pins nightly for this subtree only
├── fuzz_targets/ thin fuzz_target! wrappers (3-line each)
│ ├── datagram_codec.rs
│ ├── params_parse.rs
│ └── session_pumps.rs
├── shared/ alktunnels-fuzz-shared, the STABLE-toolchain library
│ └── src/… invariant logic + corpus replay (§3)
├── corpus/<target>/ committed hand-made seeds (§3)
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
├── run-detached.sh §6 detached runner (CWD-independent)
└── README.md
```
- **Invariant logic lives in `fuzz/shared/`**, not inline in the target
binaries: the stable-toolchain corpus replay
(`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME
invariant functions the fuzzer runs, on stable, in the release
verification checklist. This is CI tier-3's deliverable (alkcall
§7.2) minus automation; per alkcall §7.9 this repo has no hosted CI
and will not get one (same gitea posture); corpus replay as plain
`cargo test` is the fuzz gate.
- **Root `Cargo.toml` needs the explicit `[workspace]` table**
(`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the
publish exclude list; without it cargo auto-discovers `fuzz/shared/`
into the main workspace and the MSRV toolchain tries to build the
nightly-consumed dev-deps (the exact failure alkcall hit).
- **Corpus policy:** commit hand-made seeds, gitignore grown corpora
(quinn/h2 model). Seeds per target: valid frames, truncation at every
short prefix length, `len = 0` / `len = MAX` / `len = MAX+1`, empty
datagrams, batching (two frames per chunk), splitting (one frame per
many chunks), and for params: every substrate value, unknown
substrate, unknown/missing fields, `listen: 0`, malformed reply
shapes, untagged cross-shape targets, deep/invalid JSON.
## 3. Invariants by target
### 3.1 `datagram_codec` (wave 1)
Input: raw `&[u8]` (and in the shared logic, chunk-slice parameter
arbitrary). Drives `DatagramReader::feed` + `frame_datagram`
(`src/wire.rs`). Invariants:
- **no-panic on any byte sequence** (the baseline; the one
`unreachable!` in `feed` is a structural invariant of the state
machine; the target is how it stays true).
- **infallibility**: `feed` never errors (contract: oversize is
rejected at frame time; the decode direction is infallible by
design, `src/wire.rs` docs).
- **datagram-size bound**: every yielded datagram is
`<= MAX_DATAGRAM_LEN` (u16 frame field makes this structural; the
target pins it against future refactors).
- **no partial yields**: a datagram completes only when exactly
`declared` payload bytes are buffered (`is_mid_datagram` observable
consistency); `feed(&[])` never yields anything and never clears
mid-datagram state.
- **round-trip**: `feed(frame_datagram(x)) == [x]` for any x; the
reverse direction `write(feed-parse(x))` re-frames to the same
frame for any well-framed input.
- **F-2 sentinel layering**: `len == 0` is a legal empty datagram,
never EOF; the codec never emits zero-length datagrams for non-empty
frames and never treats an empty datagram as stream end.
- **consumption accounting**: re-framing all consumed bytes and
feeding the byte-level remainder yields the same datagram stream
(splitting a feed at any boundary is transparent).
- **oversize frame rejection**: `frame_datagram` errors iff payload >
`MAX_DATAGRAM_LEN`, echoing the sizes.
- **pending-queue bound (the §1.3 finding-class assertion)**: total
bytes retained across `buf` + `current` + `pending` after a feed is
bounded by the fed chunk size plus the one in-flight frame, so
an adversarial peer chunk cannot force unbounded queue growth in
the decoder state. (NOTE: this is the *decoder-state* bound; the
consumer-side `DatagramHalves.pending` backlog between chunk-read
and `recv_datagram` pulls is a session-shape question tracked in
§4/§8 (the wave-2 stateful target, or a code fix if the bound is
judged unacceptable. The fuzzer documents the amplification factor;
the remediation decision is ours. **Wave-2 resolution (§3.3):** the
session-shape backlog is now an asserted invariant: the harness's
per-op drain holds the pending queue to the routed-batch remainder
(≤ one chunk's worth of batched datagrams, drained by
`recv_datagram` pulls), so the amplification question is bounded
by construction + assertion rather than remediated; the
`datagram_backlog()` diagnostic exposes the live count.)
### 3.2 `params_parse` (wave 1)
Input: raw bytes → `serde_json` (with alkcall's bounded
`arbitrary_value` approach, depth/width-capped `Value` generation) →
`TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` /
`SubstrateAddr` deserialization + `parse_listen_reply` /
`listen_reply_fields` (`src/params.rs`). Invariants:
- **no-panic on any JSON-shaped input** (untagged-enum serde dispatch,
`deny_unknown_fields`, the `positive_u32` pin).
- **clean failure only**: parse failures are `Err`, never panics;
`parse_listen_reply` returns `TunnelError::MalformedReply` on
malformed shapes.
- **round-trip for valid params**: `from_json(to_json(p)) == p` for
constructed params of every substrate.
- **deny-unknown posture**: unknown top-level fields reject;
untagged-variant leniency (extra fields inside a `SubstrateAddr`
variant) is the documented asymmetry (asserted, not "fixed").
- **the `listen: 0` pin**: zero rejects at serde level.
- **projection agreement**: `listen_reply_fields` yields both-`Some`
iff `parse_listen_reply` is `Ok`; it never panics and never refuses
construction (the lenient view projects malformed to both-`None`).
- **establishment-reason surface**: `establishment_reason` never
panics on arbitrary `ChannelOpenError` shapes.
### 3.3 `session_pumps` (wave 2)
Input: op sequences (`#[derive(Arbitrary)]` `SessionSequence`, the
`manager_routing` pattern) over one live channels stack per exec:
MuxRunner → raw chunk stream → the real `run_demux_loop_for_client`
demux → `ChannelManager` per side (producer Connect / consumer
Accept over one duplex transport, the reverse POC's topology). Ops:
`Route` (producer→consumer framed datagrams / raw stream bytes via
`route_payload`), `AdoptDatagram` / `AdoptStream` (the
`TunnelSession` data-plane mirrors: codec reader + `pending`, and
`pump_bidi` against in-process peer halves), `Pump` (peer→channel;
`len == 0` drops the p2c source, the EOF path), `Teardown`,
`ClearAll`. Invariants:
- **no-panic / no-wedge on any op sequence** (the baseline; the
harness's 5 s reconcile bound turns a wedged pump into a finding,
and §5's `-timeout=25` fork containment is the outer net).
- **two-pump shutdown-on-completion (convention 8)**: after the p2c
source drops, the pump's channel-side shutdown must propagate and
the pump must complete once the teardown EOFs the c2p source,
pinned by the `reconcile` join with the 5 s bound.
- **lossless p2c copy**: the pump's `p2c` count equals exactly what
the harness pushed into the peer half (copy never truncates).
- **EOF-sentinel teardown semantics**: after a sentinel (teardown /
`clear_all`), the reassembled stream drains its queue before
reporting EOF, with every routed datagram consumable and nothing
lost;
the consumer map empties; the codec's `len == 0` sentinel layering
holds at the session layer (a routed empty datagram is data; the
channels EOF is a separate mechanism).
- **FIFO reconciliation**: every complete frame routed through the
real demux path arrives as exactly one datagram of the declared
length, in order (the model walks the framing as the codec does,
the wave-1 F-2 walk reused at the session layer).
- **pending-queue bound (the §1.3 finding-class assertion)**: the
session-shape `DatagramHalves.pending` backlog never exceeds the
unrouted remainder (i.e. the backlog holds only datagrams decoded
from routed chunks beyond the ones consumed; with the harness's
per-op drain the queue returns to zero after every op, so an
amplification factor > 1× chunk size would trip the assert. (The
wave-1 decoder-state bound re-asserted per op alongside.)
- **monotonicity**: the manager's early-arrival / dropped counters
survive `clear_all` (the alkcall §7.7 pattern), and the consumer
map empties on the transport-EOF path.
Bounds: 128 ops, ≤ 512-byte payloads (generator-clamped; the codec's
`MAX_DATAGRAM_LEN` stays the wire bound). Even-id masking (ADR-047
§5: the consumer is the Accept side; the producer allocates even).
## 4. Scope: two waves
**Wave 1 (complete):** the two targets above. Both drive already-
`pub` APIs, with no `#[cfg(fuzzing)]` exposure needed (matching alkcall's
experience where none of five targets needed it).
**Wave 2 (this session):** `session_pumps`, the stateful op-sequence
target over the two-pump tunnel shape in the alkcall `manager_routing`
style (`#[derive(Arbitrary)]` op enum; live mux + demux runners; the
alkcall harness lessons applied: even-id masking, drained-state
asserts). Drives: the `TunnelSession` data-plane shapes (the datagram
codec plane + the `pump_bidi` stream plane) over a REAL channels
stack: the two-pump shutdown-on-completion contract (AGENTS.md
convention 8), the EOF-sentinel teardown semantics, and the §1.3
backlog bound as byte-accounting invariants (§3.3). The
`forwarded_routing` correlation target stays deferred until the
assembly-side correlation registry (ADR-008 §3, assembly
bookkeeping) grows into this crate.
Sequencing mirrors alkcall §7.4: init + wave-1 targets → 10–30 min
campaigns per target via the detached runner → fix anything found →
wave 2 (`session_pumps`) → further targets when justified.
## 5. Operational isolation: campaigns never share fate with the agent session
**This is an environment constraint, not a nice-to-have** (upstream
doc §7.6, adopted verbatim as policy here). Agent sessions (opencode)
run fuzzer builds/campaigns through a bash tool that spawns the fuzzer
as a **child of the session's own process tree and cgroup**. A fuzz
run is precisely the workload shape that kills its own host:
- **OOM-killer shared fate**: the target's worst case IS unbounded
allocation. If libFuzzer's soft RSS guard loses the race with the
kernel, the OOM killer fires, and it picks the largest-RSS process
in the cgroup, which can be the opencode server hosting the session.
That is *recoverable* (sessions are recorded in a DB) but hugely
derailing, since
the session's live environment access dies mid-task.
An OOM in a fuzz target must cost the fuzzer, never the session.
- **Fork bombs / CPU saturation**: `-fork=N` workers; a runaway
campaign starves the session host.
- **Wedged targets**: a deadlock in an async harness would hang a
foreground bash call forever.
Three layers of defense, cheapest first, **all mandatory**:
1. **libFuzzer soft limits (always on)**: `-rss_limit_mb=2048`
`-malloc_limit_mb=2048` make libFuzzer exit cleanly (artifact
recorded) before the kernel gets involved. Note the RSS limit is
*soft* (polls `/proc/self/statm`); a fast huge allocation can beat
it, and that is
what layers 2–3 are for. Never `ulimit -v` with ASAN
(it reserves terabytes of virtual address space); the ASAN-safe
hard-limit recipe is `-fork=1`.
2. **Fork mode as blast-radius containment (default)**: `-fork=1`
(or a small number) runs each input in a short-lived child, so a
crash/timeout/OOM/leak kills only that child; the harness survives,
records the artifact, keeps fuzzing. Also the deadlock answer: the
per-input timeout kills the child, not the session's bash call.
3. **Process detachment from the session tree (MANDATORY for agent-run
campaigns)**: never run fuzzing as a foreground child of the
session. The runner script wraps `cargo fuzz run` in `setsid` +
`nohup` + full redirection to a log file, which (a) removes the
fuzzer
from the session's controlling terminal and signal relation, (b)
survives the session ending, (c) gives the agent a pollable log +
artifact directory instead of a blocking call. Combined with fork
mode, an OOM-class finding costs one child process.
**The runner** (`fuzz/run-detached.sh`, CWD-independent, since it
resolves its
own repo root):
```bash
#!/usr/bin/env bash
# Detached fuzzing runner for agent sessions: the campaign never runs
# as a foreground child of the session (an OOM in a target must cost
# the fuzzer, never the agent host), and survives the session ending.
set -euo pipefail
target="${1:?usage: run-detached.sh <target> [extra libfuzzer args...]}"
shift
runtime="${FUZZ_RUNTIME_SECS:-600}"
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
log_dir="$root/fuzz/artifacts"
mkdir -p "$log_dir"
log="$log_dir/${target}-$(date -u +%Y%m%d-%H%M%S).log"
cd "$root"
setsid nohup cargo fuzz run "$target" -- \
-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 \
-max_total_time="$runtime" "$@" \
>"$log" 2>&1 < /dev/null &
echo "pid=$! log=$log"
```
**Agent polling protocol** (never `wait` on the detached process from a
tool call; poll instead):
- `tail -n 50 fuzz/artifacts/<target>-*.log`: campaign progress.
- `ls fuzz/artifacts/`: `crash-*` / `oom-*` / `timeout-*` / `leak-*`
artifacts are the findings.
- `pgrep -f "cargo fuzz run <target>"`: still running?
- One detached campaign per target at a time; log filenames carry the
UTC timestamp.
**Flags worth pinning beyond the runner defaults**: `-max_len=65536`
on the codec target; `-use_value_profile=1`; a JSON token `-dict` for
the params target (alkcall's `json.dict` transfers as-is).
## 6. No hosted CI
Per alkcall §7.9, the standing policy applies to this repo identically:
no hosted CI will exist; do not add Actions/Gitea-Actions workflow
files. **Corpus replay is the fuzz gate as plain `cargo test`**:
`cargo test --manifest-path fuzz/shared/Cargo.toml` replays every
committed seed through the invariant functions; add it to the release
verification checklist (AGENTS.md). Campaigns run manually via the
detached runner (§6), e.g. before a release, or after touching
`src/wire.rs`, the consumer data planes, or `src/params.rs`.
## 7. Implementation log
- **2026-09-28, proposal adopted** (this doc). Wave-1 targets
`datagram_codec` + `params_parse`; wave-2 deferred (§4).
- **2026-09-28, `alkcall` bumped 0.8.0 → 0.8.1.** Bug-fix release, no
API/wire change; the fix (duplicate adopt/open no longer destroying
the live channel) is directly relevant to this crate's
`TunnelSession::adopt` path: the changelog's "open-op response
replayed / id re-announced" scenario is our reverse path. Verification:
full suite (135 passed), clippy, fmt, wasm check (§8 commands).
- **2026-09-28, wave-1 implemented.** `fuzz/` workspace laid out per
§2 (nightly-pinned subtree, stable `fuzz/shared` library, runner,
dict, generator); `datagram_codec` + `params_parse` targets over
already-`pub` APIs, with no `#[cfg(fuzzing)]` exposure needed. One new
public diagnostic: `DatagramReader::retained_bytes()` (the
decoder-state byte count the §3.1 bound asserts against, in the
`is_mid_datagram` diagnostic style). Seeds committed: 27 +
69 generated (fuzz/gen_fuzz_seeds.py) + 1 campaign-found crash
input promoted to `datagram_codec/seed-900` (see the finding below).
Stable side: corpus replay green, shared crate clippy/fmt clean;
main suite untouched and green; `cargo fuzz build` clean.
- **2026-09-28, first campaign finding: a HARNESS-model bug, not a
crate bug.** The `datagram_codec` campaign crashed in its first
minute on input `00 2b 01 00 00 05` (`fuzz/artifacts/datagram_codec/
crash-e9ba284e…`, exit 77, fork-mode containment worked as
designed: the harness parent survived and persisted the artifact).
Analysis: the codec is CORRECT, since `00 2b` declares a 43-byte
datagram; `01 00 00 05` is its in-flight payload; nothing is
yielded. The invariant that tripped ("an empty datagram must come
only from a [00 00] length header") was mis-modeled: it counted
`00 00` byte pairs anywhere in the RAW stream, but a `00 00` pair
sitting INSIDE a frame's payload region is payload, not a length
header, which is exactly the same class as alkcall §7.7's probe (the
harness claimed `4 + len` when the prefix counts only the body).
Fix: the count now walks the framing as the decoder does (complete
frames from the start; the walk stops where the decoder would be
stuck mid-frame). The crash input is committed as seed-900 and
replays clean; the campaign relaunched clean.
- **2026-09-28, second harness finding (caught by corpus replay,
pre-campaign): `establishment_reason` is a pass-through extractor,
not a validated table.** The params.rs doc names the pinned reason
strings, but the upstream implementation (alkcall
`channels/client.rs` `establishment_reason`) extracts
`details.reason` AS-IS whenever the code is `channel:open_failed`,
so
a made-up reason is surfaced as-is by design, and validation is the
consumer's job. The harness first asserted a closed set of reason
strings and corpus replay tripped it on `made_up_reason` before any
campaign ran. Invariant corrected to assert the extraction shape
(Some iff code == `channel:open_failed` and reason is a string).
- **2026-09-28, second harness finding (same replay pass):
`is_mid_datagram` means a partial DATAGRAM, not buffered header
bytes.** A dangling 1-byte length prefix at input end is buffered
header state (no frame in flight, `is_mid_datagram == false`);
the harness first asserted `true` for that case. Corrected:
mid-datagram iff the framing walk leaves a remainder ≥ 2 bytes
(a parsed-but-unfinished payload), not `!= 0`.
- **2026-09-28, wave-1 campaigns run (10-min detached runs, §6 runner;
`-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25`;
codec with `-max_len=65536`, params with `-dict=json.dict
-use_value_profile=1`). Both exited 0.**
- `params_parse`: 3.14M execs (~5.3k exec/s), 33 fork jobs,
`oom/timeout/crash: 0/0/0` throughout. Coverage 2049 edges /
15.6k features; in-memory corpus 69 committed seeds → 3018
entries; the serde/untagged-enum shape space was still expanding
at budget end, so longer campaigns keep paying here.
- `datagram_codec` (post-harness-fix): 827k execs, 33 fork jobs,
`oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 221
edges (ft 1198); the 2-byte-header state machine is fully
enumerated, matching alkcall §7.7's `chunk_header` saturation
profile. Exec/s reads low (~400) because each exec replays the
input through a ~35-feed 3-byte-sliced sequence alongside the
whole-input feed; the state space is small and exhausted early.
- The pre-fix `datagram_codec` run (the §7 finding above) crashed at
job 4 and exited 77; fork-mode containment behaved as designed,
with the harness surviving, persisting the artifact, and the run
wrapping up. That artifact is the promoted seed-900.
- Grown corpora (3018 params entries) were **not** merged into the
committed seeds, since
per the §2 policy, grown corpora stay
gitignored.
- Operational note for future sessions: `cargo fuzz run` returns
before its last fork-mode children fully exit; a `pkill -f
"cargo fuzz run <target>"` followed immediately by another
`pgrep` of the same pattern can wedge a bash tool call on its own
process match. Prefer `pgrep -af` to LIST, kill by explicit pid
from the runner's echoed `pid=`, and never pattern-kill-and-recheck
in one call.
- **NO crate bugs found by wave 1.** The two findings this session
were both harness-model defects (§7 above), each invisible to
example-based review and caught by fuzzing/replay: the alkcall §7.7
"fuzzer as a harness-checker" pattern, confirmed. The §1.3
decoder-state bound held everywhere: `retained_bytes()` never
exceeded fed + one frame on any adversarial input. The session-shape
`DatagramHalves.pending` amplification remains a documented
§1.3/§4 note (wave-2's motivation), not a campaign finding, since
the
wave-1 targets assert the decoder, not the session pump.
- **2026-09-28, wave-2 implemented: `session_pumps` (the stateful
two-pump/EOF-sentinel target, §3.3).** The harness mirrors
`TunnelSession`'s data-plane shapes over one REAL channels stack
per exec (MuxRunner → raw chunk stream → the real
`run_demux_loop_for_client` demux → `ChannelManager` per side over
one duplex transport; producer Connect / consumer Accept, the
reverse POC's topology). Ops: Route/AdoptDatagram/AdoptStream/
Pump/Teardown/ClearAll; invariants per §3.3. One diagnostic
addition to the crate: `TunnelSession::datagram_backlog()` (the
`DatagramHalves.pending` count the §3.3 bound asserts against, in
the `retained_bytes()` diagnostic style) + a suite test pinning
its rest states. Seeds: 8 committed (deterministic decode-scan
hex patterns, the `manager_routing` precedent, since
arbitrary ships
no encoder) + 2 in-source semantic fixtures (the §1.3 batched-
backlog probe; the W4 half-close shape). Two harness-model
findings caught by corpus replay pre-campaign:
- **the p2c-EOF pump-join wedge is the DESIGNED half-close
shape, not a wedge.** The first `reconcile` draft awaited the
pump after dropping only the p2c source, but the c2p pump's
source (the channel read half) EOFs only via teardown, so the
join legitimately blocked (the 5 s bound tripped). `pump_bidi`
completes when BOTH pumps finish; the half-close contract is
that the p2c completion shuts the channel side down (the
observable), and the pump completes once the c2p source EOFs
too. The harness now models exactly that: drop p2c source →
teardown the entry → join with the 5 s bound. (The alkcall §7.7
harness-checker pattern again: the fuzzer harness's first
campaign-found crash in wave 1 and this replay catch are both
harness-model defects, not crate bugs.)
- **the stream-plane c2p accounting is asynchronous**: the pump
writes channel→peer into the peer duplex while a far-end reader
task counts; the reconcile join (after the pump completes) is
when the count is final. First draft read it early. Fixed by
reading after the reader task's join.
- **2026-09-28, third harness finding (campaign-found, fork-mode
containment as designed): duplicate adopt is a LEGAL manager
outcome, not a finding.** The first campaign crashed at ~49 s on
`AdoptDatagram { channel_id: 0 }` after the id had already been
adopted. `ChannelExists` is the manager's documented
id-collision error, and my adopt arms treated every error as a
finding. Fix: both adopt arms model the documented error table
(`ChannelExists` / `TooManyChannels` are no-ops, exactly the
alkcall `manager_routing` harness's treatment). The crash input
is committed as seed-900 and replays clean; the campaign
relaunched clean.
- **2026-09-28, fourth harness finding (second campaign): the
harness modeled the producer→consumer path through the WRONG
primitive.** The second campaign crashed at ~5 s on `Route`:
raw-id addressing (`even_id` masked arbitrary ids; collisions +
an `UnknownChannel`-class state the model didn't track), `len ==
0` raw routes doubling as EOF sentinels mid-model, and
(the structural one) `route_payload` was used as the producer's
forward write. `route_payload` routes into the LOCAL manager's
channel entry (the demux direction); the producer pump handler
writes the channel's SEND half (the mux pump frames onto the
transport). Bytes never reached the consumer. Rewrite (the real
protocol shape, matching `manager_routing`'s op-vocabulary
lesson): producer `Open` (`open_channel` allocates the id, the
harness holds the send half, the pump handler's write side),
session-INDEX addressing (no masking, no collisions), `Adopt`
consumes the producer-allocated id (the early-arrival drain is
the real adopt-race path), `Route` writes the producer send half
(the real wire path: mux pump → transport → consumer demux →
reassembly), `Pull` (the `recv_datagram` pull pops the backlog +
the model), and a STATEFUL framing model (partial frames persist
across ops, with the wave-1 walk carried across ops). EOF sentinels
now arise only from teardown/sender-drop (the real mechanisms),
never from `len == 0` routes (F-2: a routed empty datagram is
DATA). All committed session_pumps seeds regenerated by the
rewrite (the old hex patterns decode against the new op enum);
the semantic fixtures re-pinned. Verification of the fix: the
campaign's 0-exec crash went away; batched frames now reconcile
FIFO through the real two-manager stack.
- **2026-09-28, fifth + sixth harness findings (third campaign's
first minutes): the two ClearAll modeling defects.** (5) A
double-adopt of a live session panicked the harness's own guard,
but a re-adopt of a LIVE id is the manager's documented
`ChannelExists` outcome (a legal no-op; alkcall 0.8.1's
insert-replace fix leaves the live entry intact). Modeled as a
no-op. (6) A `ClearAll` followed by `Route` on the same session
tripped the backlog reconciliation. The harness retired only the
sessions whose ids appeared in `clear_all`'s RETURN value, but
that value is the OPENER-LEDGER records (ADR-047 §7): adopted
channels carry no ledger entry, so the set is empty while the map
itself empties. The alkcall §7.7 lesson ("`drained.len() ==
open_count()` is not an invariant"), learned again at this
crate's layer: the map-empties assert is the ground truth; the
harness now retires EVERY session on `ClearAll`.
- **2026-09-28, wave-2 campaign run (10-min detached run, §6
runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048
-timeout=25 -max_len=4096 -use_value_profile=1`): exited 0.**
228,676 execs (~380 exec/s; each exec replays up to 128 ops on a
live two-manager stack with real mux/demux tasks), 32 fork jobs,
`oom/timeout/crash: 0/0/0` throughout. Coverage 3427 edges /
17,529 features / 1184 in-memory corpus entries and STILL
EXPANDING at budget end (the op-interleaving space is much larger
than the parser targets'), so longer campaigns keep paying. The
two-pump contract held across every adversarial sequence: no
wedged pump (the 5 s reconcile join), no lost datagram (the
FIFO/backlog reconciliation), no premature EOF (the sentinel
layering). The §1.3 backlog bound held everywhere: the
`DatagramHalves.pending` queue never exceeded the
routed-but-unpulled remainder.
- **NO crate bugs found by wave 2.** All six findings across waves
1–2 were harness-model defects (four this session), each
invisible to example-based review and caught by fuzzing/replay:
the alkcall §7.7 "fuzzer as a harness-checker" pattern,
re-confirmed. The stateful thesis is validated from the harness
side: the harness defects themselves (double-adopt semantics, the
clear_all ledger/map distinction, the route_payload direction)
are exactly the interleaving-shape mistakes the alkcall §7.8
campaigns caught in their own drivers.
- (progress appended as implementation proceeds; see git log)
## 8. Verification commands
```bash
cargo test # main suite (stable)
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay (fuzz gate)
cargo fuzz build # in fuzz/ (nightly, ASAN)
fuzz/run-detached.sh <target> # campaigns (never foreground)
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo clippy --manifest-path fuzz/shared/Cargo.toml --all-targets -- -D warnings
cargo fmt --manifest-path fuzz/shared/Cargo.toml --check
cargo check --target wasm32-unknown-unknown # default crate stays wasm-clean
```
Campaign flags per target: `datagram_codec -max_len=65536`;
`params_parse -dict=json.dict -use_value_profile=1`;
`session_pumps -max_len=4096 -use_value_profile=1`.
## 9. References
- Upstream campaign + operational model (authoritative for tooling,
runner, corpus policy, no-CI posture):
`/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
- alkcall 0.8.1 changelog: the finding our adopt path is exposed to
- This crate: ADR-003 (codec framing), ADR-002 (ALPN), ADR-007/008
(direct/forwarded ops), `docs/architecture/wire.md`
- External: RUSTSEC-2026-0037 (quinn-proto, the maintainers'
fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust
Fuzz Book (all linked from the upstream doc's §9)
+161 -158
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
```
+10
View File
@@ -0,0 +1,10 @@
target
corpus/*
!corpus/datagram_codec
!corpus/params_parse
!corpus/session_pumps
# Grown corpus entries (libFuzzer hash-named) are gitignored; only the
# hand-made seed-* files are committed (the quinn/h2 policy).
corpus/*/[0-9a-f]*
artifacts
coverage
+1297
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "alktunnels-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
alktunnels-fuzz-shared = { path = "shared" }
[dependencies.alktunnels]
path = ".."
[[bin]]
name = "datagram_codec"
path = "fuzz_targets/datagram_codec.rs"
test = false
doc = false
bench = false
[[bin]]
name = "params_parse"
path = "fuzz_targets/params_parse.rs"
test = false
doc = false
bench = false
[[bin]]
name = "session_pumps"
path = "fuzz_targets/session_pumps.rs"
test = false
doc = false
bench = false
[workspace]
+58
View File
@@ -0,0 +1,58 @@
# alktunnels fuzzing
libFuzzer targets for alktunnels' untrusted-input surfaces (see
`docs/research/fuzzing.md` for the full rationale, scope decision, and
campaign plan; the tooling/corpus/runner/no-CI posture is inherited
from alkcall's `docs/research/fuzzing.md`).
## Layout
- `fuzz_targets/`: one binary per target; thin `fuzz_target!` wrappers.
- `shared/`, the invariant logic, as a plain library so normal
`cargo test` (stable toolchain) can replay the committed corpora
through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]`
modules; quinn's CI pattern). The fuzz binaries are nightly-only;
the shared crate is stable-clean.
- `corpus/<target>/`: committed hand-made seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora
and artifacts are gitignored.
- `run-detached.sh`, the mandatory runner for agent sessions: wraps
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
in a target can never take down the agent host (fuzzing.md §5).
- `json.dict`: a JSON token dictionary for the params target
(transferred from alkcall).
## Targets
| Target | Drives | Invariants |
|---|---|---|
| `datagram_codec` | `DatagramReader::feed` / `frame_datagram` (`src/wire.rs`) | no-panic; infallible decode; yielded datagrams ≤ `MAX_DATAGRAM_LEN`; F-2 sentinel layering (empty datagram ≠ EOF, only from `[00 00]` headers); splitting transparency (3-byte slices == whole-input feed); re-frame round-trip; oversize rejection exactly at the bound; `is_mid_datagram` tracks framing; decoder-state retained-bytes bound |
| `params_parse` | `TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` / `SubstrateAddr` serde parses + `parse_listen_reply` / `listen_reply_fields` / `establishment_reason` (`src/params.rs`) | no-panic on raw bytes and structured Values; clean typed failure only; round-trip for valid params; `listen: 0` pin; loud/lenient projection-pair agreement; pinned establishment-reason table |
| `session_pumps` | op sequences (`SessionSequence`) over one live channels stack per exec (MuxRunner → real demux → `ChannelManager` per side; the `TunnelSession` data-plane mirrors, codec plane + `pump_bidi` stream planes) | no-panic / no-wedge (5 s reconcile bound); two-pump shutdown-on-completion; lossless p2c copy count; EOF-sentinel teardown semantics (drain-before-EOF, map empties); FIFO datagram reconciliation (framing-walk model); `pending` backlog ≤ routed-batch remainder (the §1.3 bound); decoder-state retained-bytes bound re-asserted per op; monotonic counters |
## Commands
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
(or deeper) as the working directory so rustup selects the pinned
nightly toolchain. The detached runner handles that itself.
```sh
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
cargo fuzz build
# agent sessions: detached campaign (never foreground; CWD-independent)
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh datagram_codec -max_len=65536
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh params_parse -dict=json.dict -use_value_profile=1
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh session_pumps -max_len=4096 -use_value_profile=1
# corpus replay through the invariants (stable toolchain, no nightly)
cargo test --manifest-path fuzz/shared/Cargo.toml
# coverage
cargo fuzz coverage <target>
```
The fuzz workspace is excluded from the main workspace and from the
published package (`exclude` in the root `Cargo.toml`); it pins its own
nightly toolchain via `rust-toolchain.toml` and does not affect the
crate's stable MSRV.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
��bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbLine truncated
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@
��
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
��
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
4
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{"resource": "postgres-primary", "substrate": "tcp"}
+1
View File
@@ -0,0 +1 @@
{"resource": "postgres-primary", "substrate": "udp"}
+1
View File
@@ -0,0 +1 @@
{"resource": "sock", "substrate": "unix"}
+1
View File
@@ -0,0 +1 @@
{"resource": "r", "substrate": "tcp", "extra": true}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp"}
+1
View File
@@ -0,0 +1 @@
{"resource": "r"}
+1
View File
@@ -0,0 +1 @@
{"resource": "", "substrate": ""}
+1
View File
@@ -0,0 +1 @@
{"resource": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "substrate": "tcp"}
+1
View File
@@ -0,0 +1 @@
{"resource": null, "substrate": "tcp"}
+1
View File
@@ -0,0 +1 @@
{"resource": 1, "substrate": 2}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {"host": "db.internal", "port": 5432}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "udp", "target": {"host": "10.0.0.7", "port": 53}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "unix", "target": {"path": "/run/x.sock"}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {"port": 1}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {"host": "h"}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "unix", "target": {"path": "/p", "host": "h"}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {"host": "h", "port": 65536}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {"host": "h", "port": -1}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": [1, 2]}
+1
View File
@@ -0,0 +1 @@
{"substrate": "unix", "target": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp"}
+1
View File
@@ -0,0 +1 @@
{"target": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"listen": 918273, "substrate": "tcp", "peer": {"host": "198.51.100.4", "port": 55231}}
+1
View File
@@ -0,0 +1 @@
{"listen": 918273, "substrate": "unix", "peer": {"path": "/run/x.sock"}}
+1
View File
@@ -0,0 +1 @@
{"listen": 0, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"listen": -1, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"listen": 1, "substrate": "tcp", "peer": {"host": "h", "port": 1}, "channel": 7}
+1
View File
@@ -0,0 +1 @@
{"listen": 4294967296, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"listen": "token", "substrate": "tcp", "peer": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "peer": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"listen": 1, "substrate": "tcp"}
Loaded 100 of 209 files, more files were not shown because too many files have changed in this diff. Show more