docs+tasks: ADR-008 Amendment 1 (correlation token); decompose the graduation into tasks/tunnels

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).
This commit is contained in:
2026-09-18 14:26:21 +00:00
parent c31dd5e100
commit f315b0e0ca
13 changed files with 1153 additions and 69 deletions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-09-16 (`open_direct`, `TunnelListener`, the forwarded-op serving side — ADR-007/008)
last_updated: 2026-09-18 (`open_direct`, `TunnelListener`, the forwarded-op serving side — ADR-007/008 as amended: the correlation token)
---
# alktunnels — Consumer Half
@@ -107,6 +107,7 @@ 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 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
```
@@ -116,18 +117,23 @@ listener.close(self); // or Drop — tears the listen channel + far li
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).
a dial target) and `"listen"` (ADR-008 Amendment 1 — the
correlation token the producer minted).
- **`bound()`** — `Option`-shaped: `Some` when the producer supplied
the field (bind-first), `None` otherwise (accept-first, dial
establishers, or old producers). reply#1-class fidelity (BND.ADDR).
- **`listen()`** — `Option<u32>`: the correlation token (bind-first
producers). The opener-side correlation registry — token → waiting
session — is assembly-side bookkeeping (consumer.md's posture:
the protocol provides the op and params, not the wait state
machine); `TunnelListener` hands the assembly the key.
- **The forwarded side:** each accepted connection at the far
listener arrives as a producer-initiated
`channels/tunnel/forwarded` open that the opener SERVES
(`tunnel:forwarded` scope). 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.
(`tunnel:forwarded` scope), its `listen` param matching the token.
The opener-side assembly correlates the open to the waiting
session, adopts the allocated channel (`TunnelSession::adopt`),
and pumps against the local BIND client's halves.
- **`peer` fidelity:** the forwarded op's params carry the accepted
connection's real remote address (reply#2-class fidelity — the
alksocks POC's sentinel retires). Producer-asserted metadata, not

View File

@@ -2,7 +2,8 @@
## Status
Accepted (2026-09-16)
Accepted (2026-09-16); amended 2026-09-18 (the correlation token —
`listen` reply field + forwarded-params key, see Amendment 1)
## Context
@@ -53,6 +54,19 @@ accept-first shape (which stays — see §5):
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 mints a correlation token** (Amendment 1): a fresh
u32 unique per open on this producer, carried twice — inside the
plan (the pump handler needs it to correlate per-accept forwarded
opens) and as the additive `listen` reply field (§2). The original
§3 key (the listen channel's ID) is superseded: the serving-side
open-op wrapper never exposes the allocated channel ID to the
establisher or handler (alkcall 0.8.0's `OpenHandler` surface), and
a generic hub relay forwards params untouched (it strips/rewrites
only the reply's `channel_id`), so a spoke's forwarded open would
carry an id the end opener never allocated. A producer-minted token
rides both hops untouched — reply fields pass through relays
per-hop-truthful (alkcall ADR-051), and the same minted token
appears in every hop's reply.
- 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
@@ -84,6 +98,14 @@ The open-op reply gains an **optional, additive** field:
**Landed in alkcall 0.8.0** (review 008 U-2, ADR-049 amendment 3:
`Establishment::with_reply_field` + the wrapper merge with the
reserved-key guard).
- **`listen` — the correlation token (Amendment 1).** A bind-first
establisher also contributes `"listen": <u32>`: the token the
forwarded op's params key on (§3, as amended). Same projection
carrier, same additive posture — a second reply field on the same
opens that carry `bound`. `bound` and `listen` are independent
fields (an accept-first establisher could supply `listen`-shaped
correlation too — the accept queue's establisher mints its own —
but v1 pins the pair on bind-first only).
- `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
@@ -107,18 +129,33 @@ the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
```json
{
"channel": 7,
"listen": 42113,
"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).
- **`listen`** (u32, required) — the correlation token the
bind-first establisher minted and returned in the listen open's
reply (§2, as amended). The opener matches it against the
`listen` field of its open listener sessions — a `HashMap<u32, ..>`
on the opener side; no registry op, no extra wire field. The
token space is the producer's: fresh per open, unique on that
producer (the opener-side map is keyed by the producer's token,
and one opener's map may hold tokens from several producers — a
hash collision across producers is the opener's bookkeeping
choice, e.g. keying `(token)` uniquely or `(producer, token)`;
v1 leaves it assembly-side). **Amends the original `channel`
key** (the listen channel's ID): that semantic is unimplementable
as pinned — the serving-side wrapper never exposes the allocated
channel ID to the establisher or handler (alkcall 0.8.0's
`OpenHandler` surface), and generic hub relays forward params
untouched, so a spoke's forwarded open would carry an id the end
opener never allocated (§1's Amendment 1 note has the full
rationale). Tokens minted by the producer ride relays untouched:
the reply field passes through per-hop truthful (alkcall ADR-051),
and the forwarded open carries the same minted value at every
hop.
- **`substrate`** (string, required) — the listen tunnel's
substrate; shapes the forwarded channel's data-plane framing
(ADR-003), same discriminator semantics as everywhere else.
@@ -136,10 +173,11 @@ the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
- 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
scope, handler = correlate the open (via `listen`, the correlation
token) 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
halves. The correlation registry is assembly-side 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.
@@ -149,10 +187,10 @@ the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
### 4. Consumer API
```rust
let listener = TunnelListener::open(&client, params).await?; // reply carries `bound`
let listener = TunnelListener::open(&client, params).await?; // reply carries `bound` + `listen`
let bound = listener.bound(); // the resolved address
// ... per forwarded accept (opener side, served op):
// correlate by `channel`, accept the BiStream, pump against the
// correlate by `listen`, accept the BiStream, pump against the
// local session's halves
listener.close(self); // or Drop — tears the listen channel + far listener down
```
@@ -162,7 +200,10 @@ 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.
nothing — forwarded channels are ordinary adopted channels. The
opener-side correlation registry (token → waiting listener session)
is assembly-side bookkeeping (consumer.md's posture); the crate's
`TunnelListener` type provides the `listen` token it needs to key it.
### 5. The accept-first shape stays
@@ -176,18 +217,20 @@ 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
- **Wire-stable from the first consumer:** the `bound` and `listen`
reply fields, the forwarded op id, its params, and `tunnel:forwarded`
are one-way doors. The reply fields are additive (absent = old-producer
compatible); the forwarded op is additive (old openers reject loudly).
- **Upstream coordination resolved:** the `bound`-projection ask
(ADR-049 amendment 3) and the flavor-form discovery derivation
(ADR-047 amendment) **landed in alkcall 0.8.0** (review 008, Units
12); the in-tree `ChannelRelay` + `HubLegTemplate` (ADR-051) came
with them — the hub re-produce machinery §6 assumed assembly-layer
is now upstream, and the relay passes `bound` through per-hop
truthful. The bind-first establisher is this crate's remaining
implementation work (tasks/tunnels).
is now upstream, and the relay passes reply fields through per-hop
truthful (which is what makes the Amendment 1 token compose through
hubs with zero tunnel-specific relay behavior). The bind-first
establisher is this crate's remaining implementation work
(tasks/tunnels).
- **`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
@@ -198,10 +241,13 @@ wire distinction beyond `bound`'s presence.
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
own listener (bind-first, its own `bound` + minted `listen` token)
and re-opens forwarded channels with the same token — the spoke's
forwarded opens carry the hub's token untouched (params are not
rewritten by the generic relay), and the metadata is per-hop
truthful, matching alkcall ADR-042's terminate-and-re-produce
posture.
posture. The end opener correlates on the hub's minted token — the
value it read from its own open's reply.
- **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.
@@ -221,4 +267,62 @@ wire distinction beyond `bound`'s presence.
- 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)
loop over one stream)
## Amendment 1 (the correlation token, 2026-09-18)
The decision stands — bind-first establisher, accept-as-open, the
`bound` reply field, the forwarded op. What changes: the forwarded
op's correlation key (§3's `channel` param) and the bind-first
establisher's reply surface (§2 gains `listen`).
- **The `channel`-as-listener-channel-ID key is superseded by a
producer-minted token.** Two facts forced the change, both found
while decomposing the implementation against the landed alkcall
0.8.0 surface:
1. **The serving side never learns its own channel ID.** The
open-op wrapper allocates the channel and replies with its ID,
but the `OpenHandler`/establisher surface is
`Fn(Value, Option<ChannelPlan>, Connection, AuthContext)` — no
ID anywhere. The §3 semantic ("the opener knows … without a
registry op") required the producer's accept dispatcher to know
its own listen channel's ID to stamp into forwarded opens;
unimplementable without a new upstream surface. (Filing one —
exposing the allocated ID to handlers — was considered and
rejected: it is an alkcall-surface change for a correlation
need this crate can solve in-protocol, and it would still not
fix (2).)
2. **Generic hub relays do not rewrite params.** The in-tree
`ChannelRelay` (ADR-051) forwards the open-op input untouched —
it strips/rewrites only the reply's `channel_id`. A spoke's
forwarded opens would carry the spoke-side listen channel's ID,
which the end opener never allocated and cannot correlate ("hubs
re-map the id naturally" assumed tunnel-specific relay behavior
the generic machinery deliberately does not have).
- **The token:** the bind-first establisher mints a fresh u32 per
open, unique on that producer; carries it in the plan (the accept
dispatcher reads it) and contributes it as the additive `listen`
reply field via the same `Establishment::with_reply_field` carrier
`bound` rides. Both fields pass through relays untouched
(reply fields ride per-hop-truthful, ADR-051), and the forwarded
open's `listen` param carries the minted token at every hop — the
end opener correlates on the value from its own open's reply.
- **Why not a consumer-minted token:** the opener choosing the token
and passing it in the listen open's params would work on the direct
path, but fails hub re-produce the same way (the hub's own
bind-first open is a NEW open — it cannot adopt the end opener's
token without a pass-through mechanism, which is exactly the
params-rewriting relays don't do). Producer-minted is the direction
information already flows (producer → opener via reply fields);
no new flow is needed.
- **Token lifecycle:** minted at establisher time (one per open —
two opens never share a token, so the opener's correlation map
needs no session disambiguation beyond the token); retired with
the listener session (the token's only consumer is the per-accept
forwarded opens of that listener). Uniqueness is per-producer;
the opener-side map keys on the token as read from its own reply.
- **Scope names, op ids, `bound`, `peer` — unchanged.** The wire
delta vs the pre-amendment ADR is exactly: the reply gains the
optional `listen` field, the forwarded params rename `channel`
`listen` with token semantics. Nothing else shipped against either
(no consumer exists), so the change is pre-consumer and legal.

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-09-16 (OQ-TN-15/16 promoted + resolved by ADR-007/008)
last_updated: 2026-09-18 (OQ-TN-16 updated — ADR-008 Amendment 1: the correlation token)
---
# alktunnels — Open Questions
@@ -280,22 +280,30 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
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 the
establisher → reply projection — filed as the review's ask #2 and
**landed in alkcall 0.8.0** as ADR-049 amendment 3,
(2026-09-16; **amended 2026-09-18** — Amendment 1). 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 the establisher →
reply projection — filed as the review's ask #2 and **landed in
alkcall 0.8.0** as ADR-049 amendment 3,
`Establishment::with_reply_field` + `open_channel_with_reply`),
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,
producer opens a channel per accept with `{listen, 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. Bonus from the same release: the
in-tree `ChannelRelay` + `HubLegTemplate` (alkcall ADR-051) pass
`bound` through the hub per-hop truthful (ADR-008 §6 realized).
is superseded). Amendment 1: the correlation key is a
**producer-minted token** (`listen` reply field + forwarded
param), not the listen channel's ID — the serving-side handler
never sees its own channel ID (alkcall 0.8.0's `OpenHandler`
surface) and generic relays don't rewrite params, so a
producer-minted token (which rides reply fields and params through
hubs untouched) is the only key that works end-to-end. The
accept-first listen shape stays for accept-queue-backed resources.
Bonus from the same release: the in-tree `ChannelRelay` +
`HubLegTemplate` (alkcall ADR-051) pass `bound`/`listen` through
the hub per-hop truthful (ADR-008 §6 realized).
- **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,

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-09-16 (the graduation: the direct op, listen metadata, the forwarded op — ADR-007/008)
last_updated: 2026-09-18 (the graduation: the direct op, listen metadata, the forwarded op — ADR-007/008 as amended: the correlation token)
---
# alktunnels — Overview
@@ -85,8 +85,9 @@ A tunnel channel's life (full detail in [wire.md](wire.md)):
`channel:open_failed` (`dial_failed` / `unknown_resource` /
`resource_shortage` / `handler_error` / `timeout`); the channel
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).
open completes at bind time and may carry `"bound"` (the resolved
address) and `"listen"` (the correlation token) in the reply
(ADR-008 §2/Amendment 1).
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
@@ -140,7 +141,7 @@ src/
lib.rs — re-exports; crate docs
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);
TunnelForwardedParams {listen, substrate, peer} (ADR-008 as amended);
the three scope constants
wire.rs — the data-plane codec: frame_datagram / DatagramReader (ADR-003)
producer.rs — the open ops: tunnel_open_spec + direct/forwarded spec builders
@@ -170,7 +171,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [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; 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 |
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen metadata + the forwarded op | Bind-first establisher (reply `bound` field = BND.ADDR fidelity; `listen` = the correlation token), `channels/tunnel/forwarded` accept-as-open (params carry `{listen, substrate, peer}` = reply#2 fidelity) — the `forwarded-tcpip` shape; amended — producer-minted correlation token supersedes the channel-ID key |
## Open Questions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-09-16 (the direct op, the bind-first establisher, the forwarded op — ADR-007/008)
last_updated: 2026-09-18 (the direct op, the bind-first establisher, the forwarded op — ADR-007/008 as amended: the correlation token)
---
# alktunnels — Producer Half
@@ -153,6 +153,15 @@ dial:
`Establishment::with_reply_field`; the `channel_id` key is
wrapper-reserved). **Landed in alkcall 0.8.0** (review 008 U-2);
consumers read it via `ChannelClient::open_channel_with_reply`.
- **The establisher mints the correlation token** (ADR-008
Amendment 1): a fresh u32 per open, contributed as the `"listen"`
reply field (the same projection) and carried in the plan — the
accept dispatcher reads it from the plan and stamps it into each
forwarded open's `listen` param. The plan carries
`{listener, token}` (a bind-first plan type); the token is
relay-transparent by construction (reply fields and params both
pass hubs untouched — alkcall ADR-051; ADR-008 Amendment 1 has the
why-superseded of the channel-ID key).
- **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
@@ -228,14 +237,16 @@ 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
1. The producer's per-accept open carries `{listen, substrate, peer}`
— the correlation token from the plan (the value the establisher
minted; the opener matches it against the `listen` field of its
open listener sessions — ADR-008 Amendment 1), 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
3. The opener-side handler correlates the open (via `listen`) 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.
@@ -280,6 +291,7 @@ pub fn register_tunnel_bindfirst_openable( // bind-first (shape 3, ADR-008)
accept: AcceptFn, // per-accept: open the forwarded channel, pump
) -> Result<(), String>
```
```
- `ResourceRegistry` (assembly construct, OQ-TN-11): maps
`(resource, substrate)` → backing. The POCs' `HashMap` shape is the

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-09-16 (the graduation wire surface: direct + forwarded ops, `bound` reply field — ADR-007/008)
last_updated: 2026-09-18 (the graduation wire surface: direct + forwarded ops, `bound`/`listen` reply fields — ADR-007/008 as amended)
---
# alktunnels — Wire Format
@@ -104,24 +104,32 @@ same establishment phase and wrapper as `…/sub`. Scope gate:
- 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
## The Listen Open Op's Reply — the `bound` and `listen` Fields
A bind-first listen open (ADR-008 §1, served by `…/sub` with the
bind-first establisher) MAY add an optional, additive reply field:
bind-first establisher) MAY add optional, additive reply fields:
```json
{ "channel_id": 7, "bound": { "host": "203.0.113.9", "port": 42113 } }
{ "channel_id": 7, "bound": { "host": "203.0.113.9", "port": 42113 },
"listen": 918273 }
```
- 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
- `bound` is substrate-shaped like the direct op's target
(`{host, port}` / `{path}`); the REAL OS-chosen address (port `0`
resolved).
- `listen` is the correlation token (ADR-008 Amendment 1): a u32 the
establisher minted, unique per open on that producer — the key the
forwarded op's params carry (§The Forwarded Op below). Riding the
reply is what makes it relay-transparent: reply fields pass through
hub re-produce untouched (alkcall ADR-051), and the same minted
token reappears in every hop's reply.
- Present only when the establisher supplies them; 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.
- `bound` is 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`)
@@ -139,14 +147,16 @@ Scope gate (opener side): `["tunnel:forwarded"]`
```json
{
"channel": 7,
"listen": 918273,
"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).
- `listen`: integer > 0, required — the correlation token from the
listen open's `listen` reply field (ADR-008 Amendment 1; the
producer-minted token, relay-transparent). The opener correlates
the accept to its waiting listener session by matching it.
- `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,
@@ -261,8 +271,8 @@ UDP substrate, one direction's wire view:
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)
- **OQ-TN-16**: resolved — see ADR-008 (the `bound`/`listen` fields +
the forwarded op)
## References

