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).
6.7 KiB
ADR-030: PeerCompositeEnv::peer_operations Override
Status
Accepted (implemented 2026-09-04 — surfaced as UP-03 in alkhttp's
review 006 docs/…/006-alkcall-0.3.0-consequence-review.md: the
op/register amendment's "announced op is discoverable via
services/list-peers" promise did not resolve on the wire because
this override had never been ported into alkcall; the gate is
announced_op_is_discoverable_via_services_list_peers in
src/registry/op_register.rs. The services/list-peers unit tests
did not catch it because they mock peer_operations with hand-rolled
envs.)
Context
OperationEnv::peer_operations (defined in registry/env.rs:63-65) has a
default implementation returning Vec::new(). PeerCompositeEnv overrides
invoke_with_policy, contains, invoke_peer, peer_contains, and
peer_ids — but does not override peer_operations. This means
peer_operations on a PeerCompositeEnv always returns an empty Vec.
The services/list-peers handler (registry/discovery.rs:245-296) calls
ctx.env.peer_operations(&peer_id) to discover what operations each peer
serves. Since PeerCompositeEnv does not override this, non-local peers
always show empty operation lists in the list-peers response. The
peer_ids() method correctly returns the peer IDs, but the operations for
each peer are always empty.
This is a pure gap — the services/list-peers handler is specced to enumerate
each peer's operations (ADR-024 §6), and the PeerCompositeEnv type has all
the data needed to implement it (each peer's OverlayOperationEnv holds a
HashMap<String, HandlerRegistration>). The override is one method collecting
each peer overlay's registered op names.
The alkapi project identified this as gap G.6: a hub consumer calling
services/list-peers gets peers: [{peer_id: "dev1", operations: []}] until
this is fixed.
Decision
PeerCompositeEnv overrides peer_operations to collect the operation names
from each peer's connection overlay:
fn peer_operations(&self, peer: &PeerId) -> Vec<String> {
match self.connections.get(peer) {
Some(overlay) => {
// The overlay is an OverlayOperationEnv wrapping a
// HashMap<String, HandlerRegistration>. We need the op names.
// Rather than adding a method to OperationEnv (which would
// require every impl to add it), we use the existing `contains`
// method — but that requires knowing the name to check.
//
// The correct approach: iterate the overlay's known names.
// OverlayOperationEnv already has the data (the HashMap keys).
// We add a `list_operation_names(&self) -> Vec<String>` method
// to OperationEnv with a default returning Vec::new(), and
// OverlayOperationEnv overrides it to return the keys.
overlay.list_operation_names()
}
None => Vec::new(),
}
}
1. OperationEnv gains list_operation_names with a default impl
fn list_operation_names(&self) -> Vec<String> {
Vec::new()
}
The default returns empty — existing impls (LocalOperationEnv, test-only
envs) don't need to change. Only OverlayOperationEnv overrides it.
2. OverlayOperationEnv overrides list_operation_names
impl OperationEnv for OverlayOperationEnv {
fn list_operation_names(&self) -> Vec<String> {
self.overlay.read().keys().cloned().collect()
}
// ... existing impl unchanged
}
3. PeerCompositeEnv::peer_operations uses list_operation_names
The override delegates to each peer's overlay:
fn peer_operations(&self, peer: &PeerId) -> Vec<String> {
self.connections
.get(peer)
.map(|overlay| overlay.list_operation_names())
.unwrap_or_default()
}
Why a new trait method instead of a different approach
Alternatives considered:
- Add
fn operations(&self) -> Vec<String>toOperationEnv: Same concept, different name.list_operation_namesis chosen to match the existinglist_operationsnaming onOperationRegistry. - Make
peer_operationsonPeerCompositeEnvreach intoOverlayOperationEnv's internals: RequiresOverlayOperationEnvto expose itsHashMapor a method. The trait method is cleaner — it keeps the abstraction boundary intact. - Have
services/list-peersiteratectx.env.peer_ids()and callcontainsfor every known op name: Requires knowing all possible op names (from the registry), which is a cross-layer coupling. The trait method keeps the data where it lives.
The trait method is the smallest surface change: one new method with a
default impl, one override on OverlayOperationEnv, one override on
PeerCompositeEnv. No existing code changes.
Consequences
Positive:
services/list-peersreturns actual operation lists for each peer. A hub consumer callingservices/list-peersgetspeers: [{peer_id: "dev1", operations: [{name: "docker/container/exec", ...}, ...]}]— the specced behavior.- The fix is small: one trait method, two overrides. No existing code paths change.
- The
list_operation_namesmethod is generally useful — any future code that needs to enumerate an env's operations can use it.
Negative:
OperationEnvgains a method. The default impl preserves back-compat for all existing implementors. OnlyOverlayOperationEnvandPeerCompositeEnvoverride it.- The
OverlayOperationEnvoverride holds theRwLockread for the duration of thekeys().cloned().collect(). This is aVec<String>allocation — cheap for typical peer operation counts (tens, not thousands).
Assumptions
OverlayOperationEnv'sRwLock<HashMap<String, HandlerRegistration>>read is cheap. The lock is held only for thekeys()iteration andcollect(). Typical peer operation counts are small (tens of ops).list_operation_namesis the right name. It matches the existinglist_operationsnaming onOperationRegistryand avoids confusion withpeer_operations(which takes aPeerIdparameter).
References
- ADR-024 §6:
services/list-peersopt-in peer-attributed re-export listing - ADR-029: Aggregated Peer-Environment Wiring (sibling hub-wiring decision)
- ADR-028: from_call Is a Manual Free Function (sibling hub-wiring decision)
crates/alknet-call/src/registry/env.rs:63-65— defaultpeer_operationscrates/alknet-call/src/registry/env.rs:155-301—PeerCompositeEnvcrates/alknet-call/src/protocol/connection.rs:305-397—OverlayOperationEnvcrates/alknet-call/src/registry/discovery.rs:245-296—services_list_peers_handler- alkapi gap G.6:
PeerCompositeEnv::peer_operationsunimplemented