Files
alkcall/docs/architecture/decisions/030-peer-composite-env-peer-operations.md
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

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> to OperationEnv: Same concept, different name. list_operation_names is chosen to match the existing list_operations naming on OperationRegistry.
  • Make peer_operations on PeerCompositeEnv reach into OverlayOperationEnv's internals: Requires OverlayOperationEnv to expose its HashMap or a method. The trait method is cleaner — it keeps the abstraction boundary intact.
  • Have services/list-peers iterate ctx.env.peer_ids() and call contains for 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-peers returns actual operation lists for each peer. A hub consumer calling services/list-peers gets peers: [{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_names method is generally useful — any future code that needs to enumerate an env's operations can use it.

Negative:

  • OperationEnv gains a method. The default impl preserves back-compat for all existing implementors. Only OverlayOperationEnv and PeerCompositeEnv override it.
  • The OverlayOperationEnv override holds the RwLock read for the duration of the keys().cloned().collect(). This is a Vec<String> allocation — cheap for typical peer operation counts (tens, not thousands).

Assumptions

  1. OverlayOperationEnv's RwLock<HashMap<String, HandlerRegistration>> read is cheap. The lock is held only for the keys() iteration and collect(). Typical peer operation counts are small (tens of ops).
  2. list_operation_names is the right name. It matches the existing list_operations naming on OperationRegistry and avoids confusion with peer_operations (which takes a PeerId parameter).

References

  • ADR-024 §6: services/list-peers opt-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 — default peer_operations
  • crates/alknet-call/src/registry/env.rs:155-301 — PeerCompositeEnv
  • crates/alknet-call/src/protocol/connection.rs:305-397 — OverlayOperationEnv
  • crates/alknet-call/src/registry/discovery.rs:245-296 — services_list_peers_handler
  • alkapi gap G.6: PeerCompositeEnv::peer_operations unimplemented