Files
alkcall/src/channels/env.rs
T
glm-5.3-flash fd212307e2 fix(up-03): PeerCompositeEnv::peer_operations override — list-peers sees peer-announced ops
Surfaced by alkhttp review 006 (UP-03): services/list-peers showed
every peer with an empty operations array. PeerCompositeEnv overrode
peer_ids only, so peer_operations fell to the trait default
(Vec::new()) and the ADR-022 amendment's "announced op is discoverable
via services/list-peers" promise never resolved on the wire. ADR-030
prescribed the fix but it had never been ported into alkcall. The
existing list-peers unit tests passed because they mock
peer_operations with hand-rolled envs.

Implements ADR-030 as specified:
- OperationEnv gains list_operation_names (default Vec::new(),
  back-compat for all existing implementors)
- OverlayOperationEnv overrides it with its overlay's registered names
- PeerCompositeEnv::peer_operations delegates to the peer overlay's
  list_operation_names; PeerCompositeEnv::list_operation_names
  aggregates session + connections + base (mirrors its contains())
- LocalOperationEnv enumerates its registry; ChannelsSessionEnv
  delegates to base

Gate: announced_op_is_discoverable_via_services_list_peers in
src/registry/op_register.rs — announces an op through op/register,
then asserts both the direct peer_operations probe and the
services/list-peers wire shape attribute the announced op to the peer,
over the exact compose_root_env shape (PeerCompositeEnv + attached
connection overlay). Verified load-bearing: reverting the
peer_operations override fails the gate.

ADR-030 status Proposed -> Accepted with the UP-03 provenance note.

Verification: 590 default / 607 all-features tests, clippy
(all-targets, all-features, wasm32) clean, fmt clean, doc clean,
semver-checks 196 pass against v0.3.0 (defaulted trait method is
non-breaking).
2026-09-04 16:00:23 +00:00

317 lines
10 KiB
Rust

