docs: spec the graduation — ADR-007 (direct op), ADR-008 (listen metadata + forwarded op)

The alksocks graduation research (tunnels-graduation.md) is decided;
the ADRs are normative now:

- ADR-007: the direct open op — channels/tunnel/direct on the one
  alk/tunnel ALPN, params {substrate, target} (self-describing JSON
  target; ATYP bytes rejected as not schema-describable), scope
  tunnel:direct (never implied by tunnel:open), dial-only.
- ADR-008: listen metadata — bind-first listen establisher (reply
  gains additive 'bound' = BND.ADDR fidelity; the BIND deadlock +
  10s-bound findings dissolve) + the forwarded op
  channels/tunnel/forwarded (accept-as-open, params carry
  {channel, substrate, peer} = reply#2 fidelity). Accept-first stays.
- ADR-001/002/006 amendments: params layout is per-op; flavor-form
  op ids on the one ALPN; per-flavor scopes + the hub re-produce
  policy sentence (the hub's ACL governs the final dial).
- wire/producer/consumer/overview/README updated for the new ops,
  the three establisher shapes, TunnelListener, open_direct.
- OQ-TN-15/16 promoted + resolved; upstream alkcall asks filed at
  alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md
  (flavor-form discovery derivation; the establisher → reply
  projection).

Verification: cargo test (22 passed), clippy -D warnings, fmt --check
(docs-only change; wasm check unaffected)
This commit is contained in:
glm-5.3-flash committed 2026-09-16 08:31:56 +00:00
1 parent c86021e6a9
commit 0d6b23b7cf
12 files changed
+1054 -112

No files matched your search

+46 -28
View File
@@ -1,6 +1,6 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-16 (the graduation ADRs 007/008 + the amendments)
---
# alktunnels — Architecture
@@ -18,15 +18,15 @@ raw byte/datagram transport, not terminal sessions).
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, the resource model in brief, dependencies, ALPN, feature gates, module map |
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP — ADR-003), sentinels, half-close semantics |
| [producer.md](producer.md) | draft | The producer half: the `channels/tunnel/sub` open op, the establisher (dial + listen variants), the pump handler, registration, scope gating |
| [consumer.md](consumer.md) | draft | The consumer half: `TunnelSession` (stream + datagram variants), the reverse-flow initiator, teardown ownership (ADR-005) |
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001; the direct op's `{substrate, target}` — ADR-007), the `bound` reply field + the forwarded op (ADR-008), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP — ADR-003), sentinels, half-close semantics |
| [producer.md](producer.md) | draft | The producer half: the `channels/tunnel/sub` + `channels/tunnel/direct` open ops, three establisher shapes (dial, accept-first listen, bind-first listen), the pump handler, the forwarded-op opener side, registration, scope gating |
| [consumer.md](consumer.md) | draft | The consumer half: `TunnelSession` (stream + datagram variants, `open`/`open_direct`), `TunnelListener` (the listen flow), the reverse-flow initiator, teardown ownership (ADR-005/008) |
| [bast.md](bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the data-plane's binary framing (the UDP `[len: u16 BE]` codec — ADR-003); the stream pass-through has no binary framing to describe |
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-TN-01..14; the Phase 0 ledger promoted) |
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-TN-01..16; the Phase 0 ledger promoted) |
## Applicable ADRs
alktunnels-native ADRs (001..005). Upstream ADRs (alkcall/alknet) are
alktunnels-native ADRs (001..008). Upstream ADRs (alkcall/alknet) are
referenced by their own numbers and are NOT ported — the alkcall
originals at `/workspace/@alkdev/alkcall/docs/architecture/decisions/`
remain authoritative for the shared surfaces (establishment, channels
@@ -34,22 +34,28 @@ wire format, `pump_bidi`, identity seam).
| ADR | Title | Status |
|-----|-------|--------|
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted |
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN (Option A) | Accepted |
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted; amended (params layout is per-op — the direct op's params ride ADR-007) |
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN (Option A) | Accepted; amended (flavor-form op ids: `…/sub`, `…/direct`, `…/forwarded` on the one ALPN) |
| [003](decisions/003-codec-and-udp-framing.md) | Data-Plane Codec: Raw Pass-Through (Stream) / Mandatory Length Framing (UDP) | Accepted |
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend Trait — Halves Functions at the Assembly Layer | Accepted |
| [005](decisions/005-consumer-session-owns-teardown.md) | The Consumer Session Type Owns Teardown | Accepted |
| [006](decisions/006-access-control-posture.md) | Access-Control Posture — the Open Gate Is the Security Boundary | Accepted |
| [006](decisions/006-access-control-posture.md) | Access-Control Posture — the Open Gate Is the Security Boundary | Accepted; amended (`tunnel:direct`/`tunnel:forwarded` scopes; the hub re-produce policy sentence) |
| [007](decisions/007-direct-open-op.md) | The Direct Open Op — Dynamic-Target Egress | Accepted |
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen Metadata — Bind-First Establisher and the Forwarded Op | Accepted |
## Key Design Principles
1. **A tunnel is a resource, not an address.** `params` identify a
produced resource + substrate discriminator; the producer owns the
backing (local port, docker container, in-process service, unix
socket). No URL-style general addressing in the wire — rich
addressing enters only through `-D`/dynamic composition, which
speaks its protocol inside the tunnel payload. See
[ADR-001](decisions/001-open-params-layout.md) and
1. **A tunnel is a resource, not an address — with one additive
exception.** Base params identify a produced resource + substrate
discriminator; the producer owns the backing (local port, docker
container, in-process service, unix socket). Rich addressing
enters through `-D`/dynamic composition, which speaks its protocol
inside the tunnel payload. The exception (ADR-007): the direct op
lets a consumer ask the producer to dial an arbitrary target — a
different capability on its own op and scope (`tunnel:direct`),
never implied by `tunnel:open`. See
[ADR-001](decisions/001-open-params-layout.md),
[ADR-007](decisions/007-direct-open-op.md), and
[wire.md](wire.md).
2. **Role follows the resource; connection direction is independent.**
@@ -57,9 +63,11 @@ wire format, `pump_bidi`, identity seam).
channels); whoever wants the bytes is the consumer. `-L` and `-R`
are the same open op with the entry point on different machines —
no protocol-level direction (the hub-owns-the-connection model,
OQ-TN-03). Validated by the reverse-flow POC: the worker serves on
the connect side; the hub initiates per accept. See
[producer.md](producer.md) and [consumer.md](consumer.md).
OQ-TN-03). The composed `-R` flow rides the bind-first establisher
+ the forwarded op (ADR-008). Validated by the reverse-flow POC:
the worker serves on the connect side; the hub initiates per
accept. See [producer.md](producer.md) and
[consumer.md](consumer.md).
3. **Raw pass-through for stream substrates; mandatory length framing
for UDP.** A tunnel has one data stream per direction — no
@@ -100,14 +108,19 @@ wire format, `pump_bidi`, identity seam).
second demux/mux (AGENTS.md convention 10).
8. **Security posture: the open gate is the boundary.** Scope-gated
opens (`TUNNEL_OPEN_SCOPE = "tunnel:open"`, stable once published
— ADR-006), alkcall's op-level ACL via `register_openable`; caller
identity resolves in the 0.7.0 precedence order (token >
`ServingConfig.identity` > transport); the establisher/pump
handler see the per-call opener identity. No target allowlists, no
tunnel-specific policy machinery in v1. See
[ADR-006](decisions/006-access-control-posture.md) and
[producer.md](producer.md).
opens (`TUNNEL_OPEN_SCOPE = "tunnel:open"`,
`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`,
`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"` — each stable once
published; ADR-006 + Amendment 1), alkcall's op-level ACL via
`register_openable`; caller identity resolves in the 0.7.0
precedence order (token > `ServingConfig.identity` > transport);
the establisher/pump handler see the per-call opener identity. No
target allowlists in the protocol (an assembly establisher MAY
enforce them — policy, not protocol). Hub re-produce: the hub's
ACL governs the final dial (the hub is the network boundary). See
[ADR-006](decisions/006-access-control-posture.md),
[ADR-007](decisions/007-direct-open-op.md), and
[producer.md](producer.md).
9. **Wasm-clean default crate.** The default crate (protocol only)
compiles to `wasm32-unknown-unknown`; socket/platform I/O lives in
@@ -140,6 +153,11 @@ Questions section.
lifetime contract, R-03 = `pump_bidi`) and
`/workspace/@alkdev/alkcall/docs/reviews/consumer-findings-ledger.md`
(CF-005/CF-006 = the identity seam, resolved in 0.7.0)
- The graduation research (the two new ADRs implement it):
`docs/research/tunnels-graduation.md`; the pending upstream alkcall
asks are filed at `/workspace/@alkdev/alkcall/docs/reviews/
008-tunnels-graduation-upstream-review.md` (flavor-form discovery
derivation; the establisher → reply `bound` projection)
- Sibling precedent: alktty (`/workspace/@alkdev/alktty`) — the
producer/consumer-on-channels shape, wasm-clean default,
feature-gated local backend, BAST convention
+82 -10
View File
@@ -1,18 +1,22 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-16 (`open_direct`, `TunnelListener`, the forwarded-op serving side — ADR-007/008)
---
# 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](decisions/005-consumer-session-owns-teardown.md)
(session owns teardown), [ADR-003](decisions/003-codec-and-udp-framing.md)
(codec); this document is the normative WHAT.
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](decisions/005-consumer-session-owns-teardown.md) (session
owns teardown), [ADR-003](decisions/003-codec-and-udp-framing.md)
(codec), [ADR-007](decisions/007-direct-open-op.md) (the direct op),
[ADR-008](decisions/008-listen-metadata-forwarded-op.md) (listen
metadata + the forwarded op); this document is the normative WHAT.
## Opening a Tunnel
@@ -36,6 +40,26 @@ let session = TunnelSession::open(&channel_client, params).await?;
yields a session** — no phantom session, mirroring the
no-phantom-channel property (POC-verified).
### Direct path (dynamic-target egress, ADR-007)
```rust
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
@@ -72,6 +96,47 @@ let pumped = session.pump_against(accepted_halves).await; // or drive the halves
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 `TunnelSession`s on the
opener's manager (`adopt` gains nothing).
```rust
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 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).
- **`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).
- **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). The opener-side assembly correlates the
open (via the `channel` param) to the waiting session, adopts the
allocated channel (`TunnelSession::adopt`), and pumps against the
local BIND client's halves. The correlation registry is
assembly-side (alksocks') bookkeeping.
- **`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/`close`ing 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
@@ -146,8 +211,12 @@ serving registry of its own.
- **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)
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
@@ -155,6 +224,9 @@ serving registry of its own.
[wire.md](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: `alkcall/docs/reviews/
008-tunnels-graduation-upstream-review.md`
- alkcall ADR-047 §5 (allocation — the initiator adopts), ADR-050
(`pump_bidi`), ledger
CF-005/CF-006 (identity on the reverse path)
@@ -2,7 +2,8 @@
## Status
Accepted (2026-09-07)
Accepted (2026-09-07); amended 2026-09-16 (params layout is per-op —
the direct op pins its own params, see Amendment 1)
## Context
@@ -117,4 +118,18 @@ carries (socks5, udpgw-style framing), never in base params.
ADR-049 (typed establishment errors)
- Forward POC: `docs/research/poc-summary.md` (the shape riding 17
tests); reverse POC: `docs/research/reverse-poc-summary.md`
- OQ-TN-01 (promoted), OQ-TN-11 (naming residue)
- OQ-TN-01 (promoted), OQ-TN-11 (naming residue)
## Amendment 1 (params layout is per-op, 2026-09-16)
The `{resource, substrate}` layout pins the `channels/tunnel/sub` open
op. It is **per-op, not per-ALPN**: ADR-007 adds the
`channels/tunnel/direct` op on the same `alk/tunnel` ALPN with its own
params (`{substrate, target}` — a dynamic target, a different
capability with its own scope; the resource-not-address invariant is
unchanged because a dynamic target is not a produced resource). The
per-op reading was always latent in the layout's basis — params are
"the `input` of the operation" (alkcall ADR-047), and ops are
per-flavor once flavor-form op ids exist (ADR-002 Amendment 1).
Additive-only evolution per op stands: this amendment adds nothing to
the `…/sub` op's wire surface.
@@ -2,7 +2,8 @@
## Status
Accepted (2026-09-07)
Accepted (2026-09-07); amended 2026-09-16 (flavor-form op ids on the
one ALPN, see Amendment 1)
## Context
@@ -83,4 +84,34 @@ session types).
- OQ-TN-07 (promoted, resolved by this ADR)
- `docs/research/ssh-socks5-survey.md` §ALPN-relevant prior art
- alkcall ADR-004 (ALPN convention), ADR-047 (open ops per ALPN)
- AGENTS.md convention 17 (ALPN naming)
- AGENTS.md convention 17 (ALPN naming)
## Amendment 1 (flavor-form op ids, 2026-09-16)
The decision stands — one ALPN, no ALPN split per flavor. What
changes: the one-ALPN rule is realized as **one ALPN, several
`Sub`-typed open ops**, because ADR-007 (the direct op) and ADR-008
(the forwarded op) are different capabilities sharing the tunnel
transport — ssh's one-transport-many-channel-types shape.
- The op-id convention extends to flavor form: `channels/<alpn>/sub`
(the original resource op, reserved and unchanged),
`channels/<alpn>/direct` (dial an arbitrary target), and
`channels/<alpn>/forwarded` (accept-as-open toward the listen
opener). New flavors are new op ids — additive; the `…/sub` op is
never reused for a different meaning.
- The alternative — sibling ALPNs (`alk/tunnel-direct`,
`alk/tunnel-forwarded`) with standard `…/sub` op ids — was rejected:
it fragments the tunnel namespace per flavor, forces hub relay to
track three ALPNs, and amends this ADR's own new-ALPN justification
clause (the flavors share framing; none needs structurally different
framing from byte zero).
- Upstream coordination: alkcall ADR-047 pins discovery's ALPN
derivation to exactly the `…/sub`/`…/pub` suffix shapes
(`derive_alpn_from_op_name`); flavor-form op ids need that
derivation (and the hub-relay reconstruction riding it) extended —
filed upstream
(`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`,
ask #1) before the first consumer. Runtime was never blocked:
registration sets the ALPN marker explicitly and
`open_channel(op_id, params, alpn)` takes the ALPN as an argument.
@@ -4,7 +4,9 @@
Accepted (2026-09-07; records the OQ-TN-08 resolution as a decision —
the posture was resolved in Phase 0 but the consumer-visible constants
had no ADR backing)
had no ADR backing); amended 2026-09-16 (scope separation for the
direct/forwarded ops + the hub re-produce policy sentence, see
Amendment 1)
## Context
@@ -99,4 +101,31 @@ consumer-visible and stable-once-published:
per-call opener); alknet ADR-024/ADR-050 (registry layering,
ownership — the alkcall ports ADR-019/ADR-011)
- alktty's `TTY_OPEN_SCOPE` posture (the sibling precedent)
- AGENTS.md convention 13 (access control)
- AGENTS.md convention 13 (access control)
## Amendment 1 (per-flavor scopes + hub re-produce policy, 2026-09-16)
The posture stands; the scope space grows with the ops (ADR-007,
ADR-008):
- **`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`** gates
`channels/tunnel/direct` — dynamic-target egress. **Never implied by
`tunnel:open`**: "dial `postgres-primary`" and "dial anything from
my network" are different capabilities, and the separation is
structural (scopes attach to op specs, not params). A deployment
granting both grants both explicitly.
- **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`** gates
`channels/tunnel/forwarded` on the opener side — the grant for
RECEIVING producer-initiated forwarded connections (ingress).
Ingress and egress are different grants (the alksocks POC #5
`socks5:bind` gate proved the distinction matters in practice).
- **Hub re-produce policy sentence (the security model):** a hub that
re-produces the direct op applies ITS OWN target policy at the
re-produce hop — the hub's ACL, not the original opener's, governs
the final dial. The hub is the network boundary; the original
opener's grant gets it to the hub, the hub's own grant decides what
it will dial on the far side. Correct-by-default; pinned here
because it is the boundary-allocation rule for every composed
consumer (alknodes included).
- Scope strings are ACL-stable once published (§2 unchanged): renames
are breaking; new gates are new scopes on new ops.
@@ -0,0 +1,192 @@
# ADR-007: The Direct Open Op — Dynamic-Target Egress
## Status
Accepted (2026-09-16)
## Context
ADR-001 pinned params as "resource, not an address": the producer owns
the backing; rich addressing rides the `-D`/dynamic composition path,
never the wire. That decision stands. The use case that justifies an
*additive* revisit is the one the alksocks graduation research
(`docs/research/tunnels-graduation.md`) named: a consumer asking the
producer to dial an arbitrary target chosen at session time — ssh's
`direct-tcpip` shape, SOCKS5 CONNECT's egress, and the vpn-like
dial-callback translation all need it. A dynamic target is a different
capability from opening a named resource, and ssh carries it as its own
channel type precisely because the two capabilities must not share one
grant.
The capability is dangerous in exactly the way ADR-001's "resource, not
address" framing protects against — arbitrary egress from the
producer's network. The mitigation is not refusing the capability (a
SOCKS5 service IS the capability) but separating it at the ACL: "dial
`postgres-primary`" and "dial anything" must not be the same grant.
The alksocks POC #5 scope gate (`socks5:bind`) proved the pattern
matters in practice.
Timing: alksocks is the first real consumer and nothing is published
against the direct shape yet — this is the correct moment to pin the
wire-stable surface.
## Decision
### 1. One ALPN, a second open op
The direct op rides the same `alk/tunnel` ALPN (ADR-002 stands: one
ALPN, one protocol identity — SSH's one-transport-many-channel-types
shape). The op id is **`channels/tunnel/direct`** — the flavor-form
op-id convention (ADR-002 Amendment 1): `channels/<alpn>/<flavor>`,
`Sub`-typed, same establishment phase and wrapper as `…/sub`. An older
producer rejects the unknown op loudly (registry lookup fails — the
SSH "unknown channel type" posture, `NOT_FOUND`).
The op-id convention itself needed an upstream alkcall amendment
(ADR-047 amendment: discovery's ALPN derivation and the
`rebuild_spec_for` marker path recognize flavor-form op ids; hub relay
reconstructs them). Runtime was never blocked — registration sets the
ALPN explicitly and `open_channel(op_id, params, alpn)` takes the ALPN
as an argument — but discovery + hub relay are the re-produce path
(alknodes, OQ-TN-12), so the convention is pinned upstream before the
first consumer.
### 2. Params: `{ substrate, target }`
```json
{
"substrate": "tcp",
"target": { "host": "db.internal", "port": 5432 }
}
```
- **`substrate`** (string, required) — same discriminator semantics as
ADR-001: `"tcp"` | `"udp"` | `"unix"` in v1; selects the data-plane
framing (ADR-003). Enum-validated; unknown values fail schema
validation loudly.
- **`target`** (object, required) — a self-describing JSON object keyed
by the sibling `substrate` field:
- `tcp` → `{ "host": <string>, "port": <u16> }` — `host` is a
hostname, IPv4/IPv6 literal, or the JSON form of
`SocketAddr`-shaped values; **domain-form targets stay unresolved
on the wire** (producer-side resolution — the ssh `-D` semantic;
both POCs validated egress-side resolution). No DNS dependency on
the consumer.
- `udp` → `{ "host": <string>, "port": <u16> }` — the connected-UDP
destination (endpoint-at-open; ADR-003 framing applies as for base
udp).
- `unix` → `{ "path": <string> }` — the socket path on the
producer's filesystem.
- The schema pins both fields required; the target sub-schema is
per-substrate (`oneOf` on the sibling `substrate` value, validated at
establisher time — see Consequences). Malformed targets are the
typed `invalid_input`-class failure; **`unknown_resource` can never
fire on the direct op** (there is no registry lookup).
- Encoding choice over the RFC-1928 ATYP byte string: the ATYP form is
SOCKS5's wire carrier and alksocks maps `TargetAddr` → this JSON
trivially (`to_target_addr` decomposes to host/port at the adapter),
but a self-describing JSON object is schema-describable (alkcall
ADR-047's registry `input_schema` runs before the establisher),
carries the unix path form without an extension, and is
human-readable in discovery listings and error messages. SOCKS5
remains the reason the *capability* exists, not the carrier the
*wire* encodes.
- **Dial-only: no listen/direct-BIND form.** The reverse shape
(`-R`-style ingress) is the listen-tunnel + the forwarded op
(ADR-008) — a direct-BIND form would duplicate its capability with
none of its metadata fidelity.
### 3. Scope: `TUNNEL_DIRECT_SCOPE = "tunnel:direct"`
The op's `AccessControl.required_scopes` is `["tunnel:direct"]` — a
separate grant from `tunnel:open`, never implied by it. "Dial
`postgres-primary`" and "dial anything from my network" are different
capabilities; the ACL separation is structural (scopes attach to op
specs, not params — ADR-006 §2's additivity rule). Scope strings are
ACL-stable once published (ADR-006 §2): renames are breaking; new
gates are new scopes on new ops.
### 4. Establisher: the dial shape without the registry
Same establisher contract as the dial shape (producer.md), minus the
lookup:
1. Parse and validate `target` against `substrate` (a mismatch —
`unix` without `path`, `tcp` with a bad port — is `invalid_input`
class).
2. Inject the parsed `TunnelTarget` into the assembly-provided
`DialFn` (the same halves function the base op's registry backings
resolve to; ADR-004 — the protocol never sees a socket type).
3. Return the dialed handle via `Establishment::new(plan)` — unchanged
plan-flow.
Error mapping (the dial table, minus the registry row):
| Producer condition | Typed reason |
|---|---|
| target malformed for the substrate | schema / `invalid_input`-class |
| dial refused / timed out / unreachable | `dial_failed` |
| bind/connect resource exhaustion | `resource_shortage` |
| establisher-internal failure | `handler_error` |
| the bound exceeded | `timeout` (wrapper-level) |
### 5. Consumer API
```rust
let session = TunnelSession::open_direct(&client, target, substrate).await?;
```
A separate constructor, not a params enum: two scopes are two
capabilities, and the caller states which one it holds. The session
type, data planes, and teardown API are identical to `open` (ADR-005).
### 6. Hub re-produce: the hub's policy governs the final dial
A hub that re-produces the direct op (terminate-and-re-produce, alkcall
ADR-042) applies **its own** target policy at the re-produce hop: the
hub's ACL, not the original opener's, governs the final dial. The hub
is the network boundary; the original opener's `tunnel:direct` grant
gets it to the hub, and the hub's own grant decides what it will dial
on the far side. Correct-by-default; pinned here as the security model
sentence (also ADR-006 amendment).
## Consequences
- **Wire-stable from the first consumer.** Op id, scope string, params
field names, and the target sub-schema are one-way doors; renames
are breaking. Additive evolution: a new substrate is a new `substrate`
value + a new target key (never a format change); new fields inside
a target object must be optional-with-default.
- **The BAST question is moot** — params is JSON inside the channels
open op; the data plane is unchanged (raw pass-through for tcp/unix,
the `[len: u16 BE]` codec for udp — ADR-003).
- **Discovery exposure**: the direct op's `input_schema` carries
`{substrate, target}`, so a discovery listing reveals that dynamic
egress exists and of which substrates — acceptable; the target
values (what was dialed) never enter discovery (they ride per-call
params). A producer wanting to hide the capability entirely simply
does not register the op (and old producers reject it loudly anyway).
- **`local` feature interaction:** the direct establisher composes the
same `DialFn` the base op uses; a producer that registered
`dial_tcp` for the base op can register the direct op with no new
substrate code. The `local` feature ships the dial functions; the
direct op itself is protocol-layer (default crate, wasm-clean).
- **SOCKS5 CONNECT composes directly:** the alksocks adapter's
`DialFn` seam maps the SOCKS5 CONNECT target (ATYP-decoded) onto
`open_direct` — the graduation's §4 table, now with the stand-in
dissolved.
## References
- `docs/research/tunnels-graduation.md` §2 (the ask, options, and the
lean) — the design discussion this ADR records
- alksocks `poc-bind-findings.md` (the scope-gate precedent) and
`poc-connect-wasm-findings.md` (the dial-at-command-read layering)
- alkcall ADR-047 (open ops + `input_schema` + discovery marker),
ADR-049 (establishment/typed errors), Gap F (op-name → ALPN
derivation — the flavor-form amendment target)
- alktunnels ADR-001 (the resource-params invariant the direct op
extends), ADR-002 Amendment 1 (flavor-form op ids), ADR-006
(scope posture + the hub-policy amendment)
- AGENTS.md conventions 9 (no forced binding — dial-only, no listen
form), 13 (access control), 17 (ALPN naming)
@@ -0,0 +1,219 @@
# ADR-008: Listen Metadata — Bind-First Establisher and the Forwarded Op
## Status
Accepted (2026-09-16)
## Context
alksocks' BIND POC (`poc-bind-findings.md` in the alksocks repo) pinned
two upstream asks for composed (`-R`-style) BIND fidelity:
1. **Bound-addr discovery without consuming an accept** — reply#1's
`BND.ADDR` must precede the app server's dial, but the listen
establisher is accept-first: its establishment phase pops an
already-accepted connection, so the open cannot complete (and
cannot report a bound address) until the app server dials.
2. **Peer metadata on accepted handles** — reply#2's peer address is
lost through the composed path; `TargetHandle` carries boxed halves
only, so the composed variant reports the sentinel.
The graduation research (`docs/research/tunnels-graduation.md` §3)
framed the option space: per-ask carriers (a reply-metadata projection
plus one of three addr-discovery shapes) versus ssh's
`forwarded-tcpip` shape as the combined alternative. Decision: the
`forwarded-tcpip` shape — **bind-first establisher + accept-as-open**
— because it resolves both asks *and* two structural problems the POC
recorded, with zero special carriers:
- The address-first-vs-accept-first **deadlock dissolves** instead of
being sequenced around: establishment = bind the listener (fast,
no app-server dependency), so the open-op reply carries the **real**
OS-chosen bound address (ask #1 solved natively).
- The **10s establishment-bound problem dissolves**: the accept wait
moves out of the establishment phase into the pump-handler phase
(whose lifetime is teardown-governed, not establishment-bounded) —
FTP-style long BIND waits stop resolving as spurious refusals.
- Ask #2 (peer metadata) rides the reverse channel's open-op params —
the wire's native "this connection is for X" moment.
All the machinery is POC-validated: `open_reverse_channel` +
`TunnelSession::adopt` + producer-allocated IDs (the reverse POC), the
listen establisher + `AcceptQueue` (POC #5), per-accept channel opens
over a serving connection (the reverse POC's hub shape).
## Decision
### 1. Third establisher shape: bind-first
The listen establisher gains a bind-first variant alongside the
accept-first shape (which stays — see §5):
- The establisher **binds the listener** (via the assembly-provided
listen function) and returns `Establishment::new(plan)` carrying the
listener handle. Establishment is fast — bounded by the bind, not by
any app-server dial.
- The establisher MAY contribute the observed bound address to the
open-op reply (§2): `"bound": { ... }` — the real OS-chosen address
(port `0` resolved), substrate-shaped like the direct op's target
(`{host, port}` for tcp/udp, `{path}` for unix).
- The pump handler for this shape is an **accept dispatcher**, not
`pump_bidi`: it accepts the channel's `BiStream` (the R-02 contract)
and runs the assembly-provided accept loop concurrently — each
accepted connection produces a forwarded channel (§3); the channel's
own `BiStream` stays idle and its EOF (the opener dropping the
listener session) cancels the loop and tears the listener down.
### 2. The `bound` reply field (upstream ask)
The open-op reply gains an **optional, additive** field:
```json
{ "channel_id": 7, "bound": { "host": "203.0.113.9", "port": 42113 } }
```
- Present only when the establisher supplies it (bind-first listeners);
absent for dial establishers — old consumers ignore unknown reply
fields (plain JSON, alkcall `ResponseEnvelope`), so the change is
wire-additive.
- The carrier is an upstream alkcall ask (ADR-049 amendment 3:
establisher → reply projection — `Establishment` gains optional
reply fields the wrapper merges into the success output; the
`channel_id` key is wrapper-reserved). Filed in
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`.
- `bound` is informational surface, not addressing: a consumer MUST
NOT treat it as a dial target (the producer owns the backing —
ADR-001's invariant). It exists for RFC-1928-reply-class fidelity
(BND.ADDR) and observability.
### 3. The forwarded op: `channels/tunnel/forwarded`
When the listener accepts, the **producer opens a channel back toward
the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
- Op id **`channels/tunnel/forwarded`** (flavor-form, ADR-002
Amendment 1), `Sub`-typed, ALPN marker `alk/tunnel`. It is served by
the **listen-opener side** (the initiator of the listen-tunnel
open): the producer *calls* it per accept
(`open_reverse_channel`-shaped — the call plane on channel 0), the
opener's registry allocates the `channel_id`, the producer adopts it
(the ADR-047 §5 connection-owner rule — the serving side of the
forwarded call allocates; the producer-side initiator adopts) and
pumps the accepted connection's halves against the adopted channel.
- Params:
```json
{
"channel": 7,
"substrate": "tcp",
"peer": { "host": "198.51.100.4", "port": 55231 }
}
```
- **`channel`** (u32, required) — the listen channel's ID: the
correlation key. The opener knows which of its open listener
sessions the accept belongs to without a registry op or an
extra wire field; terminate-and-re-produce hubs re-map the id at
their re-produce hop naturally (the hub's re-produced forwarded
open carries the hub's channel id).
- **`substrate`** (string, required) — the listen tunnel's
substrate; shapes the forwarded channel's data-plane framing
(ADR-003), same discriminator semantics as everywhere else.
- **`peer`** (object, required) — the accepted connection's remote
address, substrate-shaped (`{host, port}` / `{path}`). This is ask
#2's carrier: reply#2-class fidelity rides the open op, the wire's
native metadata moment. Optional in kind, required in v1 — a
producer that cannot know the peer (rare; all POC'd listeners
can) omits... no: **required**; a producer that cannot determine
the peer does not serve a bind-first listener over that substrate.
- Scope: **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`** — the
opener-side grant for RECEIVING forwarded connections (ingress).
Separate from `tunnel:open` and `tunnel:direct` (ADR-006 amendment):
exposing a listener and dialing egress are different capabilities.
- The opener-side serving shape: `register_openable_with_establisher`
as-is (no establisher — the forwarded channel needs no
establishment; its data plane starts immediately), ACL-gated by the
scope, handler = correlate the open (via `channel`) to the waiting
session, accept the `BiStream`, pump against the local BIND client's
halves. The correlation registry is assembly-side (alksocks')
bookkeeping — the protocol provides the op and the params, not the
wait/quiet-state machine.
- An older opener that does not serve the forwarded op rejects the
producer's per-accept call loudly (`NOT_FOUND`) — the SSH posture.
A producer serving bind-first SHOULD prefer openers that advertise
the forwarded op (discovery, alkcall ADR-047).
### 4. Consumer API
```rust
let listener = TunnelListener::open(&client, params).await?; // reply carries `bound`
let bound = listener.bound(); // the resolved address
// ... per forwarded accept (opener side, served op):
// correlate by `channel`, accept the BiStream, pump against the
// local session's halves
listener.close(self); // or Drop — tears the listen channel + far listener down
```
`TunnelListener` is the listen-flow sibling of `TunnelSession`: it owns
the listen channel's teardown (ADR-005's ownership posture extended to
the listen flow); it presents `bound()` and the teardown API, and the
forwarded channels themselves arrive as separate adopted-side
`TunnelSession`s on the opener's manager. `TunnelSession::adopt` gains
nothing — forwarded channels are ordinary adopted channels.
### 5. The accept-first shape stays
`listen_establisher` + `AcceptQueue` (the POC #5 composed shape)
remains a valid establisher shape for producers whose resource backing
IS an accept queue (an externally-fed or pre-bound listener shared
across opens). Bind-first is the shape for producers that own the
bind decision (the common hub case; alksocks' composed BIND). The two
shapes are per-registration assembly choices over the same op — no
wire distinction beyond `bound`'s presence.
## Consequences
- **Wire-stable from the first consumer:** the `bound` reply field,
the forwarded op id, its params, and `tunnel:forwarded` are one-way
doors. The `bound` field is additive (absent = old-producer
compatible); the forwarded op is additive (old openers reject loudly).
- **Upstream coordination before the first consumer:** the
`bound`-projection ask (ADR-049 amendment 3) and the flavor-form
discovery derivation (ADR-047 amendment) land in alkcall first —
the review records both. The bind-first establisher itself is this
crate's work and does not wait on upstream (§2's `bound` is the only
upstream-gated piece).
- **`TargetHandle` does NOT gain a `peer` field.** The option-(i)
carrier from the graduation doc is superseded: with accept-as-open,
the peer rides the forwarded op's params; no process-local metadata
field, no sentinel. (If a future non-forwarded accept shape needs
metadata, revisit then — additive.)
- **The BIND reply#2 fidelity is end-to-end:** the opener-side
correlation writes reply#2 with the REAL peer from the forwarded
op's params. reply#1's `BND.ADDR` is the `bound` reply field. Both
POC stand-ins (carried `bind_addr`; sentinel `0.0.0.0:0`) retire.
- **Hub re-produce:** a hub re-producing a listen resource binds its
own listener (bind-first, its own `bound`) and re-opens forwarded
channels with its own channel ids — the metadata is per-hop
truthful, matching alkcall ADR-042's terminate-and-re-produce
posture.
- **Local-variant BIND needs none of this** (the listener and the
accept are in-process — POC #5 variant 1, unchanged); the asks and
this ADR are scoped to the composed variant.
## References
- `docs/research/tunnels-graduation.md` §3/§3.1 (the ask ledger and
the `forwarded-tcpip` alternative this ADR adopts)
- alksocks `poc-bind-findings.md` (the two asks with evidence; the
deadlock and establishment-bound findings this shape dissolves)
- alkcall ADR-047 (open ops; connection-owner allocation; the
flavor-form amendment), ADR-049 (establishment; the reply-projection
amendment), ADR-042 (hub relay — re-produce id semantics)
- alktunnels ADR-001 (params per-op amendment), ADR-002 Amendment 1
(flavor-form op ids), ADR-005 (teardown ownership — extended to
`TunnelListener`), ADR-006 (scope posture amendment)
- AGENTS.md conventions 9 (no forced binding — the bind decision is
the producer's), 13 (access control), 8 (two-pump contract — the
accept dispatcher is a per-accept `pump_bidi`, not a hand-rolled
loop over one stream)
+51 -3
View File
@@ -1,6 +1,6 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-16 (OQ-TN-15/16 promoted + resolved by ADR-007/008)
---
# alktunnels — Open Questions
@@ -240,4 +240,52 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
the wire enum's `unix` value now has a v1 implementation path.
- **Cross-references**: ADR-003, ADR-004, overview.md; alktty
`tty-local.md` §pipe mode, alktty ADR-004 (exit code on a control
chunk)
chunk)
## Theme 5: The graduation (alksocks consumer, 2026-09-15/16)
### OQ-TN-15: The direct op (dynamic-target egress)
- **Origin**: `docs/research/tunnels-graduation.md` §2 (the alksocks
graduation discussion, 2026-09-15)
- **Priority**: high
- **Status**: resolved — [ADR-007](decisions/007-direct-open-op.md)
(2026-09-16). Option (a): a second `Sub`-typed open op
`channels/tunnel/direct` on the same `alk/tunnel` ALPN, params
`{substrate, target}` (self-describing JSON target keyed by the
sibling substrate — ATYP bytes rejected: not schema-describable, no
unix path form), scope `tunnel:direct` (never implied by
`tunnel:open` — structural ACL separation), dial-only (no
listen/direct-BIND form — ingress is the listen + forwarded
surface). The op-id convention needed an upstream alkcall amendment
(ADR-047's discovery derivation is `/sub`-shaped) — filed in
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
(ask #1); the runtime was never blocked (explicit ALPN marker +
explicit ALPN argument on `open_channel`).
- **Cross-references**: ADR-001 (per-op amendment), ADR-002
Amendment 1, ADR-006 Amendment 1, wire.md, producer.md
### OQ-TN-16: Listen metadata (bound-addr discovery + peer fidelity)
- **Origin**: `docs/research/tunnels-graduation.md` §3 (the alksocks
BIND POC's two asks: bound-addr without consuming an accept; peer
metadata on accepted handles)
- **Priority**: high
- **Status**: resolved — [ADR-008](decisions/008-listen-metadata-forwarded-op.md)
(2026-09-16). The `forwarded-tcpip` shape adopted over per-ask
carriers: (1) a bind-first listen establisher — establishment ends
at bind time, so the open-op reply carries the real OS-chosen
address via an additive `bound` field (ask #1; the carrier is an
upstream alkcall ask — the establisher → reply projection, filed as
the review's ask #2; ADR-049's `#[non_exhaustive]` anticipates it),
and the address-first-vs-accept-first BIND deadlock + the 10s
establishment-bound problem dissolve with the shape; (2) the
forwarded op `channels/tunnel/forwarded` (accept-as-open) — the
producer opens a channel per accept with `{channel, substrate,
peer}` in params (ask #2's carrier; the `TargetHandle.peer` option
is superseded). The accept-first listen shape stays for
accept-queue-backed resources.
- **Cross-references**: ADR-002 Amendment 1 (the flavor-form op id),
ADR-006 Amendment 1 (`tunnel:forwarded`), ADR-005 (teardown
ownership extended to `TunnelListener`), producer.md,
consumer.md, wire.md
+58 -34
View File
@@ -1,6 +1,6 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-16 (the graduation: the direct op, listen metadata, the forwarded op — ADR-007/008)
---
# alktunnels — Overview
@@ -16,30 +16,39 @@ details are in the sibling documents ([wire.md](wire.md),
## What
`alktunnels` registers the `alk/tunnel` ALPN's open op on alkcall
channels and provides the typed consumer session:
`alktunnels` registers the `alk/tunnel` ALPN's open ops on alkcall
channels and provides the typed consumer sessions:
- **The producer half** (a `channels/tunnel/sub` open op registered
via `ChannelCore::register_openable_with_establisher`, alkcall
ADR-047/049): the establisher validates params semantically
(registry lookup of `{resource, substrate}` → backing), dials (or
accepts for) the substrate, and returns the dialed handle via
`Establishment::new(plan)`; the pump handler awaits
- **The producer half** (open ops registered via
`ChannelCore::register_openable_with_establisher`, alkcall
ADR-047/049): the establisher validates params semantically, then
dials (or binds/accepts for) the substrate, and returns the handle
via `Establishment::new(plan)`; the pump handler awaits
`alkcall::channels::pump_bidi` over the channel and the substrate
halves (ADR-050, R-02's lifetime contract).
- **The consumer half** (`TunnelSession`, ADR-005): opens tunnel
channels (`ChannelClient::open_channel` on the forward path; the
reverse path's call + adopt equivalent), splits the channel
`BiStream`, presents the substrate-shaped data plane (raw halves or
datagram codec), and owns teardown (close/join/Drop).
halves (ADR-050, R-02's lifetime contract). Three ops on the one
ALPN (ADR-002 Amendment 1): `channels/tunnel/sub` (the resource
op), `channels/tunnel/direct` (dynamic-target egress, ADR-007), and
— served by the listen-opener side — `channels/tunnel/forwarded`
(accept-as-open, ADR-008). Three establisher shapes: dial, listen
(accept-first), and bind-first listen.
- **The consumer half** (`TunnelSession` / `TunnelListener`,
ADR-005/008): opens tunnel channels (`ChannelClient::open_channel`
on the forward path; the reverse path's call + adopt equivalent),
splits the channel `BiStream`, presents the substrate-shaped data
plane (raw halves or datagram codec), and owns teardown
(close/join/Drop). `TunnelListener` is the listen-flow session
(bound-address discovery + forwarded-connection receipt).
The guiding insight (the OQ-TN-01 reframe):
> A tunnel is a resource, not an address. `params` identify a produced
> resource + substrate; the producer owns the backing. Rich addressing
> (SOCKS5 ATYP, per-datagram remotes) enters only through the
> `-D`/dynamic composition path — inside the tunnel payload, never in
> the wire.
> A tunnel is a resource, not an address. Base params identify a
> produced resource + substrate; the producer owns the backing. The
> one additive exception is the direct op (ADR-007): a consumer may
> ask the producer to dial an arbitrary target — a different
> capability, its own op and its own scope (`tunnel:direct`), never
> implied by `tunnel:open`. Rich per-datagram addressing (SOCKS5 ATYP,
> udpgw-style remotes) still enters only through the `-D`/dynamic
> composition path — inside the tunnel payload, never in the wire.
## Why
@@ -68,12 +77,16 @@ A tunnel channel's life (full detail in [wire.md](wire.md)):
1. **Open (the establishment phase).** The consumer calls
`channels/tunnel/sub` with `params = {resource, substrate}`
(ADR-001). The producer's establisher — the open op's awaited
phase (alkcall ADR-049) — resolves the resource, dials the
(ADR-001) — or `channels/tunnel/direct` with
`params = {substrate, target}` (ADR-007, dynamic egress). The
producer's establisher — the open op's awaited phase (alkcall
ADR-049) — resolves the resource (or parses the target), dials the
substrate, and returns the handle in the plan. Failure is a typed
`channel:open_failed` (`dial_failed` / `unknown_resource` /
`resource_shortage` / `handler_error` / `timeout`); the channel
never exists on the opener's side afterward.
never exists on the opener's side afterward. A bind-first listen
open completes at bind time and may carry `"bound"` in the reply
(ADR-008 §2 — the resolved address).
2. **Pump (the data plane).** Two pumps, one per direction, via
`pump_bidi` (ADR-050): each shuts the opposite sink down on
completion; half-close semantics fall out. Stream substrates ride
@@ -88,7 +101,8 @@ Direction is not in the protocol: whoever can reach the target is the
producer; whoever wants the bytes is the consumer; connection
direction (who dialed the transport) is independent of tunnel
direction (OQ-TN-03's hub model). `-L`, `-R`, and `-D` are assembly
shapes over the same open op.
shapes over the same ops; the `-R` composed flow rides the bind-first
establisher + the forwarded op (ADR-008).
## Dependencies
@@ -122,14 +136,19 @@ trivial here, since the codec is a 2-byte length prefix).
```
src/
lib.rs — re-exports; crate docs
params.rs — TunnelParams {resource, substrate} + open-op input schema (ADR-001)
params.rs — TunnelParams {resource, substrate} + open-op input schema (ADR-001);
TunnelTarget {substrate, target} + direct-op schema (ADR-007);
TunnelForwardedParams {channel, substrate, peer} (ADR-008);
the three scope constants
wire.rs — the data-plane codec: frame_datagram / DatagramReader (ADR-003)
producer.rs — the open op: tunnel_open_spec (scope-gated), establisher
shapes (dial + listen), pump handler (pump_bidi),
register_tunnel_openable (ADR-004 shapes)
consumer.rs — TunnelSession: open / adopt / stream_halves /
take_halves / send_datagram /
recv_datagram / pump_against / close / join / Drop (ADR-005)
producer.rs — the open ops: tunnel_open_spec + direct/forwarded spec builders
(scope-gated), establisher shapes (dial + listen + bind-first),
pump handler (pump_bidi / accept dispatcher),
register_tunnel_*_openable (ADR-004/007/008 shapes)
consumer.rs — TunnelSession: open / open_direct / adopt / stream_halves /
take_halves / send_datagram / recv_datagram / pump_against /
close / join / Drop (ADR-005); TunnelListener: open / bound /
forwarded-receipt / close / Drop (ADR-008)
error.rs — TunnelError + the typed open-error surface (ADR-049 §4)
local/ — the `local` feature module: real socket halves functions
```
@@ -147,7 +166,9 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [003](decisions/003-codec-and-udp-framing.md) | Data-plane codec | Raw pass-through (stream) / mandatory `[len: u16 BE]` (UDP — F-2 mandate); `len=0` is a legal empty datagram |
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend trait | Halves functions at the assembly layer; listen is an establisher shape; trait re-evaluated at the 3-consumer threshold |
| [005](decisions/005-consumer-session-owns-teardown.md) | Consumer session owns teardown | `TunnelSession` with close/join/Drop — the W3 gap closes structurally |
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1 |
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1; amended — `tunnel:direct`/`tunnel:forwarded` scopes + the hub re-produce policy sentence |
| [007](decisions/007-direct-open-op.md) | The direct open op | `channels/tunnel/direct`: dynamic-target egress, `{substrate, target}` params, `tunnel:direct` scope, dial-only — the ssh `direct-tcpip` capability with structural ACL separation |
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen metadata + the forwarded op | Bind-first establisher (reply `bound` field = BND.ADDR fidelity), `channels/tunnel/forwarded` accept-as-open (params carry `{channel, substrate, peer}` = reply#2 fidelity) — the `forwarded-tcpip` shape |
## Open Questions
@@ -155,10 +176,13 @@ Open questions are tracked in [open-questions.md](open-questions.md).
Key questions affecting this document:
- **OQ-TN-11**: resource naming collision domain + lifecycle (partially resolved)
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope))
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope) —
the policy sentence is pinned: the hub's ACL governs the final dial)
- **OQ-TN-13**: UDP truncation semantics (resolved — fail-loud, ADR-003)
- **OQ-TN-14**: unix/stdio placement (resolved — unix in via
`local`, stdio out: alktty's pipe mode owns process stdio)
- **OQ-TN-15**: the direct op (resolved — ADR-007)
- **OQ-TN-16**: listen metadata (resolved — ADR-008)
## References
+175 -22
View File
@@ -1,22 +1,28 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-16 (the direct op, the bind-first establisher, the forwarded op — ADR-007/008)
---
# alktunnels — Producer Half
The producer half: the `channels/tunnel/sub` open op — its spec, its
establisher (dial + listen shapes), its pump handler, and its
registration. The producer is whoever can reach the tunnel target;
connection direction is irrelevant (OQ-TN-03's hub model). The reverse
POC validated the producer running on the connect side of its own
connection (`from_connection_with_serving`).
establishers (dial + listen shapes), its pump handler, and its
registration — plus the sibling ops on the same ALPN: the direct op
(`channels/tunnel/direct`, dynamic-target egress) and the forwarded op
(`channels/tunnel/forwarded`, accept-as-open). The producer is whoever
can reach the tunnel target; connection direction is irrelevant
(OQ-TN-03's hub model). The reverse POC validated the producer running
on the connect side of its own connection
(`from_connection_with_serving`).
Design decisions: [ADR-001](decisions/001-open-params-layout.md)
(params), [ADR-002](decisions/002-alpn-strategy.md) (ALPN),
[ADR-003](decisions/003-codec-and-udp-framing.md) (codec),
[ADR-004](decisions/004-no-backend-trait.md) (no backend trait). This
document is the normative WHAT.
[ADR-004](decisions/004-no-backend-trait.md) (no backend trait),
[ADR-007](decisions/007-direct-open-op.md) (the direct op),
[ADR-008](decisions/008-listen-metadata-forwarded-op.md) (listen
metadata, bind-first, the forwarded op). This document is the
normative WHAT.
## The Open-Op Spec
@@ -50,6 +56,35 @@ posture — the reverse POC's W2): the dispatcher reads through the
shared `Arc<OperationRegistry>` per dispatch. Late registration is a
supported assembly shape, not a race.
## The Direct Op (`channels/tunnel/direct`)
Dynamic-target egress (ADR-007): the same ALPN, a second `Sub`-typed
open op whose establisher dials a per-call target instead of resolving
a resource. One ALPN, two ops, two scopes — dial-what-the-producer-
registered (`tunnel:open`) and dial-anything (`tunnel:direct`) are
different capabilities.
```text
operation id: channels/tunnel/direct
op type: Sub (same reply/wrapper shape as …/sub)
ALPN marker: alk/tunnel (ChannelOpenSpec)
visibility: External
input schema: { substrate: enum [tcp, udp, unix] (required),
target: object, per-substrate sub-schema (required) }
output schema: { channel_id: integer > 0 }
access control: required_scopes: ["tunnel:direct"]
description: SHOULD carry a human-readable hint (dynamic-target egress)
```
- **No registry lookup** — the establisher parses `target` against
`substrate` (malformed → `invalid_input`-class; `unknown_resource`
can never fire) and injects the parsed `TunnelTarget` into the same
assembly-provided `DialFn` the base op's backings resolve to.
- **Dial-only** — no listen/direct-BIND form; the ingress capability
is the listen + forwarded surface (ADR-008).
- Registration: `register_tunnel_direct_openable` (same wrapper, same
plan-flow; the spec builder is public for assembly variants).
## The Establisher
The establisher is the open op's awaited, bounded establishment phase
@@ -62,7 +97,7 @@ reverse POC F-1, documented upstream). On failure:
`EstablishmentError` (the typed reason codes) → the wrapper tears the
channel down and replies `channel:open_failed`.
**Two shapes** (both return the same plan-flow; ADR-004):
**Three shapes** (all return the same plan-flow; ADR-004):
### 1. Dial establisher (the `-L`/forward shape)
@@ -79,6 +114,10 @@ never sees a socket type. Failure mapping:
| establisher-internal failure | `handler_error` |
| the bound exceeded | `timeout` (wrapper-level) |
The direct establisher is this shape minus the lookup row: target
malformed for the substrate is the schema/`invalid_input`-class
failure (ADR-007 §4's table).
### 2. Listen establisher (the producer-side listener variant)
For a producer whose resource is a LISTENER (SSH `-R` far-side
@@ -91,6 +130,48 @@ mapping: an empty queue during establishment is `resource_shortage`;
a closed listener is `dial_failed`-class. No new wire surface — the
listen variant is plan-flow with a different halves source.
**The accept-first shape stays** (ADR-008 §5): for producers whose
resource backing IS an accept queue (an externally-fed or pre-bound
listener shared across opens), this remains the correct shape — the
POC #5 composed-BIND shape. Its limits (no `bound` in the reply — the
accept hasn't happened at establishment; the POC's
establishment-bound-vs-wait problem) are inherent to accepting-first.
### 3. Bind-first listen establisher (the `-R`/ingress shape, ADR-008)
For a producer that OWNS the bind decision (the common hub case;
alksocks' composed BIND): the establisher **binds the listener** (via
the assembly-provided listen function — the protocol never binds,
OQ-TN-04) and returns the listener handle as the plan payload.
Establishment is fast — bounded by the bind, not by any app-server
dial:
- **The establishment phase ends at bind time**, so the open-op reply
can carry the REAL OS-chosen address: the establisher contributes
`"bound": {host, port}` (or `{path}`) to the reply — the
establisher → reply projection is an upstream alkcall ask (ADR-049
amendment 3; `Establishment` gains optional reply fields, the
`channel_id` key wrapper-reserved — see
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`,
ask #2). Until upstream lands, the field is simply absent.
- **The pump handler for this shape is an accept dispatcher**, not
`pump_bidi`: it accepts the channel's `BiStream` (the R-02
contract), then runs the assembly-provided accept loop — each
accepted connection produces a forwarded-channel open toward the
listen opener (§The Forwarded Op below); the accepted halves pump
against the adopted channel. The listen channel's own `BiStream`
stays idle; its EOF (the opener dropping the listener session)
cancels the loop and tears the listener down.
- Error mapping: unbindable/held port → `dial_failed`-class (the
bind IS the dial here); bind resource exhaustion →
`resource_shortage`; the rest of the table unchanged.
- The address-first-vs-accept-first BIND deadlock (the alksocks POC's
one real discovery) DISSOLVES in this shape: the open completes at
bind time, reply#1-class metadata (the `bound` field) precedes any
app-server dial, and the accept wait lives in the pump phase —
teardown-governed, not establishment-bounded (the 10s bound problem
dissolves with it).
**Identity:** the establisher receives the **per-call opener
identity** (the dispatch-resolved identity overlaid onto the
install-time context — alkcall 0.7.0 CF-006). On hub-forwarded opens
@@ -127,11 +208,45 @@ JoinHandle<()>`):
at runtime. Hand-rolling the two-pump shape is out (ADR-050).
4. UDP: the plan payload is the FRAMED adapter (ADR-003) — the pump
is unchanged; the codec lives at the substrate/pump boundary.
5. **Bind-first listen handler (the accept dispatcher, ADR-008 §1):**
the plan payload is the listener; after `accept_bi()`, the handler
runs the accept loop (assembly-provided) concurrently with holding
the channel's `BiStream` — each accepted connection produces a
forwarded-channel open toward the listen opener. The listen
channel's own stream stays idle (no pumps on it); its EOF cancels
the loop (the listener tears down with the session). Per-accept
data planes are still `pump_bidi` (convention 8 — per-channel,
never a hand-rolled loop over one stream).
**The handler is substrate-agnostic by construction:** it cannot know
whether the halves came from TCP, UDP, a unix socket, or an
in-process pipe, and must not (AGENTS.md convention 7).
## The Forwarded Op (opener side, ADR-008 §3)
Accept-as-open: when the bind-first listener accepts, the PRODUCER
opens a channel back toward the listen opener. The opener SERVES the
op (`channels/tunnel/forwarded`, scope `tunnel:forwarded` — ingress;
no establisher, the data plane starts immediately):
1. The producer's per-accept open carries `{channel, substrate, peer}`
— the listen channel's ID (correlation), the substrate, the
accepted connection's remote address (reply#2-class fidelity's
carrier).
2. The opener's registry allocates the `channel_id`; the producer
adopts it (`TunnelSession::adopt`) and pumps the accepted halves
against it (`pump_against`).
3. The opener-side handler correlates the open (via `channel`) to the
waiting session (assembly-side bookkeeping — the protocol provides
the op and params, not the wait state machine), accepts the
`BiStream`, and pumps against the local session's halves.
4. Identity: the forwarded op's opener-side gate sees the PRODUCER's
per-call identity (CF-005 precedence — the producer may carry an
`auth_token` when it is itself forwarding); the peer address in
params is producer-asserted data, not transport truth — the ACL
trusts the producer's grant, the application treats `peer` as
metadata.
## Registration API (the spec surface)
```rust
@@ -146,6 +261,25 @@ pub fn register_tunnel_openable(
auth: AuthContext, // the install-time context (per-call overlay is upstream's)
dial: DialFn, // substrate halves function (ADR-004; `local` feature or assembly)
) -> Result<(), String>
pub fn register_tunnel_direct_openable(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
dial: DialFn, // the SAME DialFn the base op uses; receives the parsed TunnelTarget
) -> Result<(), String>
pub fn register_tunnel_listen_openable( // accept-first (shape 2, unchanged)
... // as today: listen_establisher + AcceptQueue
)
pub fn register_tunnel_bindfirst_openable( // bind-first (shape 3, ADR-008)
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
bind: BindFn, // substrate bind function -> (listener, bound addr)
accept: AcceptFn, // per-accept: open the forwarded channel, pump
) -> Result<(), String>
```
- `ResourceRegistry` (assembly construct, OQ-TN-11): maps
@@ -153,24 +287,36 @@ pub fn register_tunnel_openable(
candidate; the collision domain is the producer's registry
(ADR-001 — two producers on one connection may expose the same
resource name independently).
- The scope constant `TUNNEL_OPEN_SCOPE = "tunnel:open"`; the open-op
spec builder is public so assembly layers can compose variants
(e.g. extra ACL fields) without forking the wire shape.
- The scope constants `TUNNEL_OPEN_SCOPE = "tunnel:open"`,
`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`, and
`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"` (ADR-006 Amendment 1);
the open-op spec builders are public so assembly layers can compose
variants (e.g. extra ACL fields) without forking the wire shape.
- The pump handler factory is public for the same reason.
## Access control posture
Tunnels reach local networks — the open gate is the security boundary
(OQ-TN-08):
(OQ-TN-08, ADR-006 + Amendment 1):
- The registry runs `AccessControl` before the establisher (the ACL
rides `register_openable`; free via alkcall).
- `TUNNEL_OPEN_SCOPE` is the base gate; the assembly layer MAY add
resource-scoped checks via the establisher (ownership on the
resource name) — the protocol provides the seam, not the policy.
- No target allowlists, no tunnel-specific policy machinery in v1 —
the far side owns the resource (OQ-TN-08 resolution); discovery is
the ACL-filtered ops listing.
- `TUNNEL_OPEN_SCOPE` is the base gate; `TUNNEL_DIRECT_SCOPE` gates
dynamic-target egress (never implied by `tunnel:open`);
`TUNNEL_FORWARDED_SCOPE` gates ingress (receiving forwarded
connections) on the opener side. Three scopes, three capabilities.
- The assembly layer MAY add resource-scoped checks via the
establisher (ownership on the resource name) — the protocol
provides the seam, not the policy. For the direct op the seam is
the parsed `TunnelTarget` (a deployment MAY enforce target
allowlists in its establisher — policy, not protocol).
- No target allowlists in the PROTOCOL, no tunnel-specific policy
machinery in v1 — the far side owns the resource (OQ-TN-08
resolution); discovery is the ACL-filtered ops listing.
- **Hub re-produce policy (the security model sentence, ADR-006
Amendment 1):** a hub re-producing the direct op applies ITS OWN
target policy at the re-produce hop — the hub's ACL governs the
final dial. The hub is the network boundary.
## Open Questions
@@ -179,12 +325,19 @@ Tunnels reach local networks — the open gate is the security boundary
candidate)
- **OQ-TN-14**: resolved (split: unix in, stdio out — alktty's pipe
mode owns process stdio; see open-questions.md)
- **OQ-TN-15/16**: resolved (ADR-007/008 — the direct op, listen
metadata, the forwarded op)
## References
- [wire.md](wire.md) (the open op's wire surface),
- [wire.md](wire.md) (the ops' wire surface),
[overview.md](overview.md) (module map)
- Forward POC `docs/research/poc-summary.md`; reverse POC
`docs/research/reverse-poc-summary.md` (both producer shapes ran)
- `docs/research/tunnels-graduation.md` (the graduation research;
§3.1 is the shape bind-first + the forwarded op adopt)
- alkcall ADR-047/049 (open ops + establishment), ADR-050 (`pump_bidi`),
ledger CF-005/CF-006 (identity precedence + per-call opener)
ledger CF-005/CF-006 (identity precedence + per-call opener)
- Upstream asks: `alkcall/docs/reviews/
008-tunnels-graduation-upstream-review.md` (flavor-form discovery
derivation; the establisher → reply `bound` projection)
+116 -7
View File
@@ -1,6 +1,6 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-16 (the graduation wire surface: direct + forwarded ops, `bound` reply field — ADR-007/008)
---
# alktunnels — Wire Format
@@ -13,7 +13,8 @@ its own 8-byte header transparently (alknet ADR-093); the tunnel
protocol owns everything inside the `BiStream`.
Design decisions live in the ADRs ([001](decisions/001-open-params-layout.md),
[002](decisions/002-alpn-strategy.md), [003](decisions/003-codec-and-udp-framing.md));
[002](decisions/002-alpn-strategy.md), [003](decisions/003-codec-and-udp-framing.md),
[007](decisions/007-direct-open-op.md), [008](decisions/008-listen-metadata-forwarded-op.md));
this document is the normative WHAT.
## The Open Op (`channels/tunnel/sub`)
@@ -49,6 +50,8 @@ this document is the normative WHAT.
- Success: `{ "channel_id": <u32> }` — the channel is open, the
establishment phase (dial) succeeded, the pump handler is running.
Bind-first listen opens MAY add `"bound"` (§The Listen Open Op's
Reply below).
- Failure: a typed `channel:open_failed` call error with
`details: { reason, message }` — `reason ∈ dial_failed` /
`unknown_resource` / `resource_shortage` / `handler_error` /
@@ -64,6 +67,101 @@ this document is the normative WHAT.
the establisher and pump handler receive the per-call opener
identity (CF-006).
## The Direct Op (`channels/tunnel/direct`)
Dynamic-target egress (ADR-007) — "dial what I name," not "dial what
the producer registered." `Sub`-typed, ALPN marker `alk/tunnel`; the
same establishment phase and wrapper as `…/sub`. Scope gate:
`["tunnel:direct"]` (`TUNNEL_DIRECT_SCOPE`) — **never implied by
`tunnel:open`** (ADR-006 Amendment 1).
### Input (params)
```json
{
"substrate": "tcp",
"target": { "host": "db.internal", "port": 5432 }
}
```
- `substrate`: string, required, enum `["tcp", "udp", "unix"]` — the
same discriminator semantics as `…/sub` (ADR-001); selects the
data-plane framing.
- `target`: object, required, keyed by the sibling `substrate` —
- `tcp`/`udp` → `{ "host": <string>, "port": <u16> }` (domain-form
hosts stay unresolved on the wire — producer-side resolution, the
ssh `-D` semantic);
- `unix` → `{ "path": <string> }`.
- `input_schema` pins both required; the target sub-schema is
per-substrate, validated at establisher time. Malformed targets are
the `invalid_input`-class failure; **`unknown_resource` can never
fire on the direct op** (no registry lookup).
### Reply / errors
- Success: `{ "channel_id": <u32> }` — identical semantics to
`…/sub`; no `bound` field (dial establishers do not bind).
- Failure: the same typed surface as `…/sub` minus `unknown_resource`,
plus the schema/`invalid_input` class for malformed targets.
## The Listen Open Op's Reply — the `bound` Field
A bind-first listen open (ADR-008 §1, served by `…/sub` with the
bind-first establisher) MAY add an optional, additive reply field:
```json
{ "channel_id": 7, "bound": { "host": "203.0.113.9", "port": 42113 } }
```
- Substrate-shaped like the direct op's target (`{host, port}` /
`{path}`); the REAL OS-chosen address (port `0` resolved).
- Present only when the establisher supplies it; absent for dial
establishers and accept-first listen opens. Old consumers ignore
unknown reply fields (plain JSON) — wire-additive.
- Informational surface, not addressing: a consumer MUST NOT treat
`bound` as a dial target (the producer owns the backing — ADR-001).
It exists for RFC-1928-reply-class fidelity (BND.ADDR) and
observability.
## The Forwarded Op (`channels/tunnel/forwarded`)
Accept-as-open (ADR-008 §3): when a bind-first listener accepts, the
PRODUCER opens a channel back toward the listen-opener side. The
producer *calls* the op (the opener SERVES it — the ADR-047 §5
connection-owner rule: the serving side allocates the `channel_id`,
the producer-side initiator adopts). `Sub`-typed, ALPN marker
`alk/tunnel`; no establisher (the data plane starts immediately).
Scope gate (opener side): `["tunnel:forwarded"]`
(`TUNNEL_FORWARDED_SCOPE`) — ingress, a separate grant from
`tunnel:open`/`tunnel:direct` (ADR-006 Amendment 1).
### Input (params)
```json
{
"channel": 7,
"substrate": "tcp",
"peer": { "host": "198.51.100.4", "port": 55231 }
}
```
- `channel`: integer > 0, required — the listen channel's ID (the
correlation key; hubs re-map it at their re-produce hop).
- `substrate`: string, required, enum — the listen tunnel's
substrate; shapes the forwarded channel's data-plane framing.
- `peer`: object, required — the accepted connection's remote address,
substrate-shaped (`{host, port}` / `{path}`). Reply#2-class
fidelity's carrier (ask #2).
### Reply / errors
- Success: `{ "channel_id": <u32> }` — the opener-allocated ID the
producer adopts, then pumps the accepted halves against.
- Failure: a producer-side accept correlation miss is the typed
`channel:open_failed`/`handler_error`-class error; an opener without
the scope is `FORBIDDEN`; an old opener that does not serve the op
rejects loudly (`NOT_FOUND` — the SSH posture).
## The Data Plane
One data stream per direction on one channel — the channel ID is the
@@ -162,16 +260,27 @@ UDP substrate, one direction's wire view:
- **OQ-TN-13**: resolved (fail-loud, ADR-003) — see the Truncation
bullet
- **OQ-TN-02**: resolved — see the ADR-003 mandate
- **OQ-TN-15**: resolved — see ADR-007 (the direct op)
- **OQ-TN-16**: resolved — see ADR-008 (the `bound` field + the
forwarded op)
## References
- [ADR-001](decisions/001-open-params-layout.md) (params),
[ADR-002](decisions/002-alpn-strategy.md) (ALPN),
- [ADR-001](decisions/001-open-params-layout.md) (params, per-op
amendment),
[ADR-002](decisions/002-alpn-strategy.md) (ALPN + flavor-form op
ids),
[ADR-003](decisions/003-codec-and-udp-framing.md) (codec),
[ADR-005](decisions/005-consumer-session-owns-teardown.md) (session
teardown)
teardown),
[ADR-007](decisions/007-direct-open-op.md) (the direct op),
[ADR-008](decisions/008-listen-metadata-forwarded-op.md) (listen
metadata + the forwarded op)
- Forward POC `docs/research/poc-summary.md` (codec + sentinel
layering, 17 tests); reverse POC
`docs/research/reverse-poc-summary.md` (F-2 mandate, W4 half-close)
- alkcall ADR-047 (open ops), ADR-049 (establishment/typed errors),
ADR-050 (`pump_bidi`); alknet ADR-071/093 (channels wire)
ADR-050 (`pump_bidi`); alknet ADR-071/093 (channels wire)
- `docs/research/tunnels-graduation.md` (the graduation research the
new ops implement); the upstream asks:
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
+34 -2
View File
@@ -1,10 +1,42 @@
---
status: draft
last_updated: 2026-09-15 (initial draft — the alktunnels graduation discussion)
status: decided
last_updated: 2026-09-16 (§2 → ADR-007; §3/§3.1 → ADR-008 — the leans are pinned, see the status note)
---
# alksocks research — graduating proxy surface into alktunnels
**Status note (2026-09-16).** Phase 1 decided both asks; this doc is
the research record, the ADRs are now normative:
- **§2 (the direct op) →
[ADR-007](../architecture/decisions/007-direct-open-op.md)** —
option (a) confirmed, with one refinement: the `target` encoding is
a self-describing JSON object keyed by the sibling `substrate`
field (`{host, port}` / `{path}`), not the ATYP byte string — the
registry's `input_schema` validation (alkcall ADR-047) needs
schema-describable params, and the unix path form falls out for
free. Op id `channels/tunnel/direct`, scope `tunnel:direct`,
dial-only.
- **§3/§3.1 (listen metadata) →
[ADR-008](../architecture/decisions/008-listen-metadata-forwarded-op.md)** —
the §3.1 `forwarded-tcpip` alternative ADOPTED over the per-ask
carriers: a bind-first listen establisher (reply carries the real
`bound` address — ask #1; also dissolves the POC's deadlock and
10s-bound findings) + the forwarded op `channels/tunnel/forwarded`
with `{channel, substrate, peer}` params (ask #2). The
`TargetHandle.peer` option (§3 ask #2, option i) is superseded.
- **A discovery this research missed:** alkcall ADR-047 pins
discovery's ALPN derivation to exactly `…/sub`/`…/pub` op-id shapes
(`derive_alpn_from_op_name`) — flavor-form op ids (both new ops)
need an upstream alkcall amendment before the first consumer. Filed:
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`.
The upstream ask ledger (§5) is now: items 1–2 pinned by ADR-007/008
(implementation pending the two upstream alkcall asks), item 3
unchanged (fast-socks5, courtesy). The Phase 7 "open items" are all
resolved except OQ-SK-08's alksocks-side decision (item 3 of the ask
ledger), which is unchanged by this.
The downstream motivation (2026-09-15 discussion): alksocks' three
commands (CONNECT / BIND / UDP ASSOCIATE) need only a few alktunnels
additions, and those additions are generally useful — they are the