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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
1–2); 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
157
tasks/tunnels/bindfirst-params.md
Normal file
157
tasks/tunnels/bindfirst-params.md
Normal 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.
|
||||
212
tasks/tunnels/bindfirst-producer.md
Normal file
212
tasks/tunnels/bindfirst-producer.md
Normal 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
179
tasks/tunnels/direct-op.md
Normal 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.
|
||||
146
tasks/tunnels/forwarded-receipt.md
Normal file
146
tasks/tunnels/forwarded-receipt.md
Normal 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.
|
||||
105
tasks/tunnels/local-bind-halves.md
Normal file
105
tasks/tunnels/local-bind-halves.md
Normal 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.
|
||||
141
tasks/tunnels/tunnel-listener.md
Normal file
141
tasks/tunnels/tunnel-listener.md
Normal 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.
|
||||
Reference in New Issue
Block a user