Files
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

12 KiB


status: draft last_updated: 2026-09-18 (open_direct, TunnelListener, the forwarded-op serving side; ADR-007/008 as amended: the correlation token)

alktunnels: Consumer Half

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 the connection (OQ-TN-03). Design decisions: ADR-005 (session owns teardown), ADR-003 (codec), ADR-007 (the direct op), ADR-008 (listen metadata + the forwarded op); this document is the normative WHAT.

Opening a Tunnel

Forward path (-L/direct, the consumer dials the producer's transport)

let session = TunnelSession::open(&channel_client, params).await?;
  • 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: 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 no-phantom-channel property (POC-verified).

Direct path (dynamic-target egress, ADR-007)

let session = TunnelSession::open_direct(&client, target, substrate).await?;
  • A separate constructor from open, not a params enum: two scopes are two capabilities (tunnel:direct vs tunnel:open), and the 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 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).

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 are identical:

// Per local accept (assembly-owned listener):
let channel_id = open_reverse_channel(&hub_call, &params, auth_token).await?;
let session = TunnelSession::adopt(hub_manager, channel_id, substrate, TUNNEL_ALPN).await?;
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 (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: 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 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 accepted local connection and hold the pump handle (ADR-005).

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 sibling of TunnelSession: the session that OPENS a bind-first listen tunnel and owns its teardown (ADR-005's ownership posture extended to the listen flow). The forwarded connections it receives arrive as ordinary adopted-side TunnelSessions on the opener's manager (adopt gains nothing).

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 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
  • 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 correlation token the producer minted).
  • 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: 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 listener arrives as a producer-initiated channels/tunnel/forwarded open that the opener SERVES (tunnel:forwarded scope), its listen param matching the token. The opener-side assembly correlates the open to the waiting 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 alksocks POC's sentinel retires). Producer-asserted metadata, not transport truth.
  • Teardown: dropping/closeing the listener EOFs the listen channel; the producer-side accept loop cancels and tears the far listener down (ADR-008 §1's handler contract). Forwarded sessions already adopted own their own teardown (per-session, ADR-005).

The Data Plane (substrate-shaped)

  • 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 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 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).

Lifecycle and Teardown (the point of ADR-005)

One session = one channel = one tunnel. The session owns:

  • 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, i.e. the reverse path's pump_against).

Teardown API:

  • 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, 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, 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, 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 Vec<TunnelSession> (or the assembly layer does).

Out-of-band close

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, 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 sentinel.

Both-Sides Sanity

The consumer and producer halves compose on one connection in both 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) 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 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, 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, TunnelListener, the forwarded op)

References

  • producer.md (the serving side's shapes), wire.md (the data plane)
  • Reverse POC docs/research/reverse-poc-summary.md (the seed shape: 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 0.8.0
  • alkcall ADR-047 §5 (allocation; the initiator adopts), ADR-050 (pump_bidi), ledger CF-005/CF-006 (identity on the reverse path)