Files
alktunnels/tasks/tunnels/bindfirst-params.md
T
glm-5.3-flash d586a5d2b8 feat: the forwarded op's params + the bind-first plan type (ADR-008 as amended)
- params.rs: OP_TUNNEL_FORWARDED / TUNNEL_FORWARDED_SCOPE; TunnelForwardedParams
  {listen, substrate, peer} (deny-unknown; listen positive via schema minimum 1 +
  a serde pin); tunnel_forwarded_spec() (Sub, External, alk/tunnel ALPN, scope
  tunnel:forwarded, per-substrate if/then peer sub-schema, no establisher);
  TunnelListenReply + parse_listen_reply (the additive bound/listen reply
  projection — unknown reply fields tolerated, malformed ones fail loud)
- error.rs: TunnelError::MalformedReply (the viewer's fail-loud path)
- producer.rs: BindFirstPlan {listener: Arc<dyn Any + Send + Sync>, token: u32}
  — the opaque assembly-typed listener + the correlation token; the mint
  discipline pinned in docs (non-zero-seeded AtomicU32, per-open fresh,
  per-producer unique, wraps acceptable)
- Wire-stable types pin first (the tunnels/params ordering); the establisher +
  accept dispatcher + registration land with tunnels/bindfirst-producer

Verified: cargo test (105 default; 125 --all-features), clippy --all-targets
-D warnings (native + wasm32), fmt --check, wasm32-unknown-unknown check,
cargo doc --no-deps — all clean.
2026-09-19 07:44:28 +00:00

10 KiB

id, name, status, depends_on, scope, risk, impact, level, tags
id name status depends_on scope risk impact level tags
tunnels/bindfirst-params Bind-first params — ListenParams, the forwarded-op spec, the bind-first plan type (ADR-008 as amended) completed
narrow medium component implementation
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

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)

/// 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

  • SubstrateAddr had already landed (via tunnels/direct-op) — consumed, not redefined (the "either order works" branch resolved to the consuming side).
  • The reply viewer is parse_listen_reply(&Value) -> Result<TunnelListenReply, TunnelError>: parses the FULL success output (the open_channel_with_reply shape), tolerates unknown extra reply fields (the additive posture — no deny-unknown gate on the reply), requires a positive channel_id (its absence/zero = not an open reply — loud), and fails loud on malformed bound (not substrate-shaped) or listen (not a positive u32) via the new TunnelError::MalformedReply variant (error.rs). null fields parse as None (defensive; a well-formed reply omits them).
  • TunnelForwardedParams.listen carries a serde-level positive_u32 pin behind the schema's minimum: 1 (the task's "listen: 0 rejected (schema minimum 1 + serde-level pin)" — both gates, test-pinned).
  • The peer sub-schema follows the direct op's if/then precedent (tcp/udp → {host, port} with additionalProperties: false + maximum: 65535; unix → {path}), structural assertions per the no-jsonschema-dep precedent. The untagged-peer lenient-extra-fields asymmetry carries over from the direct op (schema = the shape gate; serde = the backstop) — documented in the test.
  • BindFirstPlan lives in producer.rs (the plan-payload family, next to TargetHandle): { listener: Arc<dyn Any + Send + Sync>, token: u32 } per the honest shape — the listener is NOT TargetHandle halves (a listener is accepted FROM, not pumped); the accept dispatcher downcasts via a type-erased accept closure. The token-mint discipline (AtomicU32 seeded non-zero, per-open fresh, per-producer unique, wraps acceptable) is pinned in the type's doc — the establisher that implements it lands with tunnels/bindfirst-producer.
  • Custom Debug impls (matching TargetHandle's precedent): the listener renders <opaque>, the token renders plainly.
  • Tests: 10 new unit tests in params.rs (forwarded round-trips per substrate, unknown/missing-field rejection, listen: 0 serde rejection, full/bare/extra-field reply projection, malformed-reply rejection, spec conformance, peer-schema structure, the parse-backstop asymmetry) + the BindFirstPlan downcast-through-ChannelPlan test in producer.rs (incl. the wrong-type downcast failure).

Summary

Implemented the bind-first wire surface's params half per ADR-008 as amended: params.rs — OP_TUNNEL_FORWARDED / TUNNEL_FORWARDED_ SCOPE constants; TunnelForwardedParams {listen, substrate, peer} (deny-unknown, listen positive via schema minimum: 1 + the serde pin); tunnel_forwarded_spec() (Sub-typed, External, ChannelOpenSpec::new(TUNNEL_ALPN), scope ["tunnel:forwarded"], per-substrate if/then peer sub-schema, output {channel_id: integer > 0}, description, no establisher); TunnelListenReply + parse_listen_reply (the projected reply viewer — unknown extra fields tolerated, channel_id presence required, malformed fields fail loud via TunnelError:: MalformedReply). producer.rs — BindFirstPlan {listener: Arc<dyn Any + Send + Sync>, token: u32} with the mint discipline pinned in docs (non-zero-seeded AtomicU32, per-open fresh, per-producer unique, wraps acceptable). Re-exported through lib.rs; the establisher/dispatcher/registration that consume these types land with tunnels/bindfirst-producer.

Verified: cargo test green (105 default; 125 with --all- features), clippy --all-targets -D warnings clean (native + wasm32), fmt clean, wasm32-unknown-unknown check passes (pure protocol types — the default crate stays wasm-clean), cargo doc --no-deps clean. CHANGELOG updated (Unreleased → Added).