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
6.9 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-09-07 |
alktunnels — Consumer Half
The consumer half: TunnelSession — the typed client for tunnel
channels. 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); 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).
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, TUNNEL_ALPN).await?;
let pump_handle = 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, 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.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 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).
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) - OQ-TN-13: UDP truncation (resolved — fail-loud, ADR-003)
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) - alkcall ADR-047 §5 (allocation — the initiator adopts), ADR-050
(
pump_bidi), ledger CF-005/CF-006 (identity on the reverse path)