Files
alkcall/src/channels/client.rs
T
glm-5.3-flash 944c300c17 feat(channels): implement the single-publish fixes — S-01..S-04 (reviews 010/011; ADR-047 am. 4, ADR-052, ADR-053, ADR-022 am.)
S-01 — the marked-pub open contract (ADR-047 amendment 4):
- ChannelCore::register_pub_openable + PubOpenHandler (the put path's
  handler returns its result for the merged single response; the
  wrapper awaits inline, teardown is EOF-driven, handler_task stays
  None); register_openable* refuses Pub-typed specs; the wrapper entry
  points refuse unmarked/non-Pub shapes
- the reserved input key `channel_id` (the initiator-allocated id):
  stripped pre-validation by resolve_sink_handler (the single source of
  truth, P-08), handed to the wrapper via OperationContext.metadata, a
  missing key rejected INVALID_INPUT; adopt parity enforced
  (ManagerError::InvalidAdopt) and adopt failures answer
  channel:open_failed / adopt_failed
- zero call.published on marked ops: the first publish chunk on a
  marked op is rejected loudly (channel:publish_chunks_rejected) in
  both dispatch loops; the handler is never fed
- relay put-translation replaces RelayError::PubTypedOpen: hub
  allocates odd on the producer leg, opens the spoke op in the
  background (put-acks-in-result), one-direction pump, response-mirror
  with the producer's reserved key stripped
- ChannelClient::open_pub_channel + PubOpenReply (the initiator-
  allocates put open capability)

S-02 — channel/control implemented (ADR-052): establisher-contributed
ChannelControl (Establishment::with_control), manager-owned registry,
teardown removals, channel:no_control_handle capability answer.

S-03 — channel/resources/subscribe implemented (ADR-053): the
ResourceEnumerators registry (shared per peer), snapshot-then-changes
enumerators, scan-once aggregation, the empty seed; the
always-failing one-shot error stream removed.

S-04 — op/register forward-through (ADR-022 amendment): announced
unmarked Sub/Pub register the real streaming/sink forwarding shapes;
marked announcements refused with MARKED_SPEC_UNSUPPORTED; marked
specs refuse the generic registration path and register_imported
(Result-returning now).

Guard (review 010 action 3): tests/stub_guard.rs greps src/ for
not_implemented; the four pinned stub tests are deleted and the
marked-spec registration refusals are pinned by tests.

Verified: cargo test (696 + guard), clippy --all-targets -D warnings,
fmt --check, fuzz corpus replay.
2026-10-13 09:00:21 +00:00

3306 lines
140 KiB
Rust

