Files
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

11 KiB


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)

alktunnels: Overview

Arbitrary bidirectional tunnels over alkcall channels: TCP, UDP, unix 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 model in brief, dependencies, the ALPN, and the module map; component details are in the sibling documents (wire.md, producer.md, consumer.md).

What

alktunnels registers the alk/tunnel ALPN's open ops on alkcall channels and provides the typed consumer sessions:

  • The producer half (open ops registered via ChannelCore::register_openable_with_establisher, alkcall ADR-047/049): the establisher validates params semantically, then dials (or binds/accepts for) the substrate, and returns the handle via Establishment::new(plan); the pump handler awaits alkcall::channels::pump_bidi over the channel and the substrate 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 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 on the forward path; the reverse path's call + adopt equivalent), splits the channel BiStream, presents the substrate-shaped data plane (raw halves or datagram codec), and owns teardown (close/join/Drop). TunnelListener is the listen-flow session (bound-address discovery + forwarded-connection receipt).

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, 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, i.e. inside the tunnel payload, never in the wire.

Why

The crate's purpose is to be the tunnel library for downstream 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).
  • 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 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, and session type.

The Resource Model in Brief

A tunnel channel's life (full detail in wire.md):

  1. Open (the establishment phase). The consumer calls channels/tunnel/sub with params = {resource, substrate} (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 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 never exists on the opener's side afterward. A bind-first listen open completes at bind time and may carry "bound" (the resolved address) and "listen" (the correlation token) in the reply (ADR-008 §2/Amendment 1).
  2. Pump (the data plane). Two pumps, one per direction, via pump_bidi (ADR-050): each shuts the opposite sink down on completion; half-close semantics fall out. Stream substrates ride raw pass-through (0 B tunnel overhead); UDP rides the mandatory [len: u16 BE] codec (ADR-003, F-2).
  3. Teardown. The serving side is wrapper-managed (R-02); the adopting side's TunnelSession owns teardown (ADR-005, W3). EOF sentinels are the channels layer's; the codec never collides with them.

Direction is not in the protocol: whoever can reach the target is the producer; whoever wants the bytes is the consumer; connection direction (who dialed the transport) is independent of tunnel direction (OQ-TN-03's hub model). -L, -R, and -D are assembly shapes over the same ops; the -R composed flow rides the bind-first establisher + the forwarded op (ADR-008).

Dependencies

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))
├── 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)

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, 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, 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);
                      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
                      (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 /
                      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

Public API surface is lib.rs re-exports (AGENTS.md convention 16).

Design Decisions

All design decisions are documented as ADRs in decisions/.

ADR Decision Summary
001 Open-op params: resource + substrate {resource, substrate}, the producer's stable name (not an address); wire-stable from the first consumer
002 Single alk/tunnel ALPN Substrate in params selects the framing; no ALPN split, no API bifurcation
003 Data-plane codec Raw pass-through (stream) / mandatory [len: u16 BE] (UDP; F-2 mandate); len=0 is a legal empty datagram
004 No TunnelBackend trait Halves functions at the assembly layer; listen is an establisher shape; trait re-evaluated at the 3-consumer threshold
005 Consumer session owns teardown TunnelSession with close/join/Drop; the W3 gap closes structurally
006 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 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 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

Open questions are tracked in 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); 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 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)

References

  • Phase 0: docs/research/ (findings, both POC summaries, survey)
  • Upstream ADRs: see README.md §References
  • Sibling: alktty (/workspace/@alkdev/alktty/docs/architecture/)