View File

@@ -23,7 +23,10 @@ the research record, the ADRs are now normative:
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
with `{listen, substrate, peer}` params (ask #2; ADR-008 Amendment
1, 2026-09-18 — the correlation key is a producer-minted token, the
original channel-ID key was unimplementable against the landed
handler surface). 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

View File

@@ -0,0 +1,157 @@
---
id: tunnels/bindfirst-params
name: Bind-first params — ListenParams, the forwarded-op spec, the bind-first plan type (ADR-008 as amended)
status: pending
depends_on: []
scope: narrow
risk: medium
impact: component
level: implementation
tags: [wire, params, bind-first, forwarded-op, correlation-token]
---
## Description
Implement the bind-first wire surface's params half (ADR-008, as
amended 2026-09-18 — Amendment 1): the listen-open reply fields
(`bound` + `listen`), the forwarded-op params + spec, and the
bind-first plan payload type. Split from the establisher/pump task
(`tunnels/bindfirst-producer`) so the wire-stable types land and get
pinned first — the same ordering `tunnels/params` used for the base
op.
### params.rs — constants + types
```rust
pub const OP_TUNNEL_FORWARDED: &str = "channels/tunnel/forwarded";
pub const TUNNEL_FORWARDED_SCOPE: &str = "tunnel:forwarded";
/// The bind-first listen-open reply (ADR-008 §2 as amended): the two
/// additive fields the establisher contributes. Extracted from the
/// full success reply (`{channel_id, bound?, listen?}`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelListenReply {
#[serde(skip_serializing_if = "Option::is_none")]
pub bound: Option<SubstrateAddr>,
#[serde(skip_serializing_if = "Option::is_none")]
pub listen: Option<u32>,
}
/// The forwarded-op input params (ADR-008 §3 as amended).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelForwardedParams {
pub listen: u32,
pub substrate: Substrate,
pub peer: SubstrateAddr,
}
pub fn tunnel_forwarded_spec() -> OperationSpec
```
- **The correlation token (Amendment 1):** `listen` (u32) — minted by
the bind-first establisher, returned in the reply as the `listen`
field, stamped into each forwarded open's `listen` param. NOT the
listen channel's ID (the pre-amendment key is superseded — the
serving-side handler never sees its own channel ID, and generic
relays don't rewrite params; ADR-008 Amendment 1 has the full
rationale). `SubstrateAddr` is defined by `tunnels/direct-op`
same value shape (`{host, port}` / `{path}`); if that task has not
landed, define it here and let `tunnels/direct-op` consume it
(either order works; the type lands once).
- `tunnel_forwarded_spec()` per wire.md §The Forwarded Op:
`Sub`-typed, `Visibility::External`, `ChannelOpenSpec::new(TUNNEL_ALPN)`,
scope `["tunnel:forwarded"]` (ADR-006 Amendment 1 — ingress, a
separate grant), input schema pinning all three fields required
(`listen` integer > 0; `peer` an object with the per-substrate
shape), output schema `{channel_id: integer > 0}`, description
carried. **No establisher** on the forwarded op — the data plane
starts immediately (ADR-008 §3).
- `TunnelListenReply` is the consumer-side reader (the wire itself is
plain JSON reply fields — additive, unknown fields ignored by old
consumers; the struct is the typed view, not a deny-unknown wire
gate on the reply — NOTE: the reply arrives as
`{channel_id, bound?, listen?}`; parse it with a dedicated
reply-viewing function that extracts the optional fields from the
full output Value, not with `deny_unknown_fields` deserialization
of the whole reply. The struct above is the projected pair; the
parse helper handles `channel_id`'s presence).
### The bind-first plan type (producer.rs or a small shared module)
```rust
/// The bind-first establisher's plan payload: the listener handle
/// (typed-opaque to the pump via downcast) + the correlation token
/// the accept dispatcher stamps into forwarded opens (ADR-008
/// Amendment 1 — the handler cannot learn its own channel ID, so the
/// establisher-minted token is the correlation key).
pub struct BindFirstPlan {
pub listener: TargetHandle, // the listener's halves? — NO: see the shape note
pub token: u32,
}
```
- **Shape note (decide during implementation, document the choice):**
the listener is NOT `TargetHandle` halves — a listener is accepted
FROM, not pumped. The plan carries whatever the assembly's `BindFn`
produced (opaque to the protocol), plus the token. Honest shape:
`pub struct BindFirstPlan { pub listener: Arc<dyn Any + Send + Sync>, pub token: u32 }`
— the accept dispatcher downcasts the `Any` to the assembly's
listener type via a type-erased accept closure (the same injection
pattern as `DialFn`/`AcceptFn`). The ADR's "listener handle" in the
plan is assembly-typed; the protocol sees `BindFirstPlan { inner,
token }`.
- The token mint: `std::sync::atomic::AtomicU32` counter seeded
non-zero (e.g. 1) inside the establisher closure state; wraps are
acceptable (u32, per-producer uniqueness is all that's required —
ADR-008 Amendment 1's token-space note; a wrap collision requires
2^32 concurrent opens on one producer).
- Wire-stability note for the implementer: `listen`'s u32 type and
field name, the forwarded params field names, and the op id are
one-way doors (ADR-008 Consequences). `deny_unknown_fields` on
`TunnelForwardedParams` (loud rejection).
### Tests (unit, params.rs)
- `TunnelForwardedParams` round-trip per substrate; unknown-field and
missing-field rejection; `listen: 0` rejected (schema minimum 1 +
serde-level pin).
- `TunnelListenReply` projected parse: full reply
`{channel_id: 7, bound: {...}, listen: 918}` → both fields; bare
`{channel_id: 7}` → both `None` (accept-first/dial shape); unknown
extra reply fields tolerated (the additive posture — the reply
viewer must not deny unknown fields).
- Spec-shape conformance: op id, Sub type, scope, schema structure
(all three required; peer object), ALPN marker, description.
- No forwarded establisher: the registration test lands in
`tunnels/bindfirst-producer` (this task pins the spec shape only).
## Acceptance Criteria
- [ ] `OP_TUNNEL_FORWARDED`/`TUNNEL_FORWARDED_SCOPE` constants;
`tunnel_forwarded_spec()` matches wire.md §The Forwarded Op
- [ ] `TunnelForwardedParams` (deny-unknown, `listen: u32 > 0`) + the
reply-projection viewer round-trip the pinned shapes
- [ ] `BindFirstPlan` (opaque listener + token) defined; the token
mint documented (per-producer unique, per-open fresh)
- [ ] Unit tests green; wasm32 check passes (pure protocol types)
- [ ] Clippy/fmt clean
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§2 as amended — the `listen` reply field; §3 as amended — the
forwarded params; Amendment 1 — the token rationale)
- docs/architecture/wire.md §The Listen Open Op's Reply +
§The Forwarded Op
- tasks/tunnels/params.md (the ordering precedent: wire types first)
- tasks/tunnels/direct-op.md (`SubstrateAddr` — shared type)
## Notes
> Agent fills during implementation.
## Summary
> Agent fills this on completion.

