feat(producer): the forwarded-op establisher variant — ForwardedGate + register_tunnel_forwarded_openable_with_establisher

Implements plan 003 W1's API item (ADR-008 Amendment 2, task
tunnels/corrmiss-forwarded-gate): the assembly-injected correlation
gate wrapped into the alkcall OpenEstablisher dance for the forwarded
op's serving half.

- ForwardedGate: sync gate closure over the parsed
  TunnelForwardedParams; a gate Err → typed channel:open_failed
  before the channel is born (no phantom channel)
- register_tunnel_forwarded_openable_with_establisher: reuses
  make_forwarded_op_handler unchanged (additive-only; the base
  registration stays byte-for-byte compatible as the
  compatibility + residual-race-fallback posture)
- ForwardedPlan: empty marker plan payload (typed-opaque posture,
  payload choice pinned); no reply-field contribution
- Establisher parse failure maps TunnelEstablishError::HandlerError
  → channel:open_failed (deliberately pinned improvement over the
  base shape's silent birth-teardown for malformed ingress); gate
  errors pass through the existing From<TunnelEstablishError> table
  (no new variants; stale token = unknown_resource); timeout: None;
  wrapper order (schema → ACL → allocate → establisher → handler)
  pinned in the establisher doc
- Two-shape posture doc updates: make_forwarded_op_handler's
  correlation-miss block, register_tunnel_forwarded_openable's doc;
  wire.md §The Forwarded Op + tunnel_forwarded_spec() doc synced
  per Amendment 2's "syncs with the API task's in-code doc updates"

Verification: cargo test (85 green, existing suite untouched);
clippy --all-targets -D warnings (native + wasm32); cargo fmt --check;
cargo doc --no-deps (0 warnings); --all-features test run green
This commit is contained in:
glm-5.3-flash committed 2026-11-03 12:28:00 +00:00
1 parent 7d2b6d11f0
commit 5b2f7255f7
5 files changed
+213 -23

No files matched your search

+6 -1
View File
@@ -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).
+1
View File
@@ -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,
+8 -4
View File
@@ -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,
+152 -15
View File
@@ -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<ChannelPlan>, 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<dyn Fn(&TunnelForwardedParams) -> 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<TunnelEstablishError> 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(&params)?;
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<OperationRegistry>,
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::*;
+46 -3
View File
@@ -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<TunnelEstablishError>` 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
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`).