--- 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, 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.