Verification pass against the landed alkcall 0.8.0 surface surfaced one
design gap: ADR-008 §3 pinned the forwarded op's correlation key as the
listen channel's ID, but (1) the serving-side OpenHandler/establisher
surface never exposes the allocated channel ID, and (2) the in-tree
generic ChannelRelay forwards open-op params untouched (it rewrites
only the reply's channel_id), so a spoke's forwarded open would carry
an id the end opener never allocated. Amended (wire still unconsumed —
alksocks is docs-only — so pre-consumer and legal):
- ADR-008 Amendment 1: the bind-first establisher mints a fresh u32
correlation token per open — carried in the plan (for the accept
dispatcher) and contributed as the additive `listen` reply field
(the same projection carrier `bound` rides); the forwarded op's
params rename `channel` -> `listen` with token semantics. Tokens
ride reply fields + params through hubs untouched (relay-transparent
by construction, alkcall ADR-051). Consumer-minted tokens and a new
upstream handler surface were considered and rejected (rationale in
the amendment).
- Spec docs synced: wire.md (the `bound`+`listen` reply section, the
forwarded params), producer.md (bind-first shape + forwarded op),
consumer.md (TunnelListener gains `listen()`; correlation registry
posture), overview.md, OQ ledger (OQ-TN-16 updated),
tunnels-graduation.md.
Task decomposition (tasks/tunnels/, Phase 2 of the graduation — the
alkcall prerequisites are gone):
- direct-op — params/spec/establisher/open_direct (ADR-007)
- bindfirst-params — forwarded-op spec, TunnelListenReply, the
BindFirstPlan + token mint (wire types first, the params.md
ordering precedent)
- bindfirst-producer — bind-first establisher, accept dispatcher,
register_tunnel_bindfirst_openable
- forwarded-receipt — the opener-side serving op + reply-field reader
- tunnel-listener — the listen-flow consumer session (ADR-008 §4)
- local-bind-halves — the `local` TCP bind + accept-pairs loop,
unix parity decision, direct-op DialFn reuse pin
Dependency order: direct-op and bindfirst-params are independent
roots; bindfirst-producer -> {bindfirst-params}; forwarded-receipt ->
bindfirst-params; tunnel-listener -> {bindfirst-params,
forwarded-receipt}; local-bind-halves -> bindfirst-producer.
Verification: cargo test 71 passed; clippy --all-targets -D warnings;
fmt --check; wasm32-unknown-unknown check + clippy clean (docs + task
files only — no code change).
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) — the produced resource- substrate discriminator.
- Flow:
ChannelClient::open_channel("channels/tunnel/sub", params, "alk/tunnel")→ adopt the returned channel ID → split the channelBiStream→ present the substrate-shaped data plane. - Errors: a failed open resolves a typed error —
ChannelOpenError::CallFailedcarrying the wireCallError; branch onestablishment_reason()forchannel: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:directvstunnel: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 asopen. - 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, ¶ms, 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; optionalauth_token), extract the worker-allocatedchannel_id. Caller identity rides the transport by default (alkcall 0.7.0 CF-005 (b):Connection::set_identitybefore dialing); the payloadauth_tokenis 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).adopttakes the same typed-error surface asopen(an adopt failure isAdoptFailed-class, not an establishment error).pump_against(accepted_halves)— spawnpump_bidiagainst 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 samechannels/tunnel/subopen 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:Somewhen the producer supplied the field (bind-first),Noneotherwise (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);TunnelListenerhands the assembly the key.- The forwarded side: each accepted connection at the far
listener arrives as a producer-initiated
channels/tunnel/forwardedopen that the opener SERVES (tunnel:forwardedscope), itslistenparam 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. peerfidelity: 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()— rawAsyncRead/AsyncWritehalves; 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_datagramreturnsSome(bytes)per datagram — possibly empty (len=0is a legal empty datagram) — andNoneonly 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: onetake_halvessemantic 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 —
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 ofpump_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 (aftertake_halves, or a datagram session the caller drives directly): there is no session-owned pump —joincompletes immediately (nothing to await) and reaps only; the copy counts are(0, 0).Drop— aborts the pump and sync-reaps the channel (teardown semantics ofclose, best-effort —Dropcannot await). Dropping without close/join never leaks the channel entry.TunnelSessiondoes not implementClone. Aliasing a session would alias its teardown; multi-channel consumers hold aVec<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 — 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_establisherwhose 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 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:ReverseTunnelopen_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)