Files
alktunnels/docs/architecture/decisions/004-no-backend-trait.md
T
glm-5.3-flash bd7d1ad8ec docs: Phase 1 architecture spec — ADRs 001..006, spec docs, OQ promotion
The Phase 0 OQ ledger (OQ-TN-01..10) promoted into
docs/architecture/open-questions.md (now OQ-TN-01..14, with four
new Phase 1 residues). Six ADRs and four spec docs, all Draft, all
decision-shaped per the SDD process (ADRs carry the WHY; specs carry
the WHAT and reference by number):

ADRs:
- 001 open-params-layout: params = {resource, substrate} — the
  producer's stable name, not an address; wire-stable from the first
  consumer; unknown substrates fail loudly at schema time
- 002 alpn-strategy: single alk/tunnel ALPN (option A); substrate in
  params selects the framing; prior art (SSH/SOCKS5/udpgw) gives no
  reason for the split
- 003 codec-and-udp-framing: raw pass-through (stream) / mandatory
  [len: u16 BE] (UDP) — F-2 mandate recorded (the codec is mandatory
  for correctness, not cosmetics); len=0 = legal empty datagram;
  OQ-TN-13 resolved fail-loud (truncation)
- 004 no-backend-trait: halves functions at the assembly layer;
  listen is an establisher shape; hub re-produce deferred(OQ-TN-12);
  trait re-evaluated at the alknet ADR-078 convergence threshold
- 005 consumer-session-owns-teardown: TunnelSession with
  open/adopt/stream_halves/take_halves/pump_against/close/join/Drop —
  the W3 adopter gap closes structurally; Drop is the best-effort
  fallback
- 006 access-control-posture: the open gate is the boundary;
  TUNNEL_OPEN_SCOPE = tunnel:open (stable once published); op-level
  ACL; ownership seam; no allowlists in v1; identity = 0.7.0's
  precedence chain

Spec docs:
- overview.md: purpose, resource model, deps (alkcall 0.7.0,
  wasm-clean default, local feature), module map
- wire.md: the open op (params/reply/typed errors), the data plane
  by substrate, sentinels + half-close, byte diagrams
- producer.md: spec, establisher (dial + listen shapes), pump
  handler (pump_bidi inline, R-02), registration API, ACL posture
- consumer.md: TunnelSession (forward open + reverse adopt
  construction), data plane, teardown API (close/join/Drop incl.
  pump-less join semantics)
- bast.md: the BAST doc for the UDP codec (convention 12's trigger
  fired — the framing IS binary beyond pass-through)
- open-questions.md: OQ-TN-01..14; 01..10 promoted (faithful to the
  phase-0 ledger's final states), 11 partially resolved (collision
  domain = per-producer registry per ADR-001; lifecycle open),
  12 deferred(scope), 13 resolved fail-loud (ADR-003), 14 open

Verified by an architecture-reviewer pass (2 criticals, 6 majors,
10 minors — all fixed: ADR-001/003-vs-OQ decision-state contradictions
resolved; the Layer-2 mislabel corrected to the ADR-047 §4 per-session
fork; alktty/alknet ADR misattributions fixed; codec placement pinned
to the establisher (pump stays substrate-agnostic); reverse-path
construction named (TunnelSession::adopt + pump_against); security
posture promoted to ADR-006; BAST doc written; F-2 rationale deduped;
README tables completed; channel_id > 0; MTU wording fixed; ADR-047 §5
attributions corrected; impacts lines added to unresolved OQs; review/
ledger pointer paths added to README references).

Verification: doc set internal cross-refs all resolve; cargo test,
fmt --check, doc --no-deps clean
2026-09-07 18:36:44 +00:00

115 lines
5.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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:
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)