//! `ChannelClient` — the client-side type for a channels connection
//! (ADR-043). Transport-agnostic `from_connection` primary: takes an
//! established `Connection` (the consumer dials the transport and
//! establishes the channels ALPN), runs the demux/mux, and exposes
//! `open_channel(alpn, params)` to open data channels via the
//! per-ALPN open ops on channel 0.
//!
//! Channel 0 runs in single-stream call mode (ADR-036 amendment):
//! all `EventEnvelope` frames for call operations are multiplexed on
//! channel 0's one `BiStream`. `CallConnection::new_single_stream`
//! holds the framed write half; the client spawns a read pump that
//! reads `EventEnvelope` frames off channel 0's reassembled read half
//! (fed by the demux) and routes them into the `PendingRequestMap`
//! via `dispatch_envelope`.
//!
//! The dial (TLS, QUIC, WebSocket) lives in the consumer —
//! `ChannelClient` is transport-agnostic by construction (ADR-043).
//!
//! See `docs/architecture/channel-client.md` for the spec.
use std::sync::Arc;
use serde_json::Value;
use tokio::sync::Mutex;
use crate::core::types::{Connection, StreamError};
use crate::protocol::connection::CallConnection;
use crate::protocol::wire::{CallError, ResponseEnvelope};
use crate::registry::registration::OperationRegistry;
use super::manager::{ChannelManager, ChannelSide};
use super::mux::MuxRunner;
use super::reassembly::{MpscRecvStream, MpscSendStream};
/// The typed error from [`ChannelClient::open_channel`] (ADR-049 §4 —
/// review 006 N-1). The open op's `CallError` — including the
/// `channel:open_failed` code with `details: { reason, message }`
/// (ADR-049 §3) — is carried verbatim instead of flattened into a
/// string, so the consumer can branch on the establishment-failure
/// reason (`dial_failed`, `unknown_resource`, `resource_shortage`,
/// `handler_error`, `timeout`).
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum ChannelOpenError {
/// The open op failed — the `CallError` carries the wire code and
/// typed `details` (`channel:open_failed` + `details.reason` per
/// ADR-049 §3, `channel:too_many_channels`, `FORBIDDEN`, …).
#[error("open op failed: {error:?}")]
CallFailed { error: CallError },
/// The open op succeeded but the response carried no `channel_id`
/// (malformed responder).
#[error("open op response missing channel_id")]
MissingChannelId,
/// The open op succeeded but local channel adoption failed (the
/// per-connection cap, or an ID collision).
#[error("adopt_channel failed: {0}")]
AdoptFailed(#[from] super::manager::ManagerError),
}
impl ChannelOpenError {
/// The wire `CallError` behind this failure, when the open op
/// itself failed. `None` for the local-only variants
/// (`MissingChannelId` comes from a success envelope;
/// `AdoptFailed` is post-reply).
pub fn call_error(&self) -> Option<&CallError> {
match self {
ChannelOpenError::CallFailed { error } => Some(error),
ChannelOpenError::MissingChannelId | ChannelOpenError::AdoptFailed(_) => None,
}
}
/// The establishment-failure reason code — `details.reason` of a
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
/// `resource_shortage`, `handler_error`, `timeout`, `adopt_failed`).
/// `None` for any other failure shape.
pub fn establishment_reason(&self) -> Option<&str> {
match self {
ChannelOpenError::CallFailed { error }
if error.code == super::operations::CHANNEL_OPEN_FAILED =>
{
error
.details
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|r| r.as_str())
}
_ => None,
}
}
}
/// The awaited single merged response of a put open
/// ([`ChannelClient::open_pub_channel`], ADR-047 amendment 4 §4.9 —
/// the put ack rides in the result, ADR-047 amendment 4 §4.5). The
/// putter writes bytes on the returned `send` half (or spawns pumps)
/// and awaits this for the put's wire contract (e.g. alkblobs' put
/// replying `{ token, digest }`). Await this after (or alongside) the
/// byte stream, never before it — the response cannot exist before the
/// byte stream completes.
pub struct PubOpenReply {
rx: tokio::sync::oneshot::Receiver<Result<Value, ChannelOpenError>>,
}
impl PubOpenReply {
/// The putter-side await: the merged response output (`Ok`);
/// `Err` carries the wire `CallError` verbatim
/// (`channel:open_failed` + `details.reason` per ADR-049 §3).
pub async fn resolve(self) -> Result<Value, ChannelOpenError> {
match self.rx.await {
Ok(result) => result,
Err(_) => Err(ChannelOpenError::MissingChannelId),
}
}
}
/// The client-side handle for a channels connection. Constructed via
/// [`ChannelClient::from_connection`] from an established
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
/// and the `CallConnection` (for calling open ops on channel 0).
///
/// The consumer dials the transport and establishes the
/// `alk/channels` ALPN, then hands the `Connection` to
/// `from_connection`. The client runs the demux/mux in spawned tasks
/// and exposes `open_channel` to call the per-ALPN open ops
/// (`channels/<alpn>/sub`, `channels/<alpn>/pub`) on channel 0.
pub struct ChannelClient {
manager: ChannelManager,
call_connection: Arc<Mutex<Option<Arc<CallConnection>>>>,
}
/// Opt-in serving configuration for a `ChannelClient` (review 004
/// F-04). A pure consumer has no registry to serve — `from_connection`
/// keeps the resolution-only read pump. A consumer that also serves
/// ops passes its registry (and identity provider) via
/// [`ServingConfig`]; the read pump becomes the full-duplex serving
/// loop (`Dispatcher::serve_single_stream`), so inbound
/// `call.requested` frames from the peer dispatch and resolve instead
/// of being dropped.
///
/// The caller identity for dispatch (CF-005): when the serving loop
/// dispatches an inbound `call.requested`, `Dispatcher::resolve_identity`
/// consults, in precedence order — (1) the payload `auth_token`
/// resolved through `identity_provider` (the hub-forwarding /
/// browser-token path), (2) `identity` if set (an explicit override),
/// (3) the transport connection's identity, if the consumer called
/// `Connection::set_identity` after dialing (the mTLS/QUIC key-based
/// path). When none is available the dispatch runs identity-less and
/// `AccessControl::check` fails closed (`FORBIDDEN`).
pub struct ServingConfig {
pub registry: Arc<OperationRegistry>,
pub identity_provider: Arc<dyn crate::core::auth::IdentityProvider>,
/// An explicit caller identity for the serving dispatch (the
/// connect-side seam, CF-005 remediation (a)). `None` (the
/// default) falls through to the transport connection's identity
/// (CF-005 remediation (b) — propagated automatically when the
/// consumer set one before calling `from_connection_with_serving`).
pub identity: Option<crate::core::auth::Identity>,
}
impl Default for ServingConfig {
fn default() -> Self {
Self {
registry: Arc::new(OperationRegistry::new()),
identity_provider: Arc::new(crate::core::auth::NoopIdentityProvider),
identity: None,
}
}
}
impl ChannelClient {
/// Construct from an established `Connection` (the consumer dials
/// the transport and establishes the `alk/channels` ALPN).
/// Installs channel 0 (pre-negotiated as `alk/call`,
/// ADR-036), wraps it as a `CallConnection` in single-stream call
/// mode (ADR-036 amendment), spawns the demux and mux tasks, and
/// returns the client.
///
/// The call dispatch loop on channel 0 is driven by the client:
/// a read pump task reads `EventEnvelope` frames off channel 0's
/// reassembled read half (fed by the demux) and routes them into
/// the `PendingRequestMap` via `dispatch_envelope`, resolving
/// pending calls. The `CallConnection` holds the framed write
/// half so `call_open_op` can write `call.requested` frames.
///
/// Serving (review 004 F-04): with `serving: None` (the default)
/// the read pump resolves responses only — inbound
/// `call.requested` frames are dropped (a pure consumer has no
/// registry to serve). With `Some(ServingConfig { .. })` the read
/// pump is the full-duplex serving loop
/// (`Dispatcher::serve_single_stream`): inbound requests dispatch
/// against the configured registry and resolve back to the peer,
/// and outbound pendings still resolve. This is the opt-in that
/// makes the connect side a serving half on channel 0 (ADR-022 §2
/// — both sides can be both).
///
/// Caller identity for the serving dispatch (CF-005): the
/// `ServingConfig.identity` override wins; otherwise the transport
/// connection's identity (`Connection::identity`, set by the
/// consumer after dialing from the transport-authenticated peer)
/// propagates to channel 0. The payload `auth_token` →
/// `ServingConfig.identity_provider` path still takes precedence
/// over both (ADR-017 §7). With no identity from any path the
/// dispatch runs identity-less and scope-gated ops fail closed
/// (`FORBIDDEN`).
pub async fn from_connection_with_serving(
connection: Connection,
serving: Option<ServingConfig>,
) -> Result<Self, StreamError> {
let remote_addr = connection.remote_addr();
let bidi = connection.accept_bi().await?;
let (reader, writer) = tokio::io::split(bidi);
let (mux_handle, mux_runner) = MuxRunner::new(Box::new(writer));
// Spawn the mux runner BEFORE installing channel 0 —
// `install_channel_zero` calls `mux.register(0).await` which
// needs the runner to be draining `new_pumps`.
let _mux_task = tokio::spawn(async move {
if let Err(e) = mux_runner.run().await {
tracing::warn!(error = %e, "channel client: mux runner ended with error");
}
});
let manager = ChannelManager::new(
mux_handle,
super::manager::DEFAULT_MAX_CHANNELS,
super::reassembly::DEFAULT_BUFFER_CAP,
remote_addr,
ChannelSide::Connect,
);
let (channel0_send, channel0_recv) = manager
.install_channel_zero(None)
.await
.map_err(|_| StreamError::StreamClosed)?;
// Single-stream call mode (ADR-036 amendment): channel 0's
// read+write halves are split into a `SharedFrameWriter`
// (for `call.requested`) and a read pump (for responses).
let channel0_source =
super::source::channel_source(channel0_recv, channel0_send, remote_addr);
let channel0_conn = Connection::from_source(channel0_source, b"alk/call".to_vec());
// The caller-identity seam (CF-005): the serving loop's dispatch
// resolves the peer identity from this connection
// (`dispatch_start` → `connection.identity()`), so it must carry
// the identity the serving ops should authenticate by. The
// explicit `ServingConfig.identity` wins; otherwise the
// transport connection's identity propagates (the
// mTLS/QUIC key-based path — the peer the transport already
// authenticated). `set_identity` is once-only; silently
// skip if somehow already set.
let identity_for_serving = serving
.as_ref()
.and_then(|config| config.identity.clone())
.or_else(|| connection.identity().cloned());
if let Some(identity) = identity_for_serving {
let _ = channel0_conn.set_identity(identity);
}
let channel0_bidi = channel0_conn.accept_bi().await?;
let (single_stream_writer, single_stream_reader) =
crate::protocol::connection::split_single_stream(channel0_bidi);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&single_stream_writer),
));
let pending_map = Arc::clone(call_connection.pending());
match serving {
None => {
let _read_pump = tokio::spawn(async move {
crate::protocol::connection::read_single_stream_until_closed(
single_stream_reader,
&pending_map,
)
.await;
});
}
Some(config) => {
let dispatcher = crate::protocol::dispatch::Dispatcher::new(
config.registry,
config.identity_provider,
);
let call_conn_for_loop = Arc::clone(&call_connection);
let _serve_loop = tokio::spawn(async move {
dispatcher
.serve_single_stream(
call_conn_for_loop,
single_stream_reader,
single_stream_writer,
)
.await;
});
}
}
// The demux loop ends on transport EOF, clearing the channel
// map (REQ-CH-02). The connect side passes `policy: None` (it
// does not enforce the per-identity cap).
let demux_manager = manager.clone();
let _demux_task = tokio::spawn(async move {
super::adapter::ChannelsAdapter::run_demux_loop_for_client(
&demux_manager,
Box::new(reader),
None,
)
.await;
});
Ok(Self {
manager,
call_connection: Arc::new(Mutex::new(Some(call_connection))),
})
}
/// Construct from an established `Connection` with serving
/// disabled (the pure-consumer default). See
/// [`ChannelClient::from_connection_with_serving`].
pub async fn from_connection(connection: Connection) -> Result<Self, StreamError> {
Self::from_connection_with_serving(connection, None).await
}
/// The `ChannelManager` — for relay logic and tests.
pub fn manager(&self) -> &ChannelManager {
&self.manager
}
/// Call a per-ALPN open op (`channels/<alpn>/sub` or
/// `channels/<alpn>/pub`) on channel 0. Returns the
/// `ResponseEnvelope` (which carries `channel_id` on success).
///
/// In single-stream call mode (ADR-036 amendment), this writes
/// `call.requested` through channel 0's shared frame writer and
/// awaits the response via the `PendingRequestMap` (resolved by
/// the read pump from the demux).
pub async fn call_open_op(&self, operation_id: &str, input: Value) -> ResponseEnvelope {
let guard = self.call_connection.lock().await;
match guard.as_ref() {
Some(conn) => conn.call(operation_id, input).await,
None => ResponseEnvelope::error(
"channel-client",
crate::protocol::wire::CallError::internal("channel client closed"),
),
}
}
/// Open a data channel by calling the per-ALPN open op on channel 0
/// and adopting the resulting `channel_id` (ADR-047 §5 odd/even
/// split). The connect side calls the open op; the accept side
/// allocates the `channel_id` (even). The connect side then adopts
/// the `channel_id` via [`ChannelManager::adopt_channel`] to install
/// local routing state (mux write half + demux read half).
///
/// Returns the `channel_id`, the `MpscSendStream` (write half), and
/// the `MpscRecvStream` (read half). The caller can build a
/// `Connection` from these via `channel_source` and
/// `Connection::from_source`.
///
/// `alpn` is the data-plane ALPN (e.g. `alk/tty`), used for
/// observability in the local manager.
///
/// The error is typed (ADR-049 §4 — review 006 N-1): a failed open
/// resolves [`ChannelOpenError::CallFailed`] carrying the
/// wire `CallError` verbatim — branch on
/// [`ChannelOpenError::establishment_reason`] for
/// `channel:open_failed`'s reason code (`dial_failed`,
/// `unknown_resource`, `resource_shortage`, `handler_error`,
/// `timeout`).
pub async fn open_channel(
&self,
operation_id: &str,
input: Value,
alpn: &str,
) -> Result<(u32, MpscSendStream, MpscRecvStream), ChannelOpenError> {
let (channel_id, _reply, send, recv) = self
.open_channel_with_reply(operation_id, input, alpn)
.await?;
Ok((channel_id, send, recv))
}
/// Open a channel and return the open-op success reply's extra
/// fields alongside the streams (ADR-049 amendment 3). The reply
/// is the full success output — `channel_id` plus any
/// establisher-contributed fields (e.g. a bind-first listener's
/// `bound`); a producer that contributed none returns just
/// `{ channel_id }`, so the payload's `Value` is the old reply.
/// [`ChannelClient::open_channel`] delegates here and discards the
/// reply, keeping its signature unchanged.
pub async fn open_channel_with_reply(
&self,
operation_id: &str,
input: Value,
alpn: &str,
) -> Result<(u32, Value, MpscSendStream, MpscRecvStream), ChannelOpenError> {
let response = self.call_open_op(operation_id, input).await;
let out = response
.result
.map_err(|error| ChannelOpenError::CallFailed { error })?;
let channel_id = out
.get("channel_id")
.and_then(|v| v.as_u64())
.ok_or(ChannelOpenError::MissingChannelId)? as u32;
self.manager
.adopt_channel(channel_id, alpn, None)
.await
.map_err(ChannelOpenError::AdoptFailed)
.map(|(send, recv)| (channel_id, out, send, recv))
}
/// The putter-facing open (ADR-047 amendment 4 §4.9 — the
/// initiator-allocates capability `open_channel`/`open_channel_with_reply`
/// cannot provide, because those await the open reply before
/// returning the halves and a put's bytes flow before the reply
/// exists, review 011 §2.7): allocates the connect-side odd id
/// locally, inserts the reserved input key, sends the open op in a
/// background task, and returns **immediately** — the caller
/// writes bytes on `send` without waiting for anything (the
/// put ack rides in the merged response; ADR-047 amendment 4 §4.5).
///
/// Returns `(channel_id, send, recv, reply)`; `reply` is the
/// awaited single merged response — `Ok(Value)` (the full output,
/// e.g. `{ token, digest }`) or the wire error. On a failed open
/// the local adopt entry is torn down and `send`/`recv` are
/// already EOF'd (teardown); the caller still holds them but every
/// write is EOF-shaped. The `recv` half is the (bidirectional)
/// channel's read side — a put that also receives reads it.
pub async fn open_pub_channel(
&self,
operation_id: &str,
input: Value,
alpn: &str,
) -> Result<(u32, MpscSendStream, MpscRecvStream, PubOpenReply), ChannelOpenError> {
use crate::registry::spec::RESERVED_OPEN_INPUT_KEY;
let (channel_id, send, recv) = self
.manager
.open_channel(
alpn,
// The opener ledger is inert on the connect side (it
// does not enforce the per-identity cap, ADR-041) —
// the record is observability only.
"channel-client",
None,
)
.await
.map_err(|e| match e {
crate::channels::manager::ManagerError::TooManyChannels { count, max } => {
ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::new(
"channel:too_many_channels",
format!("per-connection channel cap exceeded: {count}/{max}"),
false,
)
.with_details(serde_json::json!({ "count": count, "max": max })),
}
}
other => ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::new(
"channel:allocation_failed",
format!("channel allocation failed: {other}"),
false,
),
},
})?;
let mut op_input = input;
match op_input.as_object_mut() {
Some(obj) => {
obj.insert(
RESERVED_OPEN_INPUT_KEY.to_string(),
serde_json::json!(channel_id),
);
}
None => {
return Err(ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::invalid_input(
"marked pub open op input must be an object (ADR-047 amendment 4 §4.3)",
),
});
}
}
let call_conn = {
let guard = self.call_connection.lock().await;
guard.as_ref().map(Arc::clone)
};
let manager = self.manager.clone();
let op_id = operation_id.to_string();
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let response = match &call_conn {
Some(conn) => conn.call(&op_id, op_input).await,
None => ResponseEnvelope::error(
"channel-client",
crate::protocol::wire::CallError::internal("channel client closed"),
),
};
// A failed open tears the allocator-side entry down: the
// responder refused, the channel is dead, and the local
// half must not linger in the connection's channel map.
if response.result.is_err() {
let _ = manager.teardown_channel(channel_id);
}
let reply = match response.result {
Ok(out) => Ok(out),
Err(error) => Err(ChannelOpenError::CallFailed { error }),
};
let _ = reply_tx.send(reply);
});
Ok((channel_id, send, recv, PubOpenReply { rx: reply_rx }))
}
/// Take the `CallConnection` — used by the consumer to register
/// imported ops (`from_call`) on the connection's overlay. After
/// this, `call_open_op` returns an error (the connection is owned
/// by the consumer). The `Arc` is shared with the serving loop when
/// serving is enabled; taking it detaches the client's own calling
/// surface, not the serving loop's dispatch (the loop holds its own
/// `Arc` clone).
pub async fn take_call_connection(&self) -> Option<Arc<CallConnection>> {
self.call_connection.lock().await.take()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::adapter::ChannelsAdapter;
use crate::channels::policy::ChannelLifecyclePolicy;
use crate::channels::policy::NoCap;
use crate::core::auth::{AuthContext, IdentityProvider};
use crate::core::types::Connection;
use crate::protocol::connection::split_single_stream;
use crate::protocol::dispatch::Dispatcher;
use crate::registry::context::OperationContext;
use crate::registry::registration::{
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
const TEST_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321);
fn external_query_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
}
struct NoopIdProvider;
impl IdentityProvider for NoopIdProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<crate::core::auth::Identity> {
None
}
fn resolve_from_token(
&self,
_: &crate::core::auth::AuthToken,
) -> Option<crate::core::auth::Identity> {
None
}
}
/// Read one length-prefixed `EventEnvelope` frame off `reader` and
/// parse it into a `ResponseEnvelope`. Used by the single-stream
/// direct-drive tests to read the responder's single
/// `call.responded`/`call.error` frame for a Pub.
async fn read_single_stream_response(
reader: &mut (impl tokio::io::AsyncRead + Unpin),
) -> ResponseEnvelope {
use tokio::io::AsyncReadExt;
let mut len_buf = [0u8; 4];
reader
.read_exact(&mut len_buf)
.await
.expect("read response length");
let len = u32::from_be_bytes(len_buf) as usize;
let mut body = vec![0u8; len];
reader
.read_exact(&mut body)
.await
.expect("read response body");
let env: crate::protocol::wire::EventEnvelope =
serde_json::from_slice(&body).expect("parse response envelope");
let request_id = env.id.clone();
match env.r#type.as_str() {
"call.responded" => ResponseEnvelope::ok(
request_id,
env.payload
.get("output")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
"call.error" => {
let err: crate::protocol::wire::CallError = serde_json::from_value(env.payload)
.unwrap_or_else(|_| {
crate::protocol::wire::CallError::internal("malformed error payload")
});
ResponseEnvelope::error(request_id, err)
}
other => panic!("expected call.responded or call.error, got {other}"),
}
}
/// Build the `install_channel_zero` hook for the accept side: it
/// yields channel 0's `BiStream` once, splits it into the shared
/// writer + reader, constructs a single-stream `CallConnection`,
/// and runs `Dispatcher::run_loop_single_stream` on it. Returns
/// the `JoinHandle` for the spawned dispatch loop.
fn make_install_channel_zero(
registry: Arc<crate::registry::registration::OperationRegistry>,
) -> crate::channels::adapter::InstallChannelZero {
Arc::new(move |_manager, channel0_conn, _auth| {
let registry = Arc::clone(&registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let dp = Dispatcher::new(registry, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
})
}
/// C-25 #1 — the end-to-end acceptance gate for Unit 2 (channel 0
/// single-stream call mode, ADR-036 amendment). Wires
/// `ChannelClient` (connect side) ↔ `ChannelsAdapter` (accept
/// side) over a real `tokio::io::duplex` pair carrying the
/// channels 8-byte chunk header wire format. A `call.requested`
/// from the client is demuxed on channel 0, dispatched by the
/// server's single-stream `Dispatcher::run_loop_single_stream`,
/// and the `call.responded` is muxed back to the client's read
/// pump, resolving the pending call. This would have caught C-01
/// immediately (every `call_open_op` failed with
/// `StreamClosed` on the connect side; channel 0 was a black hole
/// on the accept side).
#[tokio::test]
async fn channel_0_end_to_end_call_round_trip() {
let registry = crate::registry::registration::OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_query_spec("echo/run"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(
make_install_channel_zero(Arc::clone(&registry)),
Arc::new(NoCap),
);
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.call_open_op("echo/run", serde_json::json!({ "msg": "hi" })),
)
.await
.expect("channel-0 round-trip timed out");
assert!(
response.result.is_ok(),
"channel-0 round-trip should succeed, got {:?}",
response.result
);
assert_eq!(response.result.unwrap(), serde_json::json!({ "msg": "hi" }));
}
/// C-25 #1 (negative case) — an unknown op over channel 0 returns
/// `NOT_FOUND`, proving the single-stream dispatch path reaches
/// the registry's not-found branch end-to-end (not just
/// `StreamClosed` from a dead channel 0 as in C-01).
#[tokio::test]
async fn channel_0_end_to_end_unknown_op_returns_not_found() {
let registry = Arc::new(crate::registry::registration::OperationRegistry::new());
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(
make_install_channel_zero(Arc::clone(&registry)),
Arc::new(NoCap),
);
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.call_open_op("no/such/op", serde_json::json!({})),
)
.await
.expect("channel-0 unknown-op timed out");
match response.result {
Err(e) => assert_eq!(e.code, "NOT_FOUND", "unknown op over channel 0"),
other => panic!("expected NOT_FOUND, got {other:?}"),
}
}
/// C-25 #1 (Pub over channel 0) — a `publish()` through channel
/// 0's single-stream mode delivers chunks to the responder's
/// `SinkHandler` and returns the handler's response. Proves the
/// single-stream dispatch loop's in-flight sink routing works
/// (`call.published` frames arriving after `call.requested` are
/// routed to the matching sink's `chunk_tx`).
#[tokio::test]
async fn channel_0_end_to_end_publish_delivers_chunks() {
use futures::stream::StreamExt;
let registry = crate::registry::registration::OperationRegistry::new();
let counting_sink = crate::registry::registration::make_sink_handler(
|_input, ctx, mut stream| async move {
let mut count = 0u32;
let mut last = serde_json::Value::Null;
while let Some(item) = stream.next().await {
match item {
Ok(v) => {
count += 1;
last = v;
}
Err(_) => break,
}
}
ResponseEnvelope::ok(
ctx.request_id,
serde_json::json!({ "count": count, "last": last }),
)
},
);
registry
.register(HandlerRegistration::new(
OperationSpec::new(
"fs/upload",
OperationType::Pub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
),
HandlerKind::Sink(counting_sink),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(
make_install_channel_zero(Arc::clone(&registry)),
Arc::new(NoCap),
);
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let call_conn = client
.take_call_connection()
.await
.expect("call connection present");
let chunks = vec![
serde_json::json!({"chunk": 1}),
serde_json::json!({"chunk": 2}),
serde_json::json!({"chunk": 3}),
];
let stream: std::pin::Pin<
Box<dyn futures::stream::Stream<Item = serde_json::Value> + Send>,
> = Box::pin(futures::stream::iter(chunks.clone()));
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
call_conn.publish("fs/upload", serde_json::json!({"path": "/x"}), stream),
)
.await
.expect("channel-0 publish timed out");
assert!(
response.result.is_ok(),
"publish should succeed, got {:?}",
response.result
);
let out = response.result.unwrap();
assert_eq!(
out["count"],
serde_json::json!(3),
"responder saw all 3 chunks"
);
assert_eq!(
out["last"],
serde_json::json!({"chunk": 3}),
"last chunk matches"
);
}
/// Unit 8 / P-03 (single-stream path) — `publish_schema` validation
/// fires on the single-stream dispatch loop: a chunk that violates
/// the schema is injected as an `Err(INVALID_INPUT)` into the
/// `SinkHandler`'s stream, terminating it. The handler observes the
/// valid chunks before the violation plus the error, and its
/// response reflects that. This proves the `EVENT_PUBLISHED` arm of
/// `run_loop_single_stream` validates against the
/// `InFlightSink.publish_validator` before yielding `Ok(chunk)`.
#[tokio::test]
async fn channel_0_publish_schema_rejects_invalid_chunk_on_single_stream() {
use futures::stream::StreamExt;
let publish_schema = serde_json::json!({
"type": "object",
"properties": { "bytes": { "type": "string" } },
"required": ["bytes"]
});
let recording_sink = crate::registry::registration::make_sink_handler(
|_input, ctx, mut stream| async move {
let mut oks: Vec<serde_json::Value> = Vec::new();
let mut err: Option<crate::protocol::wire::CallError> = None;
while let Some(item) = stream.next().await {
match item {
Ok(v) => oks.push(v),
Err(e) => {
err = Some(e);
break;
}
}
}
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "ok": oks, "err": err }))
},
);
let registry = crate::registry::registration::OperationRegistry::new();
registry
.register(HandlerRegistration::new(
OperationSpec::new(
"fs/upload",
OperationType::Pub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
.with_publish_schema(publish_schema),
HandlerKind::Sink(recording_sink),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(
make_install_channel_zero(Arc::clone(&registry)),
Arc::new(NoCap),
);
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let call_conn = client
.take_call_connection()
.await
.expect("call connection present");
let chunks = vec![
serde_json::json!({"bytes": "hello"}),
serde_json::json!({"bytes": 42}),
];
let stream: std::pin::Pin<
Box<dyn futures::stream::Stream<Item = serde_json::Value> + Send>,
> = Box::pin(futures::stream::iter(chunks));
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
call_conn.publish("fs/upload", serde_json::json!({}), stream),
)
.await
.expect("channel-0 publish timed out");
let out = response.result.expect("publish ok");
let oks = out.get("ok").and_then(|v| v.as_array()).expect("ok array");
assert_eq!(oks.len(), 1, "only the valid chunk reached the handler");
assert_eq!(oks[0], serde_json::json!({"bytes": "hello"}));
let err = out.get("err").expect("err field");
assert_eq!(
err.get("code").and_then(|c| c.as_str()),
Some("INVALID_INPUT"),
"invalid chunk injects INVALID_INPUT on the single-stream path"
);
}
/// Unit 8 / P-04 (single-stream path) — an initiator-side
/// `call.error` during a publish over channel 0 is routed to the
/// matching in-flight sink's `chunk_tx` as `Err(call_error)`,
/// terminating the stream. The handler observes the chunks before
/// the error plus the initiator's `CallError` (not a synthetic
/// "aborted"). This drives `run_loop_single_stream` directly with
/// crafted frames (a `call.requested`, one `call.published`, then a
/// `call.error`) because the public `publish()` API takes
/// `Stream<Item = Value>` and cannot emit an initiator `call.error`
/// — that path is for a future `Stream<Item = Result<Value,
/// CallError>>` publish API. The `call.error` is injected manually
/// on the wire to prove the single-stream `EVENT_ERROR` arm parses
/// the payload and routes it to the right in-flight sink.
#[tokio::test]
async fn channel_0_initiator_call_error_terminates_publish_on_single_stream() {
use crate::protocol::connection::split_single_stream;
use crate::protocol::dispatch::Dispatcher;
use crate::protocol::wire::EventEnvelope;
use futures::stream::StreamExt;
let recording_sink = crate::registry::registration::make_sink_handler(
|_input, ctx, mut stream| async move {
let mut oks: Vec<serde_json::Value> = Vec::new();
let mut err: Option<crate::protocol::wire::CallError> = None;
while let Some(item) = stream.next().await {
match item {
Ok(v) => oks.push(v),
Err(e) => {
err = Some(e);
break;
}
}
}
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "ok": oks, "err": err }))
},
);
let registry = crate::registry::registration::OperationRegistry::new();
registry
.register(HandlerRegistration::new(
OperationSpec::new(
"fs/upload",
OperationType::Pub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
),
HandlerKind::Sink(recording_sink),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let request = EventEnvelope::requested(
"pub-ss-err-1",
serde_json::json!({
"operationId": "fs/upload",
"input": {},
}),
);
let published = EventEnvelope::published("pub-ss-err-1", serde_json::json!({"chunk": 1}));
let initiator_error = EventEnvelope::error(
"pub-ss-err-1",
&crate::protocol::wire::CallError::new("PUBLISH_FAILED", "disk full", false),
);
let mut frame_buf = Vec::new();
for env in [&request, &published, &initiator_error] {
let body = serde_json::to_vec(env).expect("serialize envelope");
frame_buf.extend_from_slice(&(body.len() as u32).to_be_bytes());
frame_buf.extend_from_slice(&body);
}
let (response_send, mut response_recv) = tokio::io::duplex(64 * 1024);
let bidi = crate::core::types::BiStream::from_joined(
tokio::io::BufReader::new(std::io::Cursor::new(frame_buf)),
response_send,
);
let (writer, reader) = split_single_stream(bidi);
let call_connection =
Arc::new(CallConnection::new(crate::protocol::sink_empty_connection()));
let dp = Dispatcher::new(registry, Arc::new(NoopIdProvider));
let _server_handle = tokio::spawn(async move {
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
});
let response = read_single_stream_response(&mut response_recv).await;
let out = response.result.expect("handler ok");
let oks = out.get("ok").and_then(|v| v.as_array()).expect("ok array");
assert_eq!(
oks.len(),
1,
"the chunk before the error reached the handler"
);
assert_eq!(oks[0], serde_json::json!({"chunk": 1}));
let err = out.get("err").expect("err field");
assert_eq!(
err.get("code").and_then(|c| c.as_str()),
Some("PUBLISH_FAILED"),
"the initiator's CallError code reaches the handler on the single-stream path"
);
}
/// C-02 / C-03 — the end-to-end acceptance gate for Unit 3
/// (`register_openable`, ADR-047 §3 as amended 2026-08-13 —
/// per-connection registration). Wires `ChannelClient` (connect
/// side) ↔ `ChannelsAdapter` (accept side) over a real
/// `tokio::io::duplex` carrying the channels 8-byte chunk header
/// wire format. The accept side's `install_channel_zero` hook
/// builds a per-connection `ChannelCore` from the adapter-supplied
/// `ChannelManager`, registers a no-op open op
/// (`channels/tty/sub`) via `register_openable` on a fresh
/// per-connection registry, and runs the single-stream dispatch
/// loop on it. The client calls `call_open_op("channels/tty/sub")`
/// on channel 0; the wrapper does `check_open` → `open_channel` →
/// spawn the no-op `OpenHandler` on the channel's `Connection` →
/// respond `{ channel_id }`. Asserts the response carries a
/// `channel_id` and that the per-identity quota was reserved
/// (the policy's count for the caller incremented).
///
/// This is the gate the review identifies as missing: "register a
/// no-op open op via `register_openable`, invoke it end-to-end
/// through channel 0, assert `channel_id` is returned and quota is
/// reserved." It would have caught C-02 (no way to register an
/// open op) and C-03 (`resolve_channel_manager` stub) immediately.
#[tokio::test]
async fn channel_0_end_to_end_register_openable_returns_channel_id() {
use crate::channels::operations::{ChannelCore, OpenHandler};
use crate::channels::policy::PerIdentityChannelPolicy;
use crate::registry::spec::ChannelOpenSpec;
let policy: Arc<PerIdentityChannelPolicy> = Arc::new(PerIdentityChannelPolicy::new(256));
let policy_for_hook: Arc<dyn ChannelLifecyclePolicy> =
Arc::clone(&policy) as Arc<dyn ChannelLifecyclePolicy>;
let policy_for_assert = Arc::clone(&policy);
let open_handler: OpenHandler = Arc::new(|_input, _plan, _channel_conn, _auth| {
tokio::spawn(async move {
// No-op: the channel's BiStream is available via
// `_channel_conn.accept_bi()` if the test wanted
// to move data on it. For the open-succeeds gate,
// just keep the task alive briefly so the handler
// task is real (and recorded for teardown).
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
})
});
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |manager, channel0_conn, auth| {
let open_handler = Arc::clone(&open_handler);
let policy = Arc::clone(&policy_for_hook);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let core = ChannelCore::new(manager, policy);
let registry = crate::registry::registration::OperationRegistry::new();
let spec = OperationSpec::new(
"channels/tty/sub",
OperationType::Sub,
Visibility::External,
serde_json::json!({
"type": "object",
"properties": {
"container": { "type": "string" }
},
"required": ["container"]
}),
serde_json::json!({
"type": "object",
"properties": {
"channel_id": { "type": "integer" }
}
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tty"));
core.register_openable(
spec,
Arc::clone(&open_handler),
&registry,
auth.clone(),
)
.expect("register_openable");
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let dp = Dispatcher::new(registry, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.call_open_op(
"channels/tty/sub",
serde_json::json!({ "container": "abc" }),
),
)
.await
.expect("channel-0 open op timed out");
assert!(
response.result.is_ok(),
"open op should succeed, got {:?}",
response.result
);
let out = response.result.unwrap();
let channel_id = out
.get("channel_id")
.and_then(|v| v.as_u64())
.expect("channel_id in response");
assert!(
channel_id > 0,
"channel_id should be non-zero (channel 0 is the call channel), got {channel_id}"
);
let anonymous = crate::core::auth::Identity {
id: "anonymous".to_string(),
scopes: vec![],
resources: Default::default(),
};
let count = policy_for_assert.count_for(&anonymous);
assert_eq!(
count, 1,
"quota reserved: the open op incremented the per-identity count"
);
}
/// C-25 #4 — policy decrement on connection drop (ADR-047 §7).
/// Open channels via the manager, then call `clear_all` through the
/// demux loop with a policy — the per-identity count must be
/// decremented for each drained channel.
#[tokio::test]
async fn policy_decremented_on_connection_drop() {
use crate::channels::policy::PerIdentityChannelPolicy;
let policy: Arc<PerIdentityChannelPolicy> = Arc::new(PerIdentityChannelPolicy::new(2));
let dyn_policy: Arc<dyn ChannelLifecyclePolicy> =
Arc::clone(&policy) as Arc<dyn ChannelLifecyclePolicy>;
let (client, server) = tokio::io::duplex(64 * 1024);
let (server_read, server_write) = tokio::io::split(server);
let (mux_handle, mux_runner) = MuxRunner::new(Box::new(server_write));
let _mux_task = tokio::spawn(async move {
let _ = mux_runner.run().await;
});
let manager = ChannelManager::with_defaults(mux_handle, None);
let alice = crate::core::auth::Identity {
id: "alice".to_string(),
scopes: vec![],
resources: Default::default(),
};
let bob = crate::core::auth::Identity {
id: "bob".to_string(),
scopes: vec![],
resources: Default::default(),
};
assert!(dyn_policy.check_open(&alice).is_ok());
manager
.open_channel("alk/tty", "alice", None)
.await
.expect("open alice");
assert!(dyn_policy.check_open(&bob).is_ok());
manager
.open_channel("alk/tty", "bob", None)
.await
.expect("open bob");
assert_eq!(policy.count_for(&alice), 1);
assert_eq!(policy.count_for(&bob), 1);
let demux_manager = manager.clone();
let demux_policy = Arc::clone(&dyn_policy);
let demux_task = tokio::spawn(async move {
ChannelsAdapter::run_demux_loop_for_client(
&demux_manager,
Box::new(server_read),
Some(&demux_policy),
)
.await;
});
drop(client);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), demux_task).await;
assert_eq!(
policy.count_for(&alice),
0,
"alice decremented after connection drop"
);
assert_eq!(
policy.count_for(&bob),
0,
"bob decremented after connection drop"
);
}
/// C-25 #5 — concurrent open race (TOCTOU). Multiple concurrent
/// `open_channel` calls on a manager with `max_channels=1` must not
/// all succeed — only one should pass the re-check on insert.
#[tokio::test]
async fn concurrent_opens_respect_max_channels() {
let (_client, server) = tokio::io::duplex(1024);
let (_reader, writer) = tokio::io::split(server);
let (handle, runner) = MuxRunner::new(Box::new(writer));
tokio::spawn(async move {
let _ = runner.run().await;
});
let manager = Arc::new(ChannelManager::new(
handle,
1,
64,
None,
ChannelSide::Accept,
));
let m1 = Arc::clone(&manager);
let m2 = Arc::clone(&manager);
let m3 = Arc::clone(&manager);
let (r1, r2, r3) = tokio::join!(
m1.open_channel("alk/a", "alice", None),
m2.open_channel("alk/b", "bob", None),
m3.open_channel("alk/c", "carol", None),
);
let successes = [r1.is_ok(), r2.is_ok(), r3.is_ok()]
.iter()
.filter(|&&ok| ok)
.count();
assert_eq!(
successes, 1,
"exactly one concurrent open should succeed with max_channels=1"
);
assert_eq!(
manager.open_count(),
1,
"channel count should be 1 after concurrent opens"
);
}
/// C-12 — `channel/close` with `channel_id: 0` is rejected.
#[tokio::test]
async fn channel_close_rejects_channel_zero() {
use crate::channels::operations::ChannelOperations;
use crate::channels::policy::NoCap;
use crate::registry::context::{AbortPolicy, ScopedPeerEnv};
use std::collections::HashMap;
use std::sync::Arc;
struct NoopEnv;
#[async_trait::async_trait]
impl crate::registry::env::OperationEnv for NoopEnv {
async fn invoke_with_policy(
&self,
_namespace: &str,
_operation: &str,
_input: serde_json::Value,
_parent: &OperationContext,
_policy: AbortPolicy,
) -> ResponseEnvelope {
ResponseEnvelope::error("test", crate::protocol::wire::CallError::internal("noop"))
}
fn contains(&self, _name: &str) -> bool {
false
}
}
let (_client, server) = tokio::io::duplex(1024);
let (_reader, writer) = tokio::io::split(server);
let (handle, runner) = MuxRunner::new(Box::new(writer));
tokio::spawn(async move {
let _ = runner.run().await;
});
let manager = ChannelManager::with_defaults(handle, None);
let ops = ChannelOperations::new(manager, Arc::new(NoCap));
let registry = crate::registry::registration::OperationRegistry::new();
ops.register_on(&registry).expect("register");
let handler = registry
.registration("channel/close")
.expect("close op registered")
.handler
.clone();
let ctx = OperationContext {
request_id: "req-1".to_string(),
parent_request_id: None,
identity: None,
handler_identity: None,
forwarded_for: None,
capabilities: crate::core::types::Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedPeerEnv::empty(),
env: Arc::new(NoopEnv),
abort_policy: AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + std::time::Duration::from_secs(30)),
internal: false,
ownership: None,
};
let input = serde_json::json!({ "channel_id": 0 });
let response = match handler {
HandlerKind::Once(h) => h(input, ctx).await,
_ => panic!("expected Once handler"),
};
assert!(
response.result.is_err(),
"channel/close with channel_id:0 should be rejected"
);
let err = response.result.unwrap_err();
assert!(
err.code == "INVALID_INPUT",
"expected INVALID_INPUT, got {}",
err.code
);
}
/// C-08 — end-to-end channel adoption: the connect side calls an
/// open op, receives a `channel_id`, adopts it, and can write data
/// through the adopted channel to the accept side's handler.
#[tokio::test]
async fn channel_adoption_end_to_end_round_trip() {
use crate::channels::operations::{ChannelCore, OpenHandler};
use crate::channels::policy::NoCap;
use crate::registry::spec::ChannelOpenSpec;
use tokio::io::AsyncReadExt;
let (data_tx, mut data_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(1);
let open_handler: OpenHandler = Arc::new(move |_input, _plan, channel_conn, _auth| {
let data_tx = data_tx.clone();
tokio::spawn(async move {
let mut bidi = channel_conn.accept_bi().await.expect("accept_bi");
let mut buf = [0u8; 4];
bidi.read_exact(&mut buf).await.expect("read");
data_tx.send(buf.to_vec()).await.expect("send to channel");
})
});
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |manager, channel0_conn, auth| {
let open_handler = Arc::clone(&open_handler);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let core = ChannelCore::new(manager, Arc::new(NoCap));
let registry = crate::registry::registration::OperationRegistry::new();
let spec = OperationSpec::new(
"channels/tty/sub",
OperationType::Sub,
Visibility::External,
serde_json::json!({
"type": "object",
"properties": { "container": { "type": "string" } },
"required": ["container"]
}),
serde_json::json!({
"type": "object",
"properties": { "channel_id": { "type": "integer" } }
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tty"));
core.register_openable(
spec,
Arc::clone(&open_handler),
&registry,
auth.clone(),
)
.expect("register_openable");
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let dp = Dispatcher::new(registry, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let (channel_id, mut send, _recv) = client
.open_channel(
"channels/tty/sub",
serde_json::json!({ "container": "abc" }),
"alk/tty",
)
.await
.expect("open_channel");
assert!(channel_id > 0, "channel_id should be non-zero");
assert!(channel_id % 2 == 0, "accept-allocated ID should be even");
send.write_all(b"ping").await.expect("write ping");
drop(send);
let data = tokio::time::timeout(std::time::Duration::from_secs(5), data_rx.recv())
.await
.expect("timed out waiting for handler data")
.expect("handler should receive data");
assert_eq!(
&data, b"ping",
"handler received ping through adopted channel"
);
}
/// ADR-049 Unit 1 acceptance gate (review 006 E-01/N-1) — the
/// establisher fails after channel allocation: the consumer's
/// `open_channel` resolves `Err` carrying the wire `CallError`
/// (`channel:open_failed` + `details.reason`), no `channel_id` was
/// ever returned (the SSH "channel never exists opener-side"
/// property, consumer-visible), and the accept side's manager has
/// no channel afterward (teardown + ledger un-increment).
#[tokio::test]
async fn establisher_failure_resolves_typed_open_failed_on_the_client() {
use crate::channels::operations::{
ChannelCore, EstablishmentError, OpenEstablisher, OpenHandler,
};
use crate::channels::policy::PerIdentityChannelPolicy;
use crate::registry::spec::ChannelOpenSpec;
let policy: Arc<PerIdentityChannelPolicy> = Arc::new(PerIdentityChannelPolicy::new(256));
let policy_for_hook: Arc<dyn ChannelLifecyclePolicy> =
Arc::clone(&policy) as Arc<dyn ChannelLifecyclePolicy>;
let policy_for_assert = Arc::clone(&policy);
let open_handler: OpenHandler =
Arc::new(|_input, _plan, _channel_conn, _auth| tokio::spawn(async {}));
let establisher: OpenEstablisher = Arc::new(|_input, _auth| {
Box::pin(async {
Err(EstablishmentError::DialFailed {
message: "target refused the connection".to_string(),
})
})
});
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |manager, channel0_conn, auth| {
let open_handler = Arc::clone(&open_handler);
let establisher = Arc::clone(&establisher);
let policy = Arc::clone(&policy_for_hook);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let core = ChannelCore::new(manager, policy);
let registry = crate::registry::registration::OperationRegistry::new();
let spec = OperationSpec::new(
"channels/tty/sub",
OperationType::Sub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({
"type": "object",
"properties": { "channel_id": { "type": "integer" } }
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tty"));
core.register_openable_with_establisher(
spec,
Some(establisher),
open_handler,
&registry,
auth.clone(),
None,
)
.expect("register_openable_with_establisher");
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let dp = Dispatcher::new(registry, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let err = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.open_channel(
"channels/tty/sub",
serde_json::json!({ "container": "abc" }),
"alk/tty",
),
)
.await
.expect("open_channel timed out");
let err = match err {
Err(e) => e,
Ok(_) => panic!("establisher failure must resolve Err through the client"),
};
// N-1 gate: the typed error carries the wire CallError verbatim.
let call_error = err.call_error().expect("CallFailed carries the CallError");
assert_eq!(call_error.code, "channel:open_failed");
assert_eq!(
err.establishment_reason(),
Some("dial_failed"),
"consumer branches on the typed reason code"
);
// The SSH property, consumer-visible: no channel_id was ever
// returned, and the accept side holds no channel for the failed
// open (channel 0 is the pre-negotiated call channel — always
// present, never the failed open's).
assert!(
client.manager().channel_ids().into_iter().all(|id| id == 0),
"no data channel exists for the failed open"
);
let anonymous = crate::core::auth::Identity {
id: "anonymous".to_string(),
scopes: vec![],
resources: Default::default(),
};
assert_eq!(
policy_for_assert.count_for(&anonymous),
0,
"ledger un-incremented on the accept side after establishment failure"
);
}
/// ADR-049 Unit 1 acceptance gate (companion to the E-01 gate) —
/// establisher success through a real channels connection: the
/// client's `open_channel` adopts and the pumps flow (the happy
/// path is unchanged by the establishment phase).
#[tokio::test]
async fn establisher_success_opens_and_pumps_data_end_to_end() {
use crate::channels::operations::{
ChannelCore, Establishment, OpenEstablisher, OpenHandler,
};
use crate::channels::policy::NoCap;
use crate::registry::spec::ChannelOpenSpec;
use tokio::io::AsyncReadExt;
let (data_tx, mut data_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(1);
let open_handler: OpenHandler = Arc::new(move |_input, _plan, channel_conn, _auth| {
let data_tx = data_tx.clone();
tokio::spawn(async move {
let mut bidi = channel_conn.accept_bi().await.expect("accept_bi");
let mut buf = [0u8; 4];
bidi.read_exact(&mut buf).await.expect("read");
data_tx.send(buf.to_vec()).await.expect("send to channel");
})
});
let establisher: OpenEstablisher =
Arc::new(|_input, _auth| Box::pin(async { Ok(Establishment::default()) }));
let establisher_for_hook = Arc::clone(&establisher);
let open_handler_for_hook = Arc::clone(&open_handler);
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |manager, channel0_conn, auth| {
let establisher = Arc::clone(&establisher_for_hook);
let open_handler = Arc::clone(&open_handler_for_hook);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let core = ChannelCore::new(manager, Arc::new(NoCap));
let registry = crate::registry::registration::OperationRegistry::new();
let spec = OperationSpec::new(
"channels/tty/sub",
OperationType::Sub,
Visibility::External,
serde_json::json!({
"type": "object",
"properties": { "container": { "type": "string" } },
"required": ["container"]
}),
serde_json::json!({
"type": "object",
"properties": { "channel_id": { "type": "integer" } }
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tty"));
core.register_openable_with_establisher(
spec,
Some(establisher),
open_handler,
&registry,
auth.clone(),
None,
)
.expect("register_openable_with_establisher");
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let dp = Dispatcher::new(registry, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let (channel_id, mut send, _recv) = client
.open_channel(
"channels/tty/sub",
serde_json::json!({ "container": "abc" }),
"alk/tty",
)
.await
.expect("open_channel with establisher success");
assert!(channel_id > 0, "channel_id should be non-zero");
send.write_all(b"ping").await.expect("write ping");
drop(send);
let data = tokio::time::timeout(std::time::Duration::from_secs(5), data_rx.recv())
.await
.expect("timed out waiting for handler data")
.expect("handler should receive data");
assert_eq!(&data, b"ping", "handler received ping post-establishment");
}
/// ADR-049 amendment 3 (review 008 U-2) acceptance gate: the
/// establisher contributes a reply field (`bound`, the bind-first
/// listener shape) and the client's `open_channel_with_reply`
/// receives it end-to-end over a real channels connection, while
/// `open_channel` keeps its pre-amendment shape (fields discarded).
#[tokio::test]
async fn establisher_reply_fields_reach_open_channel_with_reply_end_to_end() {
use crate::channels::operations::{
ChannelCore, Establishment, OpenEstablisher, OpenHandler,
};
use crate::channels::policy::NoCap;
use crate::registry::spec::ChannelOpenSpec;
let establisher: OpenEstablisher = Arc::new(|_input, _auth| {
Box::pin(async {
Ok(Establishment::default().with_reply_field(
"bound",
serde_json::json!({ "host": "203.0.113.9", "port": 42113 }),
))
})
});
let open_handler: OpenHandler =
Arc::new(|_input, _plan, _channel_conn, _auth| tokio::spawn(async {}));
let establisher_for_hook = Arc::clone(&establisher);
let open_handler_for_hook = Arc::clone(&open_handler);
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |manager, channel0_conn, auth| {
let establisher = Arc::clone(&establisher_for_hook);
let open_handler = Arc::clone(&open_handler_for_hook);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let core = ChannelCore::new(manager, Arc::new(NoCap));
let registry = crate::registry::registration::OperationRegistry::new();
let spec = OperationSpec::new(
"channels/tty/sub",
OperationType::Sub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({
"type": "object",
"properties": {
"channel_id": { "type": "integer" },
"bound": { "type": "object" }
}
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tty"));
core.register_openable_with_establisher(
spec,
Some(establisher),
open_handler,
&registry,
auth.clone(),
None,
)
.expect("register_openable_with_establisher");
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let dp = Dispatcher::new(registry, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let (channel_id, reply, mut send, _recv) = client
.open_channel_with_reply(
"channels/tty/sub",
serde_json::json!({ "container": "abc" }),
"alk/tty",
)
.await
.expect("open_channel_with_reply");
assert!(channel_id > 0, "channel_id should be non-zero");
assert_eq!(reply["channel_id"], serde_json::json!(channel_id));
assert_eq!(
reply["bound"],
serde_json::json!({ "host": "203.0.113.9", "port": 42113 }),
"the establisher's reply field survives the wire to the consumer"
);
// The streams work as always — the projection is reply-plane only.
send.write_all(b"ping").await.expect("write ping");
drop(send);
// `open_channel` (fields discarded) still opens cleanly against
// the same accept side.
let (channel_id2, _send2, _recv2) = client
.open_channel("channels/tty/sub", serde_json::json!({}), "alk/tty")
.await
.expect("open_channel unchanged");
assert!(channel_id2 > 0);
}
/// Review 009 C-4: the new pub API's failure path — a
/// `channel:open_failed` resolving through
/// `open_channel_with_reply` carries the full wire error shape
/// (`code` + typed `details` with `reason` and `message`), so the
/// caller can branch on the establishment reason through the new
/// API the same way `open_channel`'s shared parse path does.
#[tokio::test]
async fn open_channel_with_reply_resolves_typed_open_failed_end_to_end() {
use crate::channels::operations::{
ChannelCore, EstablishmentError, OpenEstablisher, OpenHandler,
};
use crate::channels::policy::NoCap;
use crate::registry::spec::ChannelOpenSpec;
let open_handler: OpenHandler =
Arc::new(|_input, _plan, _channel_conn, _auth| tokio::spawn(async {}));
let establisher: OpenEstablisher = Arc::new(|_input, _auth| {
Box::pin(async {
Err(EstablishmentError::DialFailed {
message: "target refused the connection".to_string(),
})
})
});
let establisher_for_hook = Arc::clone(&establisher);
let open_handler_for_hook = Arc::clone(&open_handler);
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |manager, channel0_conn, auth| {
let establisher = Arc::clone(&establisher_for_hook);
let open_handler = Arc::clone(&open_handler_for_hook);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let core = ChannelCore::new(manager, Arc::new(NoCap));
let registry = crate::registry::registration::OperationRegistry::new();
let spec = OperationSpec::new(
"channels/tty/sub",
OperationType::Sub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({
"type": "object",
"properties": { "channel_id": { "type": "integer" } }
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tty"));
core.register_openable_with_establisher(
spec,
Some(establisher),
open_handler,
&registry,
auth.clone(),
None,
)
.expect("register_openable_with_establisher");
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let dp = Dispatcher::new(registry, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let err = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.open_channel_with_reply(
"channels/tty/sub",
serde_json::json!({ "container": "abc" }),
"alk/tty",
),
)
.await
.expect("open_channel_with_reply timed out");
let err = match err {
Err(e) => e,
Ok(_) => panic!("the establisher failure must resolve Err through the reply API"),
};
// The full reply shape is assertable by the caller: the wire
// code, the reason code, and the message inside `details`.
let call_error = err.call_error().expect("CallFailed carries the CallError");
assert_eq!(call_error.code, "channel:open_failed");
assert_eq!(err.establishment_reason(), Some("dial_failed"));
let details = call_error
.details
.as_ref()
.expect("details ride the typed error");
assert_eq!(details["reason"], serde_json::json!("dial_failed"));
assert_eq!(
details["message"],
serde_json::json!("target refused the connection"),
"the establishment-failure message survives the wire to the caller"
);
assert!(
client.manager().channel_ids().into_iter().all(|id| id == 0),
"no data channel exists for the failed open"
);
}
// --- review 004 Unit 3 acceptance gates (F-04 serving half) -----------
/// F-04 gate 1: hub→consumer call over an existing `ChannelClient`
/// session resolves. The consumer (connect side) dials with
/// `Some(ServingConfig { .. })`; the accept side's dispatcher later
/// calls an op the consumer serves (by opening a fresh stream from
/// its side is not available on channel 0 — the accept side writes
/// a `call.requested` through its channel-0 writer, and the
/// consumer's serving loop dispatches it and writes the response,
/// which resolves on the accept side's pending).
///
/// Mechanically: the accept side's `install_channel_zero` hook
/// returns a clone of the channel-0 `CallConnection` back to the
/// test via a channel; the test then calls
/// `call_open_op("consumer/echo")` — wait, that calls *through* the
/// accept side's registry. The consumer-serving direction needs the
/// accept side to *initiate*: it writes `call.requested` for
/// `consumer/echo` on channel 0. The client's serving loop (with
/// the `consumer/echo` op in its registry) dispatches it and writes
/// the response, resolving the accept side's pending.
#[tokio::test]
async fn serving_loop_hub_to_consumer_call_resolves() {
// Consumer-side (connect side) registry: serves `consumer/echo`.
let consumer_registry = crate::registry::registration::OperationRegistry::new();
consumer_registry
.register(HandlerRegistration::new(
external_query_spec("consumer/echo"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let consumer_registry = Arc::new(consumer_registry);
// Accept side: a plain echo registry plus a handle back to the
// accept side's channel-0 `CallConnection` so the test can
// initiate a call *from* the accept side.
let (accept_conn_tx, mut accept_conn_rx) =
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
let accept_registry = crate::registry::registration::OperationRegistry::new();
accept_registry
.register(HandlerRegistration::new(
external_query_spec("accept/echo"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let accept_registry = Arc::new(accept_registry);
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |manager, channel0_conn, _auth| {
let accept_registry = Arc::clone(&accept_registry);
let accept_conn_tx = accept_conn_tx.clone();
tokio::spawn(async move {
let _ = manager;
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let _ = accept_conn_tx.send(Arc::clone(&call_connection)).await;
let dp = Dispatcher::new(
accept_registry,
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
);
// The serving loop on the accept side too: the
// consumer may call back over the same session.
dp.serve_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection_with_serving(
client_conn,
Some(ServingConfig {
registry: Arc::clone(&consumer_registry),
identity_provider: Arc::new(NoopIdProvider),
identity: None,
}),
)
.await
.expect("channel client init");
let accept_conn = accept_conn_rx
.recv()
.await
.expect("accept side channel-0 connection handle");
// Accept side (hub) initiates a call to an op the consumer
// serves — the exact direction F-04 says was dropped.
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
accept_conn.call("consumer/echo", serde_json::json!({ "from": "hub" })),
)
.await
.expect("hub→consumer call timed out");
assert!(
response.result.is_ok(),
"hub→consumer call resolves through the consumer's serving loop, got {:?}",
response.result
);
assert_eq!(
response.result.unwrap(),
serde_json::json!({ "from": "hub" })
);
// The consumer's own calling half still works (outbound pendings
// resolve in the same loop).
let outbound = client
.call_open_op("accept/echo", serde_json::json!({ "to": "hub" }))
.await;
assert!(
outbound.result.is_ok(),
"consumer→hub call still resolves, got {:?}",
outbound.result
);
}
// --- review 004 Unit 3 acceptance gates (F-05 op/register) ------------
/// F-05 gate: the consumer announces an op over channel 0
/// (`op/register` served on the accept side's fork), the hub
/// registers it in the connection overlay, and the hub then calls
/// it back — the forwarding stub issues a nested `call.requested`
/// over channel 0 to the consumer, whose serving loop dispatches
/// the real handler. Peer-announced op, discoverable and callable,
/// end-to-end.
#[tokio::test]
async fn op_register_announce_then_hub_call_routes_back_to_consumer() {
// Consumer side: serves `consumer/exec` locally; announces it.
let consumer_registry = crate::registry::registration::OperationRegistry::new();
consumer_registry
.register(HandlerRegistration::new(
external_query_spec("consumer/exec"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(
ctx.request_id,
serde_json::json!({ "ran_on": "consumer", "input": input }),
)
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let consumer_registry = Arc::new(consumer_registry);
// Accept side: serves the `op/register` bootstrap op composed on
// a fork (the F-02(a) shape — the fork is fresh here, so the
// bootstrap set is just `op/register`).
let (accept_conn_tx, mut accept_conn_rx) =
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
let accept_registry_for_hook =
Arc::new(crate::registry::registration::OperationRegistry::new());
let accept_tx_for_hook = accept_conn_tx.clone();
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |_manager, channel0_conn, _auth| {
let accept_registry = Arc::clone(&accept_registry_for_hook);
let accept_conn_tx = accept_tx_for_hook.clone();
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let _ = accept_conn_tx.send(Arc::clone(&call_connection)).await;
let reg = accept_registry.fork();
reg.register(HandlerRegistration::new(
crate::registry::op_register::op_register_spec(AccessControl::default()),
HandlerKind::Once(crate::registry::op_register::op_register_handler(
Arc::clone(&call_connection),
Arc::clone(&accept_registry),
)),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let dp = Dispatcher::new(
Arc::new(reg),
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
);
dp.serve_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection_with_serving(
client_conn,
Some(ServingConfig {
registry: Arc::clone(&consumer_registry),
identity_provider: Arc::new(NoopIdProvider),
identity: None,
}),
)
.await
.expect("channel client init");
let accept_conn = accept_conn_rx
.recv()
.await
.expect("accept side channel-0 connection handle");
// Consumer announces `consumer/exec` over channel 0.
let announce = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.call_open_op(
crate::registry::op_register::OP_REGISTER_NAME,
crate::registry::op_register::OpRegisterRequest {
spec: external_query_spec("consumer/exec"),
replace: false,
}
.to_json(),
),
)
.await
.expect("op/register announce timed out");
assert!(
announce.result.is_ok(),
"announce ok, got {:?}",
announce.result
);
// The hub's overlay now holds the announced op; the hub calls
// it — the forwarding stub routes the nested call back over
// channel 0 to the consumer's serving loop.
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
accept_conn.call("consumer/exec", serde_json::json!({ "task": "greet" })),
)
.await
.expect("hub call to peer-announced op timed out");
assert!(
response.result.is_ok(),
"hub→peer-announced-op call resolves, got {:?}",
response.result
);
assert_eq!(
response.result.unwrap(),
serde_json::json!({ "ran_on": "consumer", "input": { "task": "greet" } })
);
}
/// Interleaved-directions gate (review 005 G-01, third consequence):
/// the consumer's outbound call resolves **while** its serving loop
/// is mid-pump on a long-lived inbound Sub to the same peer. Under
/// the pre-G-01 loop the Sub pump held the read loop inline and the
/// outbound call's `call.responded` queued unresolved until the
/// sweeper; sequential-direction gates (hub→consumer, *then*
/// consumer→hub) could never catch it. The consumer also issues the
/// outbound call *from inside* a wire-dispatched handler, the
/// tightest interleaving.
#[tokio::test]
async fn outbound_call_resolves_while_inbound_subscription_is_being_served() {
// Consumer side: serves `consumer/echo` (Once) and
// `consumer/tick` (Sub, unbounded — held open until aborted).
let consumer_registry = crate::registry::registration::OperationRegistry::new();
consumer_registry
.register(HandlerRegistration::new(
external_query_spec("consumer/echo"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
consumer_registry
.register(HandlerRegistration::new(
OperationSpec::new(
"consumer/tick",
OperationType::Sub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
),
HandlerKind::Stream(make_streaming_handler(|_input, ctx: OperationContext| {
let request_id = ctx.request_id.clone();
futures::stream::unfold((0u32, request_id), |(n, request_id)| async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
Some((
ResponseEnvelope::ok(
request_id.clone(),
serde_json::json!({ "tick": n }),
),
(n + 1, request_id),
))
})
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let consumer_registry = Arc::new(consumer_registry);
// Handle channel: the install hook yields the accept side's
// channel-0 `CallConnection` so the test driver can subscribe
// (the accept side initiates the Sub) and so the
// `hub/interleave` handler can reach it.
let (accept_conn_tx, mut accept_conn_rx) =
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
// Accept side (hub): serves `accept/echo` and
// `hub/interleave` — the latter, when wire-dispatched from the
// consumer, issues an outbound call on the *same* connection.
let accept_registry = crate::registry::registration::OperationRegistry::new();
accept_registry
.register(HandlerRegistration::new(
external_query_spec("accept/echo"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let accept_conn_for_handler =
Arc::new(tokio::sync::Mutex::new(None::<Arc<CallConnection>>));
let accept_conn_slot = Arc::clone(&accept_conn_for_handler);
accept_registry
.register(HandlerRegistration::new(
external_query_spec("hub/interleave"),
HandlerKind::Once(make_handler(move |input, ctx| {
let conn_slot = Arc::clone(&accept_conn_for_handler);
async move {
let conn = conn_slot
.lock()
.await
.clone()
.expect("hub call connection set");
let response = conn
.call("consumer/echo", serde_json::json!({ "via": "interleave" }))
.await;
let echo = response.result.expect("outbound call resolves");
ResponseEnvelope::ok(
ctx.request_id,
serde_json::json!({
"input": input,
"outbound": echo,
}),
)
}
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let accept_registry = Arc::new(accept_registry);
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |_manager, channel0_conn, _auth| {
let accept_registry = Arc::clone(&accept_registry);
let accept_conn_tx = accept_conn_tx.clone();
let accept_conn_slot = Arc::clone(&accept_conn_slot);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
accept_conn_slot
.lock()
.await
.replace(Arc::clone(&call_connection));
let _ = accept_conn_tx.send(Arc::clone(&call_connection)).await;
let dp = Dispatcher::new(
accept_registry,
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
);
dp.serve_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection_with_serving(
client_conn,
Some(ServingConfig {
registry: Arc::clone(&consumer_registry),
identity_provider: Arc::new(NoopIdProvider),
identity: None,
}),
)
.await
.expect("channel client init");
let accept_conn = accept_conn_rx
.recv()
.await
.expect("accept side channel-0 connection handle");
// Phase 1: the consumer subscribes to the hub's `consumer/tick`
// — the consumer's serving loop pumps the Sub inline (pre-fix)
// or in a spawned task (post-fix). The stream stays open.
use futures::stream::StreamExt;
let mut ticks = tokio::time::timeout(
std::time::Duration::from_secs(5),
accept_conn.subscribe("consumer/tick", serde_json::json!({})),
)
.await
.expect("subscribe timed out");
let first = tokio::time::timeout(std::time::Duration::from_secs(5), ticks.next())
.await
.expect("first tick timed out")
.expect("first tick item");
assert!(
first.result.is_ok(),
"first tick ok, got {:?}",
first.result
);
// Phase 2: with the Sub live, the consumer calls the hub's
// `hub/interleave` — the wire-dispatched handler issues an
// outbound hub→consumer call on the same connection while the
// consumer's serving loop is serving the Sub. Pre-fix, the
// consumer's serving loop held the Sub pump inline and this
// call starved (30s sweeper); post-fix it resolves.
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.call_open_op(
"hub/interleave",
serde_json::json!({ "when": "while-sub-live" }),
),
)
.await
.expect("interleaved outbound call starved while Sub served (G-01 shape)");
assert!(
response.result.is_ok(),
"interleaved call resolves, got {:?}",
response.result
);
let out = response.result.unwrap();
assert_eq!(
out.get("outbound"),
Some(&serde_json::json!({ "via": "interleave" })),
"the nested outbound hub→consumer call resolved through the serving loop"
);
// The Sub is still live after the interleaved call.
let second = tokio::time::timeout(std::time::Duration::from_secs(5), ticks.next())
.await
.expect("second tick timed out")
.expect("second tick item");
assert!(
second.result.is_ok(),
"sub still live after interleaved call, got {:?}",
second.result
);
}
/// G-02 gate (review 005): the forwarding stub is exercised
/// through **nested composition from a wire-dispatched hub
/// handler** — the exact path the direct wire call in
/// `op_register_announce_then_hub_call_routes_back_to_consumer`
/// bypasses, and the path G-01 deadlocked (the inline dispatch
/// blocked the read loop that had to resolve the stub's nested
/// call; it resolved only via the 30s sweeper). The hub registry
/// serves `hub/compose`, whose handler invokes the announced op
/// through `context.env` — the `PeerCompositeEnv` →
/// `OverlayOperationEnv` resolution a real hub handler produces.
/// Resolving quickly (bounded well under the sweeper interval)
/// proves the serving loop resolves same-connection nested
/// composition live.
#[tokio::test]
async fn hub_handler_composes_peer_announced_op_via_nested_composition() {
// Consumer side: serves `consumer/exec` locally; announces it.
let consumer_registry = crate::registry::registration::OperationRegistry::new();
consumer_registry
.register(HandlerRegistration::new(
external_query_spec("consumer/exec"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(
ctx.request_id,
serde_json::json!({ "ran_on": "consumer", "input": input }),
)
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let consumer_registry = Arc::new(consumer_registry);
// Accept side (hub): serves `op/register` + `hub/compose` on a
// fork. `hub/compose` composes the announced op via
// `context.env` — the nested-composition shape ADR-022's
// amendment promises. The stub is not invoked by the assertion
// below directly; it is resolved through the connection
// overlay `compose_root_env` attaches. The channel-0
// connection carries the peer's identity (the assembly layer
// resolves it from the accepted connection's `AuthContext`) —
// `compose_root_env` attaches the connection overlay keyed by
// that identity (ADR-030 §5); an identity-less connection gets
// no overlay.
let (accept_conn_tx, mut accept_conn_rx) =
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
let accept_registry_for_hook =
Arc::new(crate::registry::registration::OperationRegistry::new());
let accept_tx_for_hook = accept_conn_tx.clone();
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |_manager, channel0_conn, auth| {
if let Some(identity) = auth.identity.clone() {
let _ = channel0_conn.set_identity(identity);
}
let accept_registry = Arc::clone(&accept_registry_for_hook);
let accept_conn_tx = accept_tx_for_hook.clone();
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let _ = accept_conn_tx.send(Arc::clone(&call_connection)).await;
let reg = accept_registry.fork();
reg.register(HandlerRegistration::new(
crate::registry::op_register::op_register_spec(AccessControl::default()),
HandlerKind::Once(crate::registry::op_register::op_register_handler(
Arc::clone(&call_connection),
Arc::clone(&accept_registry),
)),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
reg.register(HandlerRegistration::new(
external_query_spec("hub/compose"),
HandlerKind::Once(make_handler(|input, ctx| async move {
let name = input
.get("op")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let (namespace, operation) = match name.split_once('/') {
Some(parts) => parts,
None => {
return ResponseEnvelope::error(
ctx.request_id,
crate::protocol::wire::CallError::invalid_input(
"hub/compose input missing `op` as `ns/op`",
),
)
}
};
let response = ctx.env.invoke(namespace, operation, input, &ctx).await;
ResponseEnvelope {
request_id: ctx.request_id,
result: response.result,
}
})),
OperationProvenance::Local,
None,
Some(crate::registry::context::ScopedPeerEnv::new([
"consumer/exec",
])),
crate::core::types::Capabilities::new(),
))
.unwrap();
let dp = Dispatcher::new(
Arc::new(reg),
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
);
dp.serve_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext {
identity: Some(crate::core::auth::Identity {
id: "consumer-peer".to_string(),
scopes: vec![],
resources: Default::default(),
}),
..AuthContext::anonymous(b"alk/channels")
};
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection_with_serving(
client_conn,
Some(ServingConfig {
registry: Arc::clone(&consumer_registry),
identity_provider: Arc::new(NoopIdProvider),
identity: None,
}),
)
.await
.expect("channel client init");
let _accept_conn = accept_conn_rx
.recv()
.await
.expect("accept side channel-0 connection handle");
// Consumer announces `consumer/exec` over channel 0.
let announce = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.call_open_op(
crate::registry::op_register::OP_REGISTER_NAME,
crate::registry::op_register::OpRegisterRequest {
spec: external_query_spec("consumer/exec"),
replace: false,
}
.to_json(),
),
)
.await
.expect("op/register announce timed out");
assert!(
announce.result.is_ok(),
"announce ok, got {:?}",
announce.result
);
// A wire-dispatched hub handler composes the announced op via
// nested composition: the consumer calls `hub/compose` over the
// wire, the hub's serving loop dispatches it, and the handler
// resolves `consumer/exec` through the forwarding stub. Under
// G-01 this resolved only at 30s via the sweeper; the bounded
// timeout proves live resolution.
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.call_open_op(
"hub/compose",
serde_json::json!({ "op": "consumer/exec", "task": "nested" }),
),
)
.await
.expect("nested composition through the forwarding stub timed out (G-01 shape)");
assert!(
response.result.is_ok(),
"nested composition resolves through the forwarding stub, got {:?}",
response.result
);
assert_eq!(
response.result.unwrap(),
serde_json::json!({ "ran_on": "consumer", "input": { "op": "consumer/exec", "task": "nested" } })
);
}
// --- review 004 Unit 2 acceptance gate (F-02/F-06 fork) ----------------
/// F-02/F-06 gate: the open op is registered on a **fork** of the
/// base registry (base carries a placeholder op; the fork adds the
/// openable + bootstrap discovery via
/// `install_bootstrap_discovery`), the session dispatches over the
/// fork, and — after the open op resolves over the live channels
/// connection — the per-session openable is discoverable through
/// `services/list` **on the fork** (the F-06 self-referential
/// closure) and `services/schema` on the fork still validates.
#[tokio::test]
async fn fork_registry_open_op_resolves_and_is_discoverable() {
use crate::channels::operations::{ChannelCore, OpenHandler};
use crate::channels::policy::PerIdentityChannelPolicy;
use crate::registry::spec::ChannelOpenSpec;
// Base registry: a plain op + bootstrap discovery closed over
// the *base* (which will NOT see per-session openables — that
// is the F-06 finding; the fork's own discovery does).
let base = crate::registry::registration::OperationRegistry::new();
base.register(HandlerRegistration::new(
external_query_spec("base/ping"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let policy: Arc<PerIdentityChannelPolicy> = Arc::new(PerIdentityChannelPolicy::new(256));
let policy_for_hook: Arc<dyn ChannelLifecyclePolicy> =
Arc::clone(&policy) as Arc<dyn ChannelLifecyclePolicy>;
let open_handler: OpenHandler = Arc::new(|_input, _plan, _channel_conn, _auth| {
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
})
});
let install_hook: crate::channels::adapter::InstallChannelZero =
Arc::new(move |manager, channel0_conn, auth| {
let open_handler = Arc::clone(&open_handler);
let policy = Arc::clone(&policy_for_hook);
let base = Arc::new(crate::registry::registration::OperationRegistry::new());
base.register(HandlerRegistration::new(
external_query_spec("base/ping"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let base = Arc::clone(&base);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let core = ChannelCore::new(manager, policy);
// The F-02(a) composition: fork the base, register
// the openable on the fork, install bootstrap
// discovery on the fork (F-06 — closed over the
// fork), dispatch over the fork.
let fork = base.fork();
core.register_openable(
OperationSpec::new(
"channels/tty/sub",
OperationType::Sub,
Visibility::External,
serde_json::json!({
"type": "object",
"properties": {
"container": { "type": "string" }
},
"required": ["container"]
}),
serde_json::json!({
"type": "object",
"properties": {
"channel_id": { "type": "integer" }
}
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/tty")),
Arc::clone(&open_handler),
&fork,
auth.clone(),
)
.expect("register_openable on fork");
let fork = Arc::new(fork);
crate::registry::discovery::install_bootstrap_discovery(&fork)
.expect("bootstrap discovery on fork");
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let dp = Dispatcher::new(fork, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
// 1. The open op resolves over the live connection through the fork.
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.call_open_op(
"channels/tty/sub",
serde_json::json!({ "container": "abc" }),
),
)
.await
.expect("open op timed out");
assert!(
response.result.is_ok(),
"open op resolves through the fork, got {:?}",
response.result
);
let channel_id = response
.result
.unwrap()
.get("channel_id")
.and_then(|v| v.as_u64())
.expect("channel_id in response");
assert!(channel_id > 0);
// 2. The per-session openable is discoverable via services/list
// (F-06) — the fork's own discovery closure sees the fork's ops.
let listing = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.call_open_op("services/list", serde_json::json!({})),
)
.await
.expect("services/list timed out");
assert!(
listing.result.is_ok(),
"services/list resolves, got {:?}",
listing.result
);
let names: Vec<String> = listing
.result
.unwrap()
.get("operations")
.and_then(|v| v.as_array())
.expect("operations array")
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str().map(String::from)))
.collect();
assert!(
names.contains(&"channels/tty/sub".to_string()),
"per-session openable discoverable through the fork's discovery: {names:?}"
);
// 3. services/schema on the fork still validates input (the
// bootstrap op is on the fork and answers).
let schema = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.call_open_op(
"services/schema",
serde_json::json!({ "name": "channels/tty/sub" }),
),
)
.await
.expect("services/schema timed out");
assert!(
schema.result.is_ok(),
"services/schema resolves on the fork, got {:?}",
schema.result
);
}
// --- CF-005 regression gates -----------------------------------------
/// The scope-gated open op the connect side serves — the
/// alktunnels reverse-flow POC shape (`worker/tunnel/open` with
/// `required_scopes`). The handler reports the identity the
/// dispatch resolved, so the tests can assert which path won.
fn scope_gated_open_registry() -> crate::registry::registration::OperationRegistry {
let registry = crate::registry::registration::OperationRegistry::new();
let acl = AccessControl {
required_scopes: vec!["tunnel:open".to_string()],
..Default::default()
};
registry
.register(HandlerRegistration::new(
OperationSpec::new(
"worker/tunnel/open",
OperationType::Mutation,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
acl,
None,
),
HandlerKind::Once(make_handler(|_input, ctx| async move {
ResponseEnvelope::ok(
ctx.request_id,
serde_json::json!({
"opened": true,
"caller": ctx.identity.map(|i| i.id),
}),
)
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
registry
}
/// The accept-side (hub) install hook: serves nothing but echoes
/// nothing — it hands the channel-0 `CallConnection` back to the
/// test and runs the resolution-only serving loop, so the test can
/// initiate hub→worker calls.
fn cf005_accept_side_hook(
accept_conn_tx: tokio::sync::mpsc::Sender<Arc<CallConnection>>,
) -> crate::channels::adapter::InstallChannelZero {
Arc::new(move |_manager, channel0_conn, _auth| {
let accept_conn_tx = accept_conn_tx.clone();
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let _ = accept_conn_tx.send(Arc::clone(&call_connection)).await;
let reader = reader;
crate::protocol::connection::read_single_stream_until_closed(
reader,
call_connection.pending(),
)
.await;
})
})
}
fn hub_identity() -> crate::core::auth::Identity {
crate::core::auth::Identity {
id: "hub".to_string(),
scopes: vec!["tunnel:open".to_string()],
resources: Default::default(),
}
}
fn effective_override_identity() -> crate::core::auth::Identity {
crate::core::auth::Identity {
id: "worker-effective".to_string(),
scopes: vec!["tunnel:open".to_string()],
resources: Default::default(),
}
}
/// CF-005 remediation (b) — the transport connection's identity
/// propagates to channel 0, so a scope-gated op served by the
/// connect side authenticates the peer by transport identity (the
/// mTLS/QUIC key-based path). The consumer dials, sets the
/// transport identity (the assembly layer resolves it from the
/// transport handshake), then `from_connection_with_serving`; the
/// hub calls the scope-gated op with no token and is authorized.
#[tokio::test]
async fn cf005_transport_identity_propagates_to_connect_side_serving() {
let worker_registry = Arc::new(scope_gated_open_registry());
let (accept_conn_tx, mut accept_conn_rx) =
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
let install_hook = cf005_accept_side_hook(accept_conn_tx);
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
client_conn
.set_identity(hub_identity())
.expect("transport identity set once");
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let _client = ChannelClient::from_connection_with_serving(
client_conn,
Some(ServingConfig {
registry: Arc::clone(&worker_registry),
identity_provider: Arc::new(NoopIdProvider),
identity: None,
}),
)
.await
.expect("channel client init");
let accept_conn = accept_conn_rx
.recv()
.await
.expect("accept side channel-0 connection handle");
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
accept_conn.call("worker/tunnel/open", serde_json::json!({ "c": 1 })),
)
.await
.expect("hub→worker scope-gated call timed out");
let out = response
.result
.expect("scope-gated op authorized by the propagated transport identity");
assert_eq!(out["opened"], serde_json::json!(true));
assert_eq!(
out["caller"], "hub",
"the handler saw the transport-derived identity"
);
}
/// CF-005 remediation (a) — `ServingConfig.identity` explicitly
/// overrides, winning over the transport connection's identity.
/// Both are set here; the handler must see the override.
#[tokio::test]
async fn cf005_serving_config_identity_overrides_transport_identity() {
let worker_registry = Arc::new(scope_gated_open_registry());
let (accept_conn_tx, mut accept_conn_rx) =
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
let install_hook = cf005_accept_side_hook(accept_conn_tx);
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
client_conn
.set_identity(hub_identity())
.expect("transport identity set once");
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let _client = ChannelClient::from_connection_with_serving(
client_conn,
Some(ServingConfig {
registry: Arc::clone(&worker_registry),
identity_provider: Arc::new(NoopIdProvider),
identity: Some(effective_override_identity()),
}),
)
.await
.expect("channel client init");
let accept_conn = accept_conn_rx
.recv()
.await
.expect("accept side channel-0 connection handle");
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
accept_conn.call("worker/tunnel/open", serde_json::json!({ "c": 1 })),
)
.await
.expect("hub→worker scope-gated call timed out");
let out = response
.result
.expect("the override identity satisfies the scope gate");
assert_eq!(out["caller"], "worker-effective", "the override won");
}
/// CF-005 negative case — no identity anywhere (no
/// `ServingConfig.identity`, no transport identity, no token): the
/// scope-gated op is denied `FORBIDDEN` ("authentication
/// required"). Fails closed, unchanged.
#[tokio::test]
async fn cf005_scope_gated_op_denied_without_any_identity() {
let worker_registry = Arc::new(scope_gated_open_registry());
let (accept_conn_tx, mut accept_conn_rx) =
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
let install_hook = cf005_accept_side_hook(accept_conn_tx);
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let _client = ChannelClient::from_connection_with_serving(
client_conn,
Some(ServingConfig {
registry: Arc::clone(&worker_registry),
identity_provider: Arc::new(NoopIdProvider),
identity: None,
}),
)
.await
.expect("channel client init");
let accept_conn = accept_conn_rx
.recv()
.await
.expect("accept side channel-0 connection handle");
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
accept_conn.call("worker/tunnel/open", serde_json::json!({ "c": 1 })),
)
.await
.expect("hub→worker scope-gated call timed out");
let err = response
.result
.expect_err("identity-less call must be denied");
assert_eq!(err.code, "FORBIDDEN", "no identity on the connect side");
assert!(
err.message.contains("authentication required"),
"AccessControl::check(None) message, got: {}",
err.message
);
}
/// CF-005 token fallback — the payload `auth_token` →
/// `ServingConfig.identity_provider` path still works (the
/// hub-forwarding / browser-token path, ADR-017 §7), with
/// precedence over the propagated transport identity.
#[tokio::test]
async fn cf005_auth_token_fallback_and_precedence() {
struct TokenIdProvider;
impl IdentityProvider for TokenIdProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<crate::core::auth::Identity> {
None
}
fn resolve_from_token(
&self,
token: &crate::core::auth::AuthToken,
) -> Option<crate::core::auth::Identity> {
if token.raw == b"alk_worker_token" {
Some(crate::core::auth::Identity {
id: "token-resolved".to_string(),
scopes: vec!["tunnel:open".to_string()],
resources: Default::default(),
})
} else {
None
}
}
}
let worker_registry = Arc::new(scope_gated_open_registry());
let (accept_conn_tx, mut accept_conn_rx) =
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
let install_hook = cf005_accept_side_hook(accept_conn_tx);
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
client_conn
.set_identity(hub_identity())
.expect("transport identity set once");
let server_conn =
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let _client = ChannelClient::from_connection_with_serving(
client_conn,
Some(ServingConfig {
registry: Arc::clone(&worker_registry),
identity_provider: Arc::new(TokenIdProvider),
identity: None,
}),
)
.await
.expect("channel client init");
let accept_conn = accept_conn_rx
.recv()
.await
.expect("accept side channel-0 connection handle");
// Tokenless: the propagated transport identity authorizes.
let transport = tokio::time::timeout(
std::time::Duration::from_secs(5),
accept_conn.call("worker/tunnel/open", serde_json::json!({ "c": 1 })),
)
.await
.expect("transport-identity call timed out");
let out = transport
.result
.expect("transport identity authorizes the scope gate");
assert_eq!(out["caller"], "hub");
// With a token: the token resolution wins.
let tokened = tokio::time::timeout(
std::time::Duration::from_secs(5),
accept_conn.call_with_payload(serde_json::json!({
"operationId": "worker/tunnel/open",
"input": { "c": 2 },
"auth_token": "alk_worker_token",
})),
)
.await
.expect("token call timed out");
let out = tokened
.result
.expect("the token resolves an identity satisfying the scope gate");
assert_eq!(
out["caller"], "token-resolved",
"the token resolution won over the transport identity"
);
}
}