diff --git a/docs/architecture/wire.md b/docs/architecture/wire.md index 54b2eec..6da18c4 100644 --- a/docs/architecture/wire.md +++ b/docs/architecture/wire.md @@ -153,7 +153,12 @@ PRODUCER opens a channel back toward the listen-opener side. The producer *calls* the op (the opener SERVES it — the ADR-047 §5 connection-owner rule: the serving side allocates the `channel_id`, the producer-side initiator adopts). `Sub`-typed, ALPN marker -`alk/tunnel`; no establisher (the data plane starts immediately). +`alk/tunnel`; the serving side registers establisher-less by base +shape (the data plane starts immediately) or — the recommended shape, +ADR-008 Amendment 2 — with the correlation-gate establisher variant +(a stale/missing token then fails the op as a typed +`channel:open_failed` with `details.reason = "unknown_resource"`; the +wire vocabulary is unchanged by the amendment). Scope gate (opener side): `["tunnel:forwarded"]` (`TUNNEL_FORWARDED_SCOPE`) — ingress, a separate grant from `tunnel:open`/`tunnel:direct` (ADR-006 Amendment 1). diff --git a/src/lib.rs b/src/lib.rs index 33f2f91..e3cd1ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,7 @@ pub use params::{TUNNEL_ALPN, TUNNEL_OPEN_SCOPE}; pub use producer::{ bindfirst_establisher, make_bindfirst_accept_handler, make_forwarded_op_handler, register_tunnel_bindfirst_openable, register_tunnel_forwarded_openable, + register_tunnel_forwarded_openable_with_establisher, ForwardedGate, ForwardedPlan, }; pub use producer::{ direct_establisher, direct_establisher_with_witness, register_tunnel_direct_openable, diff --git a/src/params.rs b/src/params.rs index 1d442f2..6eb0d98 100644 --- a/src/params.rs +++ b/src/params.rs @@ -426,11 +426,15 @@ where /// fails the registry's schema validation (the typed /// `INVALID_INPUT` class) before the opener-side handler runs. /// -/// **No establisher** on the forwarded op — the data plane starts -/// immediately (the opener-side serving registration is -/// `register_openable`, handler = correlate by `listen` + accept the +/// **Serving registration** (ADR-008 §3 as amended by Amendment 2): +/// establisher-less base shape — `register_openable`, the data plane +/// starts immediately (handler = correlate by `listen` + accept the /// `BiStream` + pump; the correlation registry is assembly-side -/// bookkeeping, ADR-008 §3). +/// bookkeeping) — or the recommended establisher variant, +/// `register_tunnel_forwarded_openable_with_establisher` (the +/// injected correlation gate; a stale/missing token fails the open as +/// a typed `channel:open_failed`). The spec itself is unchanged: the +/// same op id, params, scope, and reply surface under both shapes. pub fn tunnel_forwarded_spec() -> OperationSpec { OperationSpec::new( OP_TUNNEL_FORWARDED, diff --git a/src/producer.rs b/src/producer.rs index cbb4fa8..e7a3b93 100644 --- a/src/producer.rs +++ b/src/producer.rs @@ -1011,13 +1011,22 @@ pub type ForwardedHandler = /// lifetime tracks it, so a correlation miss or a dropped assembly /// handler tears the channel down). /// -/// **Correlation-miss semantics (documented + test-pinned):** a -/// `listen` token with no matching waiting session is the ASSEMBLY's -/// rejection (it holds the map): its handler's error path drops the -/// stream and returns — teardown-at-birth reaps the forwarded channel. -/// The producer's accept loop drops the accepted connection when that -/// open fails (the shapes mirror — per-accept isolation, -/// `tunnels/bindfirst-producer`'s posture). +/// **Correlation-miss semantics (two-shape posture, ADR-008 Amendment +/// 2 + test-pinned):** under the BASE shape (this registration, +/// establisher-less), a `listen` token with no matching waiting +/// session succeeds at the call plane and the miss is the ASSEMBLY's +/// post-open rejection (it holds the map): its handler's error path +/// drops the stream and returns — teardown-at-birth reaps the +/// forwarded channel (`correlation-miss`, the assembled decline +/// event at `debug`). Under the ESTABLISHER variant +/// ([`register_tunnel_forwarded_openable_with_establisher`] — the +/// recommended shape), a no-route token fails at the open as a typed +/// `channel:open_failed` before the channel is born; THIS handler's +/// post-open miss is then only the residual race (a token vanishing +/// between gate pass and handler run) — teardown-at-birth remains the +/// fallback shape under both. The producer's accept loop drops the +/// accepted connection when that open fails (the shapes mirror — +/// per-accept isolation, `tunnels/bindfirst-producer`'s posture). pub fn make_forwarded_op_handler(handler: ForwardedHandler) -> OpenHandler { Arc::new( move |input: Value, _plan: Option, conn: Connection, _auth: AuthContext| { @@ -1044,16 +1053,26 @@ pub fn make_forwarded_op_handler(handler: ForwardedHandler) -> OpenHandler { ) } -/// Register the forwarded op's opener-side serving openable (ADR-008 -/// §3): `ChannelCore::register_openable` — **no establisher** (the -/// forwarded -/// channel needs no establishment; its data plane starts immediately) -/// — scope-gated `tunnel:forwarded` (ingress, a separate grant from -/// `tunnel:open`/`tunnel:direct`, never implied by either — ADR-006 -/// Amendment 1), via [`crate::params::tunnel_forwarded_spec`]. The -/// wrapper's +/// Register the forwarded op's opener-side serving openable — the +/// BASE shape (ADR-008 §3 as amended): `ChannelCore::register_openable` +/// — **no establisher** (the forwarded channel needs no establishment; +/// its data plane starts immediately) — scope-gated `tunnel:forwarded` +/// (ingress, a separate grant from `tunnel:open`/`tunnel:direct`, +/// never implied by either — ADR-006 Amendment 1), via +/// [`crate::params::tunnel_forwarded_spec`]. The wrapper's /// `channel_id` allocation IS the reply (the producer adopts it). /// +/// **Two-shape posture (ADR-008 Amendment 2):** this base shape stays +/// byte-for-byte compatible for assemblies that prefer the post-open +/// decline (and as the residual-race fallback posture — see +/// [`make_forwarded_op_handler`]'s correlation-miss block). The +/// ESTABLISHER variant +/// ([`register_tunnel_forwarded_openable_with_establisher`]) is the +/// recommended shape: a stale/missing token fails the open as a typed +/// `channel:open_failed` before the channel is born, instead of +/// succeeding at the call plane and declining post-open. Both ride +/// the same op id, params, scope, and reply surface. +/// /// Identity (CF-005 precedence, upstream machinery): the opener-side /// gate sees the PRODUCER's per-call identity (the producer may carry /// an `auth_token` when it is itself forwarding); the `peer` param is @@ -1081,6 +1100,124 @@ pub fn register_tunnel_forwarded_openable( ) } +/// The forwarded op's establisher plan payload (ADR-008 Amendment 2): +/// an empty marker — a gate pass establishes nothing (the correlation +/// map is assembly state and the handler needs nothing it does not +/// re-derive), the same typed-opaque [`ChannelPlan`] posture as ADR-049 +/// amendment 2 with the payload choice pinned at "marker". Its `Some` +/// presence in the handler's `plan` parameter signals the gate passed; +/// the handler is free to ignore it. +pub struct ForwardedPlan; + +/// The injected correlation gate (ADR-008 Amendment 2, plan 003 W1): +/// the assembly's one-shot correlation check for the forwarded op's +/// serving half. `Err` at the gate → a typed `channel:open_failed` +/// BEFORE the channel is born (no phantom channel — ADR-049's posture +/// extended to the forwarded op's serving side). +/// +/// The gate receives the PARSED [`TunnelForwardedParams`] and runs +/// synchronously — it must be an in-memory map lookup (an await there +/// would extend the open's call-plane latency; the lookup shape is +/// what every assembly's correlation registry provides). +/// +/// **Gate error convention (the pin):** a stale/missing `listen` +/// token SHOULD return [`TunnelEstablishError::UnknownResource`] — +/// the waiting session IS a resource that is gone, and that maps to +/// `details.reason == "unknown_resource"` on the wire (the canonical +/// reason ADR-008 Amendment 2 pins; the wire vocabulary is NOT +/// extended). The crate passes through whatever the gate returns — +/// the gate owns its rejection vocabulary, within the existing +/// [`TunnelEstablishError`] table. +pub type ForwardedGate = + Arc Result<(), TunnelEstablishError> + Send + Sync>; + +/// The gate establisher (ADR-008 Amendment 2 — the correlation gate +/// wrapped into the alkcall [`OpenEstablisher`] dance; assemblies do +/// not hand-roll the plumbing, the crate-owned registration surface +/// below is the only supported path). +/// +/// **Order (pinned, ADR-008 Amendment 2 decision 3):** the registry +/// runs the `tunnel:forwarded` ACL BEFORE the establishment phase +/// (alkcall's open-op wrapper order: schema → ACL → allocate → +/// establisher → handler), so the gate never sees an unauthorized +/// open — a scope-less producer is `FORBIDDEN` before any gate +/// lookup. The establisher then parses the input and runs the gate; +/// the base shape's handler-side parse stays (see below), so +/// `deny_unknown_fields` + the positive-`listen` serde pin gate the +/// ingress at BOTH layers. +/// +/// Error mapping (pinned): +/// - Establisher parse failure → +/// [`TunnelEstablishError::HandlerError`] → the existing +/// `From for EstablishmentError` table → +/// `channel:open_failed` with `details.reason == "handler_error"`. +/// This is deliberate: a malformed ingress becomes a typed +/// call-plane failure instead of the base shape's silent +/// birth-teardown (an improvement ADR-008 Amendment 2 pins — do +/// not drift it). +/// - Gate `Err(TunnelEstablishError)` → the same existing table, 1:1 +/// (a stale token: `UnknownResource` → `details.reason == +/// "unknown_resource"`). No new variants are minted. +/// - A gate pass produces NO resource handle: the plan payload is the +/// empty [`ForwardedPlan`] marker, and there is NO reply-field +/// contribution (the op's reply stays the wrapper's reserved +/// `channel_id`). +/// +/// `timeout: None` (the alkcall 10s default, as the other +/// registrations) — the gate is an in-memory lookup; the bound is +/// belt-and-braces, not workload. +fn make_forwarded_gate_establisher(gate: ForwardedGate) -> OpenEstablisher { + Arc::new(move |input: Value, _auth: AuthContext| { + let gate = Arc::clone(&gate); + Box::pin(async move { + let params: TunnelForwardedParams = serde_json::from_value(input.clone()) + .map_err(|e| TunnelEstablishError::HandlerError(format!("invalid params: {e}")))?; + gate(¶ms)?; + Ok(Establishment::new(Arc::new(ForwardedPlan) as ChannelPlan)) + }) + }) +} + +/// Register the forwarded op's opener-side serving openable WITH the +/// correlation-gate establisher (ADR-008 Amendment 2 — the +/// recommended shape): `ChannelCore::register_openable_with_establisher` +/// with the crate-owned gate establisher (private +/// `make_forwarded_gate_establisher`) wrapping the injected +/// [`ForwardedGate`]. A stale/missing `listen` token fails the open +/// as a typed `channel:open_failed` BEFORE the channel's data plane +/// is served — the phantom-channel shape (succeed at the call plane, +/// decline post-open, pump EOFs silently) is closed at the open. +/// +/// The same op, params, scope (`tunnel:forwarded`, run before the +/// establisher — see the +/// `make_forwarded_gate_establisher` doc) and reply +/// surface as [`register_tunnel_forwarded_openable`] ride; the wire +/// delta of Amendment 2 is empty (additive registration only). +/// +/// The base shape (establisher-less) stays for compatibility + as the +/// residual-race fallback posture: a token that vanishes between +/// gate pass and handler run is post-open — the handler's +/// teardown-at-birth semantics (see [`make_forwarded_op_handler`]'s +/// correlation-miss block) are the only thing that can reap it, the +/// same under both shapes. New assemblies take this shape by default +/// (ADR-008 Amendment 2 §Additive-only surface). +pub fn register_tunnel_forwarded_openable_with_establisher( + core: &ChannelCore, + on_registry: &Arc, + auth: AuthContext, + gate: ForwardedGate, + handler: ForwardedHandler, +) -> Result<(), String> { + core.register_openable_with_establisher( + crate::params::tunnel_forwarded_spec(), + Some(make_forwarded_gate_establisher(gate)), + make_forwarded_op_handler(handler), + on_registry, + auth, + None, + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tasks/tunnels/corrmiss-forwarded-gate.md b/tasks/tunnels/corrmiss-forwarded-gate.md index 397ea8a..966556d 100644 --- a/tasks/tunnels/corrmiss-forwarded-gate.md +++ b/tasks/tunnels/corrmiss-forwarded-gate.md @@ -1,7 +1,7 @@ --- id: tunnels/corrmiss-forwarded-gate name: "API: the forwarded-op establisher variant — ForwardedGate + register_tunnel_forwarded_openable_with_establisher" -status: pending +status: completed depends_on: [tunnels/corrmiss-adr] scope: moderate risk: medium @@ -136,8 +136,51 @@ NOT in this task: the sibling-repo adoptions (alkssh W2 / alksocks W3 ## Notes -> To be filled by implementation agent +- `ForwardedGate` (sync closure over the parsed `TunnelForwardedParams`) + wraps into a private `make_forwarded_gate_establisher` → + `register_openable_with_establisher(spec, Some(...), make_forwarded_op_handler(handler), registry, auth, None)` + — the base handler is reused byte-for-byte, so the base registration + stays untouched (additive-only). +- The design decisions were finalized exactly as the task sketched: + (1) gate receives the PARSED params; establisher parse failure maps + `TunnelEstablishError::HandlerError` → typed `channel:open_failed` + (pinned in the establisher doc — an improvement over the base + shape's silent birth-teardown for malformed ingress); the handler + re-parses the raw input (deny-unknown at both layers, handler + unchanged); (2) gate pass → `Establishment::new(plan)` with an + EMPTY MARKER plan payload — pinned as a new pub `ForwardedPlan` + unit struct (chosen over `()`: self-documenting, downcast-able by + assemblies that want to assert the gate ran — same typed-opaque + posture as ADR-049 amendment 2); no reply-field contribution; + (3) gate errors pass through the existing + `From` table, no new variants — the gate doc + pins `UnknownResource` as the canonical stale-token reason; + (4) `timeout: None` (10s default); (5) no scope-table change — the + wrapper order (schema → ACL → allocate → establisher → handler) is + pinned in the establisher doc; the order-pinning test lands with + the tests task. +- Doc updates: `make_forwarded_op_handler`'s correlation-miss block + + `register_tunnel_forwarded_openable`'s doc now carry the two-shape + posture. Two adjacent doc drifts were synced in the same wave per + ADR-008 Amendment 2 ("syncs with the API task's in-code doc + updates"): `wire.md` §The Forwarded Op's "no establisher" prose + line, and `tunnel_forwarded_spec()`'s "No establisher" doc block in + `src/params.rs`. ## Summary -> To be filled on completion \ No newline at end of file +Landed the forwarded-op establisher variant per ADR-008 Amendment 2: +`ForwardedGate`, `ForwardedPlan`, `make_forwarded_gate_establisher` +(private), and `register_tunnel_forwarded_openable_with_establisher` +in `src/producer.rs`, re-exported from `lib.rs`. A gate `Err` (and an +establisher parse failure) now yields a typed `channel:open_failed` +before the channel is born; the base shape stays byte-for-byte +compatible as the compatibility + residual-race-fallback posture. +In-code docs updated (two-shape posture) plus the wire.md/params.rs +doc sync the amendment rides on this task. Verification: `cargo test` +green (existing suite untouched), clippy `--all-targets -D warnings` +clean native + wasm32, `cargo fmt --check` clean, `cargo doc +--no-deps` clean (0 warnings), `--all-features` test run green. +Follow-ups: the dedicated gate-miss tests task +(`tunnels/corrmiss-gate-tests`), then the sibling-repo adoptions +(`corrmiss-alkssh-adopt`, `corrmiss-alksocks-adopt`). \ No newline at end of file