//! `ChannelOperationEnv` — the extension trait (ADR-047 §4, as
//! amended 2026-08-13) that adds the `channel_manager()` accessor to
//! `OperationEnv` without coupling the call crate to channels types.
//!
//! **The open-op path does not use this trait.** Per the ADR-047 §4
//! amendment, open ops are registered per-connection (in the
//! `install_channel_zero` hook) with a per-connection `ChannelCore`,
//! and the wrapper uses `ChannelCore::manager()` directly — no
//! `context.env` downcast. The original §4's dynamic-resolution shape
//! (downcast `context.env` to `&dyn ChannelOperationEnv` at invocation
//! time) was unworkable: `context.env` is a `PeerCompositeEnv`, not a
//! single concrete type that can be downcast to a channels-backed env.
//!
//! The trait and `ChannelsSessionEnv` impl are retained as a two-way-
//! door implementation detail — they are not on the open-op path, but
//! remain available for future per-connection routing (e.g., nested
//! channels where each connection's overlay carries its own manager
//! reference for non-open-op queries).
//!
//! This keeps `alkcall`'s call crate free of any channels types and
//! preserves the layering (ADR-044). The `OperationEnv` is already the
//! integration point for per-connection state (ADR-019); adding a
//! `ChannelManager` accessor via an extension trait is the natural
//! extension.
use std::sync::Arc;
use crate::registry::env::OperationEnv;
use super::manager::ChannelManager;
/// Extension trait that adds the per-connection `ChannelManager`
/// accessor (ADR-047 §4, as amended 2026-08-13). Implemented by the
/// connection overlay in `channels-call` (the per-connection
/// `OperationEnv` that carries a `ChannelManager` reference).
///
/// **Not on the open-op path** — see the module doc. Retained for
/// future per-connection routing.
#[async_trait::async_trait]
pub trait ChannelOperationEnv: OperationEnv {
/// The per-connection `ChannelManager` for this channels session.
/// `None` if the env is not channels-backed (a bare call
/// connection).
fn channel_manager(&self) -> Option<&ChannelManager>;
}
/// A `ChannelOperationEnv` impl that wraps a base `OperationEnv` and a
/// `ChannelManager`. This is the per-connection overlay
/// `channels-call` installs on channel 0's call registry.
pub struct ChannelsSessionEnv {
pub base: Arc<dyn OperationEnv + Send + Sync>,
pub manager: ChannelManager,
}
#[async_trait::async_trait]
impl OperationEnv for ChannelsSessionEnv {
async fn invoke_with_policy(
&self,
namespace: &str,
operation: &str,
input: serde_json::Value,
parent: &crate::registry::context::OperationContext,
policy: crate::registry::context::AbortPolicy,
) -> crate::protocol::wire::ResponseEnvelope {
self.base
.invoke_with_policy(namespace, operation, input, parent, policy)
.await
}
fn contains(&self, name: &str) -> bool {
self.base.contains(name)
}
fn peer_ids(&self) -> Vec<crate::registry::env::PeerId> {
self.base.peer_ids()
}
fn peer_contains(&self, peer: &crate::registry::env::PeerId, name: &str) -> bool {
self.base.peer_contains(peer, name)
}
fn peer_operations(&self, peer: &crate::registry::env::PeerId) -> Vec<String> {
self.base.peer_operations(peer)
}
fn list_operation_names(&self) -> Vec<String> {
self.base.list_operation_names()
}
async fn invoke_peer(
&self,
peer: &crate::registry::env::PeerRef,
namespace: &str,
operation: &str,
input: serde_json::Value,
parent: &crate::registry::context::OperationContext,
policy: crate::registry::context::AbortPolicy,
) -> crate::protocol::wire::ResponseEnvelope {
self.base
.invoke_peer(peer, namespace, operation, input, parent, policy)
.await
}
}
#[async_trait::async_trait]
impl ChannelOperationEnv for ChannelsSessionEnv {
fn channel_manager(&self) -> Option<&ChannelManager> {
Some(&self.manager)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::mux::MuxRunner;
use crate::core::types::Capabilities;
use crate::protocol::wire::ResponseEnvelope;
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
use crate::registry::env::{LocalOperationEnv, PeerId, PeerRef};
use parking_lot::Mutex;
use std::collections::HashMap;
use tokio::io::duplex;
struct MockEnv {
contains_val: bool,
peer_ids_val: Vec<PeerId>,
peer_contains_val: bool,
peer_operations_val: Vec<String>,
invoke_with_policy_called: Mutex<bool>,
invoke_peer_called: Mutex<bool>,
}
impl MockEnv {
fn new() -> Self {
Self {
contains_val: true,
peer_ids_val: vec!["peer-a".to_string(), "peer-b".to_string()],
peer_contains_val: true,
peer_operations_val: vec!["op1".to_string(), "op2".to_string()],
invoke_with_policy_called: Mutex::new(false),
invoke_peer_called: Mutex::new(false),
}
}
}
#[async_trait::async_trait]
impl OperationEnv for MockEnv {
async fn invoke_with_policy(
&self,
_namespace: &str,
_operation: &str,
_input: serde_json::Value,
parent: &OperationContext,
_policy: AbortPolicy,
) -> ResponseEnvelope {
*self.invoke_with_policy_called.lock() = true;
ResponseEnvelope::ok(
parent.request_id.clone(),
serde_json::Value::String("mock".into()),
)
}
fn contains(&self, _name: &str) -> bool {
self.contains_val
}
fn peer_ids(&self) -> Vec<PeerId> {
self.peer_ids_val.clone()
}
fn peer_contains(&self, _peer: &PeerId, _name: &str) -> bool {
self.peer_contains_val
}
fn peer_operations(&self, _peer: &PeerId) -> Vec<String> {
self.peer_operations_val.clone()
}
async fn invoke_peer(
&self,
_peer: &PeerRef,
_namespace: &str,
_operation: &str,
_input: serde_json::Value,
parent: &OperationContext,
_policy: AbortPolicy,
) -> ResponseEnvelope {
*self.invoke_peer_called.lock() = true;
ResponseEnvelope::ok(
parent.request_id.clone(),
serde_json::Value::String("peer".into()),
)
}
}
fn make_test_context() -> OperationContext {
OperationContext {
request_id: "req-1".to_string(),
parent_request_id: None,
identity: None,
handler_identity: None,
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedPeerEnv::empty(),
env: Arc::new(MockEnv::new()),
abort_policy: AbortPolicy::default(),
deadline: None,
internal: false,
ownership: None,
}
}
fn make_env() -> ChannelsSessionEnv {
let mock = Arc::new(MockEnv::new());
let (_client, server) = duplex(64);
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);
ChannelsSessionEnv {
base: mock,
manager,
}
}
#[tokio::test]
async fn channels_session_env_delegates_to_base() {
let registry = Arc::new(crate::registry::registration::OperationRegistry::new());
let base: Arc<dyn OperationEnv + Send + Sync> = Arc::new(LocalOperationEnv::new(registry));
let (_client, server) = duplex(64);
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 env = ChannelsSessionEnv { base, manager };
assert!(env.channel_manager().is_some());
}
#[test]
fn channel_operation_env_is_operation_env() {
fn assert_operation_env<T: OperationEnv>() {}
assert_operation_env::<ChannelsSessionEnv>();
}
#[tokio::test]
async fn invoke_with_policy_delegates_to_base() {
let env = make_env();
let ctx = make_test_context();
let result = env
.invoke_with_policy(
"ns",
"op",
serde_json::Value::Null,
&ctx,
AbortPolicy::default(),
)
.await;
assert!(result.result.is_ok());
assert_eq!(
result.result.unwrap(),
serde_json::Value::String("mock".into())
);
}
#[tokio::test]
async fn contains_delegates_to_base() {
let env = make_env();
assert!(env.contains("any-op"));
}
#[tokio::test]
async fn peer_ids_delegates_to_base() {
let env = make_env();
let ids = env.peer_ids();
assert_eq!(ids, vec!["peer-a".to_string(), "peer-b".to_string()]);
}
#[tokio::test]
async fn peer_contains_delegates_to_base() {
let env = make_env();
assert!(env.peer_contains(&"peer-a".to_string(), "any-op"));
}
#[tokio::test]
async fn peer_operations_delegates_to_base() {
let env = make_env();
let ops = env.peer_operations(&"peer-a".to_string());
assert_eq!(ops, vec!["op1".to_string(), "op2".to_string()]);
}
#[tokio::test]
async fn invoke_peer_delegates_to_base() {
let env = make_env();
let ctx = make_test_context();
let result = env
.invoke_peer(
&PeerRef::Any,
"ns",
"op",
serde_json::Value::Null,
&ctx,
AbortPolicy::default(),
)
.await;
assert!(result.result.is_ok());
assert_eq!(
result.result.unwrap(),
serde_json::Value::String("peer".into())
);
}
}