View File

@@ -0,0 +1,212 @@
---
id: tunnels/bindfirst-producer
name: Bind-first establisher + accept dispatcher + registration (ADR-008 shape 3)
status: pending
depends_on: [tunnels/bindfirst-params]
scope: broad
risk: high
impact: component
level: implementation
tags: [producer, establisher, bind-first, accept-dispatcher, forwarded-op]
---
## Description
Implement the bind-first establisher (producer.md §The Establisher,
shape 3 — ADR-008 §1): the establisher binds (via the injected
`BindFn`), mints the correlation token, contributes `bound` + `listen`
to the reply, and returns the plan; the pump handler is the **accept
dispatcher** — it accepts the channel's `BiStream`, runs the
assembly-provided accept loop, and per accept opens
`channels/tunnel/forwarded` back toward the listen opener and pumps
the accepted halves against the adopted channel. Plus
`register_tunnel_bindfirst_openable` wiring it.
### The establisher (shape 3 — bind-first)
```rust
pub type BindFn = Arc<
dyn Fn(&str) -> BoxFuture<'static, Result<BoundListener, TunnelEstablishError>>
+ Send + Sync,
>;
/// What the assembly's bind produces: the opaque listener (the accept
/// loop's business) + the OS-chosen bound address (the `bound` reply
/// field's source). The protocol never binds (OQ-TN-04).
pub struct BoundListener {
pub listener: Arc<dyn Any + Send + Sync>, // assembly-typed
pub bound: SubstrateAddr,
}
pub fn bindfirst_establisher(
registry: ResourceRegistry, // namespace gate only, as listen_establisher
bind: BindFn,
) -> OpenEstablisher
```
- Flow: parse `TunnelParams` → registry namespace gate
(`unknown_resource` on miss — same posture as
`listen_establisher`) → mint the correlation token
(per-open-fresh, per-producer-unique; `tunnels/bindfirst-params`)
`bind(substrate, backing)` → contribute reply fields
(`Establishment::with_reply_field("bound", …).with_reply_field("listen", token)`)
`Establishment::new(Arc::new(BindFirstPlan { listener, token }))`.
- Error mapping (producer.md shape-3 table): unbindable/held port →
`dial_failed` (the bind IS the dial here); bind resource
exhaustion → `resource_shortage`; registry miss →
`unknown_resource`; internal → `handler_error`. The `BindFn`
returns the typed errors; the establisher passes them through.
- The token lives in BOTH the reply and the plan (the dispatcher
needs it; the opener needs it via the reply).
### The accept dispatcher (the shape-3 pump handler)
```rust
pub fn make_bindfirst_accept_handler(
accept_loop: BindFirstAcceptLoop,
open_forwarded: ForwardedOpener,
) -> OpenHandler
```
- **Injection seams (the design pin — decide the exact closure
signatures during implementation, document in Notes):** the
dispatcher needs two assembly-provided capabilities the protocol
cannot own:
1. **The accept loop** — pops accepted connections from the
`BindFirstPlan`'s opaque listener and yields
`(SubstrateAddr /* peer */, TargetHandle /* accepted halves */)`.
Substrate code lives behind the closure (ADR-004).
2. **The forwarded-open caller** — opens `OP_TUNNEL_FORWARDED`
with `TunnelForwardedParams { listen: token, substrate, peer }`
on the channel-0 call plane toward the listen opener, adopts the
opener-allocated ID (`adopt_channel` on the producer-side
manager), and returns the streams. **This needs the call
plane**: the per-channel `Connection` the handler receives is
stream-less (`open_bi` is `StreamClosed` — ADR-035) and carries
no call surface, so the registration must close over the
serving session's `Arc<CallConnection>` (the reverse POC's
`open_reverse_channel` shape: `call_with_payload` on channel 0 →
extract `channel_id` from the reply → `manager.adopt_channel`).
The registration function takes the call-plane handle (the
assembly has it — it constructed the serving session); document
the wiring.
- Handler contract (producer.md §The Pump Handler item 5 + R-02):
`conn.accept_bi()` FIRST (the yield-once contract — the wrapper's
accepted-flag telemetry requires it), then run the accept loop
concurrently with holding the channel's stream. The listen
channel's own `BiStream` stays idle (no pumps on it); **its EOF
cancels the loop and tears the listener down** — `select!` on the
idle stream's EOF vs the accept loop (read from the stream and
observe EOF; the zero-byte sentinel is the opener dropping the
listener session). Per-accept data planes are `pump_bidi`
(convention 8 — never a hand-rolled loop over one stream): each
accepted handle pumps against its adopted forwarded channel inside
a spawned task tracked by the handler's JoinHandle set.
- **Lifetime (R-02):** the handler's returned JoinHandle tracks the
whole accept loop + every per-accept pump it spawned (collect the
pump handles; the handler task awaits them on loop exit — a
forwarded pump outliving its listener is a leak). Handler exit →
wrapper teardown (the listen channel reaps) → the far listener
tears down (the `BindFn`'s listener drops with the plan/loop).
- Error posture per accept: a forwarded-open failure (opener without
the scope → FORBIDDEN; old opener → NOT_FOUND; correlation miss →
handler_error-class) logs and drops that accepted connection — the
loop continues; the listener's overall health is not hostage to one
failed accept. (Decide: retry/backoff — v1 says no; document.)
### Registration
```rust
pub fn register_tunnel_bindfirst_openable(
core: &ChannelCore,
registry: &ResourceRegistry,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
call_plane: Arc<CallConnection>, // the producer-side call plane for forwarded opens
manager: &ChannelManager, // the producer-side manager (adopt the forwarded ids)
bind: BindFn,
accept_loop: BindFirstAcceptLoop,
identity_witness: Option<IdentityWitness>,
) -> Result<(), String>
```
- Same spec (`tunnel_open_spec()`) + scope gate (`tunnel:open`) as the
dial/listen variants — the bind-first shape is a per-registration
establisher choice over the same op (ADR-008 §5's per-registration
posture). One establisher per op id per registry — same note as
`register_tunnel_listen_openable`.
- The forwarded-op SERVING side (opener side) is a separate
registration shape (`register_openable` — no establisher) and lands
with `tunnels/forwarded-receipt`; this task's tests still need an
opener-side stub (see Tests).
### Tests (integration — the duplex harness, listen-topology extension)
- **The full bind-first flow e2e:** opener opens
`channels/tunnel/sub` with the bind-first producer registered →
reply carries `bound` (the real OS/duplex-chosen address) +
`listen` (the token) → the opener serves `channels/tunnel/forwarded`
(scope `tunnel:forwarded`) with a correlation map keyed on the
token → the test accept loop accepts a duplex connection → the
producer's dispatcher opens forwarded with
`{listen: token, substrate, peer}` → the opener correlates, adopts,
pumps against the "local BIND client" halves → byte round-trip
through the forwarded channel. This test IS the ADR-008 acceptance
gate.
- Reply-field projection e2e: `open_channel_with_reply` sees both
fields; a dial establisher's open sees neither (the reserved-key
guard upstream guarantees `channel_id` stays wrapper-owned).
- EOF teardown: drop the listener session (close the listen channel's
stream) → the accept loop cancels, the far listener is gone (a
follow-on accept attempt fails), the forwarded channels already
adopted own their teardown independently.
- Per-accept failure isolation: opener rejects one forwarded open
(e.g. scope-less identity on a second opener registry) → that
accept's connection drops, the loop serves the next accept.
- Establishment error paths: bind failure → `dial_failed`-class on
the wire (`channel:open_failed`); registry miss →
`unknown_resource`.
- Token discipline: two opens on the same producer mint distinct
tokens; each dispatcher stamps ITS OWN plan's token.
## Acceptance Criteria
- [ ] `bindfirst_establisher`: registry gate → mint token → bind →
`bound`+`listen` reply fields → `BindFirstPlan` plan-flow
- [ ] The accept dispatcher: `accept_bi` first (R-02 telemetry),
idle-stream EOF cancels the loop + tears the listener down,
per-accept `pump_bidi` tasks tracked by the handler JoinHandle
- [ ] The forwarded-open caller rides the call plane
(`call_with_payload` + adopt — the reverse POC's shape); the
registration wiring documents where the call plane comes from
- [ ] `register_tunnel_bindfirst_openable` wires spec + establisher +
dispatcher + injections
- [ ] Integration tests: the full flow e2e (the acceptance gate),
reply-field projection, EOF teardown, per-accept isolation,
typed establishment errors, token discipline
- [ ] Clippy/fmt clean; wasm32 check passes (closures injected; no
substrate code in the default crate)
- [ ] `cargo test` green
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§1 shape 3, §2 the reply fields, §3 the forwarded op, Amendment 1
— the token; §6 the hub policy sentence)
- docs/architecture/producer.md §The Establisher (shape 3) + §The
Pump Handler (item 5) + §The Forwarded Op
- tasks/tunnels/bindfirst-params.md (the types this consumes)
- tasks/tunnels/producer-open-op.md + producer-listen.md (the
harness + registration shapes this extends)
- alkcall surface: `Establishment::with_reply_field` (ADR-049
amendment 3, landed 0.8.0), the `OpenHandler` signature (no channel
ID — Amendment 1's premise), `ChannelRelay` reply-field pass-through
(ADR-051 — the relay-transparency property the tests should pin)
## Notes
> Agent fills during implementation.
## Summary
> Agent fills this on completion.

179
tasks/tunnels/direct-op.md Normal file
View File

@@ -0,0 +1,179 @@
---
id: tunnels/direct-op
name: Direct op — params, spec, establisher, consumer open_direct (ADR-007)
status: pending
depends_on: []
scope: broad
risk: medium
impact: component
level: implementation
tags: [wire, params, direct-op, producer, consumer, egress]
---
## Description
Implement the direct op (`channels/tunnel/direct`, ADR-007) end to
end: the params type + spec builder (`src/params.rs`), the establisher
+ registration (`src/producer.rs`), and the consumer constructor
(`src/consumer.rs`). The op rides the same `alk/tunnel` ALPN (ADR-002
Amendment 1's flavor-form op ids — the upstream discovery derivation
landed in alkcall 0.8.0, ADR-047 amendment 3). This is the ssh
`direct-tcpip` / SOCKS5 CONNECT egress capability with structural ACL
separation from `tunnel:open`.
### params.rs — types + spec
```rust
pub const OP_TUNNEL_DIRECT: &str = "channels/tunnel/direct";
pub const TUNNEL_DIRECT_SCOPE: &str = "tunnel:direct";
/// The substrate-shaped address object (ADR-007 §2): keyed by the
/// sibling `substrate` field of the params. Also the `peer` value
/// shape of the forwarded op (ADR-008 §3) — define once here.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SubstrateAddr {
HostPort { host: String, port: u16 },
Path { path: String },
}
/// The direct-op input params (ADR-007 §2). Wire-stable.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelDirectParams {
pub substrate: Substrate,
pub target: SubstrateAddr,
}
pub fn tunnel_direct_spec() -> OperationSpec
```
- Spec per wire.md §The Direct Op: `Sub`-typed, `Visibility::External`,
`ChannelOpenSpec::new(TUNNEL_ALPN)`, scope `["tunnel:direct"]`
(**never implied by `tunnel:open`** — ADR-006 Amendment 1), output
schema `{channel_id: integer > 0}`.
- `input_schema` pins both fields required and the target sub-schema
per-substrate — a `oneOf` on the sibling `substrate` value
(`tcp`/`udp``{host, port}`, `unix``{path}`), so malformed
targets fail the registry's schema validation (the typed
`INVALID_INPUT` class) **before** the establisher runs. The
establisher's serde parse is the second gate (defensive; see the
error-mapping note).
- `deny_unknown_fields` on both params types (the loud-rejection
posture, ADR-001's precedent).
- The `Substrate` enum is reused as the discriminator (no new
substrate values — ADR-001's additive rule).
### producer.rs — the establisher + registration
```rust
pub fn direct_establisher(dial: DialFn) -> OpenEstablisher
pub fn register_tunnel_direct_openable(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
dial: DialFn, // the SAME DialFn the base op uses
identity_witness: Option<IdentityWitness>,
) -> Result<(), String>
```
- **DialFn reuse is the design pin**: the establisher parses
`TunnelDirectParams`, renders the target to the same backing-string
form the registry backings use (`"host:port"` for tcp/udp, the path
for unix), and calls the injected `DialFn(substrate, rendered)`.
No second dial type — a producer that registered `dial_tcp` for the
base op registers the direct op with zero new substrate code
(ADR-007's `local` interaction note). The UDP framed-adapter wrap
(ADR-003) happens inside the dial closure (the `local` feature's
`connect_udp` already does this) — the establisher stays
substrate-agnostic.
- **No registry lookup** — `unknown_resource` can never fire
(ADR-007 §2). Error mapping (ADR-007 §4's table minus the registry
row): the serde-parse failure of a schema-valid-shaped target maps
`HandlerError` (the schema gate should have caught it — the parse
is the backstop); dial failures pass through the `DialFn`'s own
typed errors unchanged (`dial_failed` / `resource_shortage` /
`handler_error`).
- The per-call `auth` carries the opener identity (CF-006) — the
witness seam records it, same as the base op (the direct op is the
arbitrary-egress capability; identity visibility here matters more,
not less).
- Registration uses the SAME pump handler (`make_tunnel_pump_handler`
— the direct op's plan payload is a `TargetHandle` exactly like the
base op's; plan-flow is unchanged).
### consumer.rs — `open_direct`
```rust
pub async fn open_direct(
client: &ChannelClient,
target: SubstrateAddr,
substrate: Substrate,
) -> Result<Self, TunnelOpenError>
```
- A separate constructor, not a params enum (ADR-007 §5: two scopes
are two capabilities; the caller states which one it holds).
Serializes `{substrate, target}`, `open_channel(OP_TUNNEL_DIRECT, …,
TUNNEL_ALPN)`, `from_halves` — identical session type, data planes,
and teardown API as `open`.
- Uses plain `open_channel` (no reply fields expected on the direct
op — dial establishers never bind; wire.md §The Direct Op).
- Documented error surface: the base open errors minus
`unknown_resource`; malformed targets are the schema/
`invalid_input`-class failure (registry-side); an old producer
rejects the op loudly (`NOT_FOUND` — the SSH posture).
### Tests
Unit (params.rs): `TunnelDirectParams` round-trip per substrate;
untagged `SubstrateAddr` disambiguation (`{host, port}` vs `{path}`);
`deny_unknown_fields` rejection; spec-shape conformance to wire.md §The
Direct Op (op id, scope, oneOf schema structure — structural
assertions, no jsonschema dep here, per `tunnels/params`'s precedent).
Integration (extend the duplex harness from `tunnels/producer-open-op`):
- `open_direct` e2e round-trip through `pump_bidi` (tcp duplex target).
- `open_direct` udp e2e (the framed adapter wraps at the dial closure —
datagram boundary preserved).
- Malformed target (unix without `path`) → schema-class failure, no
phantom session.
- Producer without the direct op registered → `NOT_FOUND`-class.
- Identity without `tunnel:direct` (but with `tunnel:open`) →
FORBIDDEN (the scopes do not imply each other).
- Direct establisher sees the per-call opener identity (witness).
## Acceptance Criteria
- [ ] `TunnelDirectParams`/`SubstrateAddr` round-trip; malformed
targets rejected (serde + schema shape pinned)
- [ ] `tunnel_direct_spec()` matches wire.md §The Direct Op exactly
- [ ] `direct_establisher`: no registry lookup, target rendering into
the `DialFn` backing string, typed errors per ADR-007 §4
- [ ] `register_tunnel_direct_openable` reuses the base pump handler +
dial injection; witness seam present
- [ ] `TunnelSession::open_direct` (separate ctor; identical
session/teardown semantics)
- [ ] Integration tests: e2e round-trips (tcp + udp), malformed-target
rejection, scope separation (FORBIDDEN), NOT_FOUND posture,
per-call identity witness
- [ ] Clippy/fmt clean; wasm32 check passes (no substrate code added)
- [ ] `cargo test` green
## References
- docs/architecture/decisions/007-direct-open-op.md (the normative op)
- docs/architecture/producer.md §The Direct Op; consumer.md §Direct
path; wire.md §The Direct Op
- docs/architecture/decisions/006-access-control-posture.md Amendment 1
- tasks/tunnels/params.md + tasks/tunnels/producer-open-op.md (the
shapes this extends; the harness to extend)
## Notes
> Agent fills during implementation.
## Summary
> Agent fills this on completion.

View File

@@ -0,0 +1,146 @@
---
id: tunnels/forwarded-receipt
name: Forwarded-op serving side + TunnelSession adopt path (the opener half)
status: pending
depends_on: [tunnels/bindfirst-params]
scope: medium
risk: medium
impact: component
level: implementation
tags: [opener, forwarded-op, serving, adopt, correlation]
---
## Description
Implement the forwarded op's SERVING side (the listen-opener half,
ADR-008 §3): `register_tunnel_forwarded_openable` — the
`register_openable` shape (no establisher; the data plane starts
immediately), scope-gated `tunnel:forwarded`, whose handler parses
`TunnelForwardedParams`, hands the correlation material to the
assembly, accepts the `BiStream`, and presents it for pumping against
the local session's halves. Plus the typed consumer surface for
reading the listen reply (`bound`/`listen` extraction, shared with
`tunnels/tunnel-listener`).
### The serving registration (opener side)
```rust
/// The correlation material the opener-side handler yields per
/// forwarded open: the parsed params + the channel's BiStream
/// accessor. The assembly correlates (token → its waiting listener
/// session bookkeeping) and drives the pump against the local
/// halves. The protocol provides the op + params, not the wait
/// state machine (producer.md §The Forwarded Op).
pub type ForwardedHandler = Arc<
dyn Fn(TunnelForwardedParams, BiStream) -> tokio::task::JoinHandle<()>
+ Send + Sync,
>;
pub fn make_forwarded_op_handler(handler: ForwardedHandler) -> OpenHandler
pub fn register_tunnel_forwarded_openable(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
handler: ForwardedHandler,
) -> Result<(), String>
```
- **No establisher** (`register_openable` — the forwarded channel
needs no establishment; its data plane starts immediately — ADR-008
§3). The wrapper's `channel_id` allocation IS the reply (the
producer adopts it).
- The built-in handler shape: parse
`TunnelForwardedParams` (`deny_unknown_fields`; a malformed open →
the handler logs + returns early — teardown-at-birth is correct for
a malformed ingress), accept the `BiStream`, invoke the injected
assembly handler with the parsed params + the stream. The assembly
correlates (via `listen`), adopts nothing (the WRAPPER owns this
channel — the serving side allocated it; the assembly pumps the
BiStream against its local halves via `pump_bidi`, and the wrapper's
R-02 tracking reaps on handler exit).
- **Pump responsibility split (the honest-shape decision to make and
document):** the injected `ForwardedHandler` receives the channel
`BiStream` and the params — the ASSEMBLY owns the pump
(`pump_bidi(forwarded_bidi, local_read, local_write)`), because the
local halves are assembly state (the SOCKS5 BIND client's socket).
The protocol-side built-in handler only bridges wrapper→assembly.
Await the injected handler's JoinHandle inline (R-02 — the
forwarded channel's lifetime tracks it).
- Identity: the opener-side gate sees the PRODUCER's per-call
identity (CF-005 precedence — the producer may carry `auth_token`
when it is itself forwarding); the `peer` param is
producer-asserted metadata, not transport truth (producer.md's
posture — ACL trusts the grant, app treats `peer` as metadata).
The registration's `auth` + the per-call overlay handle this
automatically (upstream machinery); nothing to implement, but the
test suite pins it.
- The `listen`-token correlation failure (no matching session) is the
ASSEMBLY's rejection (it holds the map): the injected handler's
error path — drop the stream, return (teardown-at-birth reaps the
channel). Document that a correlation miss tears the forwarded
channel down; the producer's accept loop drops the accepted
connection on the open's failure (the shapes mirror).
### The typed reply reader (shared with TunnelListener)
```rust
/// Extract the bind-first reply fields from an open-op success reply
/// (the full output Value — `channel_id` + optional `bound`/`listen`).
pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
```
- Tolerates unknown fields (the additive posture); both fields
`Option` (absent for dial/accept-first producers).
- Lands here (not in `tunnels/tunnel-listener`) so the projection
logic is unit-testable independent of the session type.
### Tests
- Serving-side e2e with the harness's producer-leg stub: an open with
valid params + scope → handler invoked with the parsed params +
the stream; the assembly's test handler pumps against local
duplex halves → byte round-trip. (The full bind-first flow e2e
lives in `tunnels/bindfirst-producer`; this task's e2e is the
serving half standalone.)
- Scope gate: identity without `tunnel:forwarded` → FORBIDDEN (never
implied by `tunnel:open`/`tunnel:direct`).
- Malformed params (missing `peer`, `listen: 0`, unknown field) →
handler logs + early return → the channel tears down (no phantom).
- The reply reader: full/empty/extra-field replies parse per the
additive posture (unit).
- Old-opener posture is upstream's registry lookup (`NOT_FOUND`) —
pin it in the producer-side test in `tunnels/bindfirst-producer`
(cross-referenced, not duplicated here).
## Acceptance Criteria
- [ ] `register_tunnel_forwarded_openable`: no-establisher
registration, scope gate `tunnel:forwarded`, injected
assembly handler receives parsed params + BiStream
- [ ] The injected handler's JoinHandle is awaited inline (R-02);
correlation-miss teardown semantics documented + tested
- [ ] `listen_reply_fields` extraction (additive-tolerant)
- [ ] Integration tests: serving e2e round-trip, FORBIDDEN gate,
malformed-params teardown; unit tests for the reply reader
- [ ] Clippy/fmt clean; wasm32 check passes
- [ ] `cargo test` green
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§3 the forwarded op — the opener-side serving shape; Amendment 1)
- docs/architecture/producer.md §The Forwarded Op; consumer.md §The
Listen Flow; wire.md §The Forwarded Op
- docs/architecture/decisions/006-access-control-posture.md Amendment 1
- tasks/tunnels/bindfirst-params.md (the types);
tasks/tunnels/bindfirst-producer.md (the caller half — the tests
compose)
## Notes
> Agent fills during implementation.
## Summary
> Agent fills this on completion.

View File

@@ -0,0 +1,105 @@
---
id: tunnels/local-bind-halves
name: local feature — TCP bind halves + the bind-first assembly pieces
status: pending
depends_on: [tunnels/bindfirst-producer]
scope: narrow
risk: low
impact: component
level: implementation
tags: [local, tcp, bind, listener, feature-gated]
---
## Description
Extend the `local` feature (non-wasm, `tokio/net`-gated —
`tunnels/local-socket-halves` established the pattern) with the
bind-first shape's substrate half: a real TCP bind function
(`BindFn`-compatible) and an accept loop producing
`(peer, TargetHandle)` pairs, plus the direct-op convenience dial
closure composition (the `DialFn` the direct op shares with the base
op — likely already satisfied by `local_dial`; verify, don't
duplicate).
### The bind half
```rust
/// The real-socket BindFn source (the `local` feature's shape 3
/// backing): bind `addr` (port 0 = OS-chosen), report the REAL
/// bound address (the `bound` reply field's source — ADR-008 §2),
/// expose the accept loop that feeds the dispatcher.
pub async fn bind_tcp_first(addr: &str)
-> Result<BoundListener, TunnelEstablishError>;
pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first
```
- `BoundListener.listener` carries the `TcpListener` (type-erased in
the plan — `tunnels/bindfirst-producer`'s shape note); the accept
loop is assembly code composed ON it — the protocol crate stays
socket-blind, so the loop lives here (`local` = substrate code,
ADR-004):
- **`TcpListenerHalves` already has an accept loop** (`accept_loop`
feeding an `AcceptQueue` — the accept-first shape). The bind-first
shape needs a DIFFERENT loop: per-accept it must produce
`(peer_addr, TargetHandle)` for the accept dispatcher (which opens
forwarded channels per accept), not feed a queue for the
establisher to pop. Add a sibling method/struct (e.g.
`TcpListenerHalves::accept_pairs` or a new
`BindFirstAcceptLoop` adapter) — decide the honest shape, document
in Notes. The peer address rides from `accept()`'s return value
(reply#2 fidelity's source — ADR-008 §3's `peer` carrier).
- The direct-op `DialFn`: `local_dial()` already renders
`(Substrate, backing)` → halves with the UDP framed adapter — the
direct establisher calls the SAME closure with the rendered target
string. Verify the UDP wrap happens INSIDE the closure (it does —
review 001 U-1) so direct-udp gets the framed adapter free; add a
test pinning direct-udp-through-`local_dial` framing if missing.
### Tests (`tests/`, real sockets — the `local` suite's posture)
- Bind port 0 → `bound` reports the REAL OS-chosen address (the
reply#1 fidelity pin).
- Two clients dial the bound address → two accepts → two forwarded
channels (via the bind-first harness) → both pump independently
(peer fidelity: each forwarded open carries the REAL accepted
peer).
- Unix-domain bind (OQ-TN-14 parity): `bind_unix_first`-equivalent —
decide whether unix bind-first ships in this task or is deferred
with a note (the wire enum already carries unix; the local dial
path exists — the bind path is the same shape; prefer shipping
both, matching dial parity).
## Acceptance Criteria
- [ ] `bind_tcp_first` + the accept-pairs loop (peer + halves per
accept) behind `local`
- [ ] The bound address is the REAL OS-chosen one (port 0 resolved —
pinned by test)
- [ ] Direct-op `DialFn` reuse verified (no duplicate dial code;
UDP framed-adapter wrap composes)
- [ ] Unix bind-first parity decided + shipped or explicitly deferred
with rationale
- [ ] `cargo test --all-features` green; default-crate wasm32 checks
still clean (the feature gate holds)
- [ ] Clippy/fmt clean (both feature sets)
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§1 shape 3 — the bind-first establisher; §3 the peer carrier)
- docs/architecture/producer.md §The Registration API (`BindFn` shape)
- tasks/tunnels/local-socket-halves.md (the `local` pattern + the
existing `bind_tcp`/`TcpListenerHalves`/`AcceptQueue` accept-first
shapes)
- tasks/tunnels/bindfirst-producer.md (the `BindFn`/
`BindFirstAcceptLoop` contracts this implements)
- OQ-TN-14 (unix ships with `local` v1; stdio out — alktty's)
## Notes
> Agent fills during implementation.
## Summary
> Agent fills this on completion.

View File

@@ -0,0 +1,141 @@
---
id: tunnels/tunnel-listener
name: TunnelListener — the listen-flow consumer session (ADR-008 §4)
status: pending
depends_on: [tunnels/bindfirst-params, tunnels/forwarded-receipt]
scope: medium
risk: medium
impact: component
level: implementation
tags: [consumer, listener, listen-flow, teardown, tunnel-listener]
---
## Description
Implement `TunnelListener` (consumer.md §The Listen Flow — ADR-008
§4): the listen-flow sibling of `TunnelSession`. It OPENS a listen
tunnel (`channels/tunnel/sub` — the establisher shape is the
producer's choice, invisible here), reads the bind-first reply fields
(`bound`/`listen`), presents them, and owns the listen channel's
teardown (ADR-005's ownership posture extended to the listen flow).
Forwarded connections arrive as ordinary adopted-side `TunnelSession`s
on the opener's manager — `adopt` gains nothing.
### The type
```rust
pub struct TunnelListener {
channel_id: u32,
manager: Option<ChannelManager>,
bound: Option<SubstrateAddr>, // from the `bound` reply field
token: Option<u32>, // from the `listen` reply field
}
```
```rust
impl TunnelListener {
pub async fn open(client: &ChannelClient, params: TunnelParams)
-> Result<Self, TunnelOpenError>;
pub fn bound(&self) -> Option<&SubstrateAddr>;
pub fn listen(&self) -> Option<u32>;
pub async fn close(self) -> bool; // tear the listen channel down
// Drop: sync-reap, best-effort — ADR-005's Drop posture
}
```
- **`open`** — `open_channel_with_reply(OP_TUNNEL_OPEN, params,
TUNNEL_ALPN)` (the U-2 surface, landed alkcall 0.8.0): the full
reply arrives, `listen_reply_fields` extracts
`bound`/`listen`, `adopt`-free construction (the client already
adopted the channel — the streams come back from
`open_channel_with_reply`), and the listener holds the channel
entry. The listen channel's own `BiStream` is held but IDLE — the
listener session's drop/EOF is the teardown signal the far accept
dispatcher watches (ADR-008 §1's contract); `TunnelListener` never
pumps its own stream.
- **The held BiStream (the one new thing):** unlike
`TunnelSession` (which presents a data plane), a listen session's
stream has NO data-plane role — its only job is to EOF when the
session drops. Keep the stream (or its write half) alive in the
struct so the drop → EOF ordering is structural (the mux sender
drops → EOF sentinel). Do NOT expose the stream publicly —
pumping on a listen channel is meaningless (the far side's
dispatcher ignores it too).
- **`close(self)`/`Drop`** — abort nothing (no pump), reap the
adopted channel entry (`teardown_channel`). The far-side teardown
cascades (EOF → accept-loop cancel → listener drop). Forwarded
sessions are NOT owned by the listener (they arrive as separate
adopted sessions the assembly correlates + owns) — document the
split (listener teardown does not cascade to already-adopted
forwarded sessions; the far listener dying stops NEW accepts).
- **No `Clone`** — same posture as `TunnelSession` (aliasing would
alias teardown). Compile-assert with a doc-test like the session's.
- **`forwarded()`**: NOT a method on `TunnelListener` — forwarded
sessions arrive on the opener's manager as adopted channels the
assembly correlates via the `listen` token (consumer.md's posture;
the correlation registry is assembly-side). The type hands the
assembly the token (`listen()`); it does not own the receipt
queue. (The task sketch in consumer.md showed `listener.forwarded()`
— the honest shape is the assembly's map; document if a v1
convenience accessor is still wanted — decide, don't silently
drop.)
### The direct path (ADR-007's consumer ctor lives in `tunnels/direct-op`; this task adds nothing to `TunnelSession`)
Per ADR-008 §4: "`TunnelSession::adopt` gains nothing." Verify the
claim while implementing: a forwarded channel adopted by the opener's
assembly (`manager.adopt_channel` or `TunnelSession::adopt`) presents
the same session/data-plane/teardown semantics — pin it with a test
(adopt a forwarded channel, pump, teardown).
### Tests
- `open` against a bind-first producer (the harness from
`tunnels/bindfirst-producer`): reply fields surface via
`bound()`/`listen()`; teardown cascades (drop listener → far
listener gone → subsequent forwarded opens fail).
- `open` against a dial/listen producer: both fields `None` (the
Option posture).
- `close` → the listen channel reaps; `Drop` without close → reaped
(the ADR-005 leak-free guarantee, listen-flavored).
- Adopt-path pin: the forwarded session adopt + pump + teardown.
- Scope interaction: an opener identity with `tunnel:open` but NOT
`tunnel:forwarded` still opens the listen channel (that gate is on
the producer side) — forwarded opens to it then fail FORBIDDEN
(the per-accept isolation path from `tunnels/bindfirst-producer`).
## Acceptance Criteria
- [ ] `TunnelListener::open` uses `open_channel_with_reply`;
`bound()`/`listen()` surface the reply fields (Option posture)
- [ ] Teardown: `close`/`Drop` reap the listen channel; the held
BiStream's EOF is the structural teardown trigger; no
`Clone`
- [ ] Forwarded sessions are assembly-adopted ordinary
`TunnelSession`s (adopt gains nothing — pinned by test)
- [ ] Integration tests: bind-first e2e with the listener type,
teardown cascade, Option posture, adopt-path pin
- [ ] Clippy/fmt clean; wasm32 check passes
- [ ] `cargo test` green
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§4 the consumer API; Amendment 1 — the token the `listen()` view
reads)
- docs/architecture/decisions/005-consumer-session-owns-teardown.md
(the ownership posture extended to the listen flow)
- docs/architecture/consumer.md §The Listen Flow
- tasks/tunnels/consumer-session.md (the TunnelSession shape this
siblings — close/join/Drop posture, the no-Clone assert)
- tasks/tunnels/bindfirst-params.md (the reply reader);
tasks/tunnels/forwarded-receipt.md (the serving half the tests
compose against)
## Notes
> Agent fills during implementation.
## Summary
> Agent fills this on completion.