# ADR-004: No TunnelBackend Trait — Halves Functions at the Assembly Layer ## Status Accepted (2026-09-07; resolves the OQ-TN-05 executable input + the hub 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 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? The tunnel producer's substrate action is much narrower than a TTY backend's: - **Dial flows:** dial a target, get an `AsyncRead + AsyncWrite` stream (or a datagram endpoint). A tunnel "handle" is just boxed halves. - **Listen flows:** accept on a listener; per accept, the accepted 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 — 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 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 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 `register_openable_with_establisher` whose establisher opens a consumer channel. Both halves are public-surface operations; no new mechanism is visible yet. Deferring the helper until a concrete re-produce consumer exists is the alknet ADR-078-style "genuine deferral" pattern pattern (OQ-TN-12). ## Decision **No `TunnelBackend` trait in v1.** The inversion point is the assembly layer: - **The produce function.** The producer's establisher resolves `(resource, substrate)` → boxed halves (`Box` ×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: the establisher closure the assembly layer constructs closes over the substrate dial function. The protocol crate never sees a socket type. - **The listen variant** is an establisher shape, not a trait: a producer that LISTENS (SSH `-R` far-side listener, or the hub's own exposed port being produced to a third party) registers the same 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 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 — 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) instead of raw halves; the pump shape is unchanged. - **Re-evaluation trigger:** if a consumer crate demonstrates that 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 across consumers, not before) — 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. ## Consequences - **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 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, same params, same typed errors (an empty listener queue during establishment is `resource_shortage`; a closed listener is `dial_failed`-class). ## References - OQ-TN-05 (promoted), OQ-TN-12 (hub re-produce, deferred), OQ-TN-14 (unix/stdio placement) - 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 pattern) - AGENTS.md conventions 4/7/14 (wasm-clean, substrate-agnostic, feature flags)