feat(call): from_call adapter — discover + register remote ops (ADR-017 §3)

The #2 gap in alknet-call: discovers the remote peer's External operations
via services/list + services/schema and registers them in the connection's
Layer 2 overlay as FromCall-provenance leaves with forwarding handlers. The
discovery mechanism was already implemented in registry/discovery.rs;
from_call is the client-side consumer of that API.

src/client/from_call.rs:
- from_call(connection, FromCallConfig) -> Result<Vec<HandlerRegistration>,
  AdapterError>. Calls services/list then services/schema for each op,
  rebuilds OperationSpec from the schema JSON (parsing op_type, visibility,
  error_schemas, access_control), constructs a forwarding handler that calls
  the remote op via CallConnection::call(), and returns FromCall-provenance
  bundles (composition_authority: None, scoped_env: None, empty capabilities,
  remote_safe: false per ADR-028 §4).
- FromCallConfig { namespace_prefix: Option<String>, operation_filter:
  Option<HashSet<String>> } with builder methods.
- v1 defaults (two-way doors recorded in client-and-adapters.md):
  - error-on-collision (DC-3/OQ-28): applying the (possibly empty) prefix
    produces a name already seen -> AdapterError::Conflict, not silent
    overwrite.
  - auto-on-reconnect (DC-2/OQ-27): the overlay is per-connection (Layer 2,
    ADR-024), so re-import on reconnect is naturally scoped; the assembly
    layer calls from_call immediately after connect().
- Forwarding handler captures an Arc<CallConnection> and, on invocation,
  calls the remote op and returns its ResponseEnvelope. The
  parent_request_id participates in the cross-node abort cascade
  (ADR-016 §6) — if the parent is aborted, the cascade reaches this handler
  which sends call.aborted to the remote node; cross-node abort is
  transparent.
- Trust is transitive (recorded in spec): a from_call-imported op executes
  the remote node's code; scoped_env bounds which ops are reachable, not
  what they do.

OperationContext.internal is now pub (was pub(crate)) so downstream
consumers (assembly layer, integration tests) can construct contexts for
overlay-env dispatch.

Tests (207 lib + 2 integration):
- Unit: rebuild_spec name/prefix/op_type/visibility/error_schemas/acl;
  unknown op_type -> SchemaParse; missing op_type -> SchemaParse;
  FromCallConfig builder; from_call against a mock connection returns
  DiscoveryFailed (no transport); FromCall provenance + leaf fields + remote_safe false.
- Integration (tests/two_node_call.rs): from_call over a real QUIC loopback
  — CallClient connects, from_call discovers server/echo, registers the
  bundle in the overlay, and the forwarding handler round-trips an input
  through the overlay env to the remote op and back.

clippy + fmt + test all green.

Refs: tasks/call/client/from-call.md
Refs: docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md §3, §6
Refs: docs/architecture/crates/call/client-and-adapters.md §from_call
This commit is contained in:
2026-06-26 13:25:13 +00:00
parent 4bf897f5ab
commit a3825f57cf
5 changed files with 561 additions and 2 deletions

View File

@@ -229,3 +229,84 @@ async fn two_node_call_round_trip() {
// CallClient opened a real connection, the shared loop dispatched, and the
// CallConnection::call() round-tripped).
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn from_call_discovers_and_forwards_over_quic_loopback() {
use alknet_call::client::{from_call, FromCallConfig};
use alknet_call::registry::context::ScopedOperationEnv;
let server_registry = build_server_registry();
let (server_addr, _server_join) = build_raw_quinn_server(Arc::clone(&server_registry)).await;
// Client with an empty registry — from_call will populate its overlay.
let client_registry = Arc::new(OperationRegistry::new());
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
let conn = tokio::time::timeout(
Duration::from_secs(5),
client.connect(server_addr, CallCredentials::new()),
)
.await
.expect("connect did not time out")
.expect("connect succeeds");
// from_call discovers the server's External ops (server/echo, server/secret
// — both External; services/list + services/schema themselves are External
// too) and builds FromCall forwarding-handler bundles. Register them in the
// connection's Layer 2 overlay.
let bundles = tokio::time::timeout(
Duration::from_secs(5),
from_call(&conn, FromCallConfig::new()),
)
.await
.expect("from_call did not time out")
.expect("from_call succeeds");
assert!(
!bundles.is_empty(),
"from_call must discover at least the server/echo op"
);
conn.register_imported_all(bundles);
// The overlay now contains the discovered ops. Verify the forwarding path
// by invoking the overlay env directly with a scoped context that allows
// server/echo — this is how a composing handler would call the imported op.
let env = conn.overlay_env();
assert!(
env.contains("server/echo"),
"overlay must contain the imported server/echo op"
);
// Build a minimal parent context to invoke the overlay env (mirrors how a
// composing handler dispatches a child).
let scoped = ScopedOperationEnv::new(["server/echo"]);
let parent = alknet_call::registry::context::OperationContext {
request_id: "parent-1".to_string(),
parent_request_id: None,
identity: None,
handler_identity: None,
capabilities: Capabilities::new(),
metadata: Default::default(),
scoped_env: scoped,
env: env.clone(),
abort_policy: alknet_call::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
internal: true,
};
let response = tokio::time::timeout(
Duration::from_secs(5),
env.invoke(
"server",
"echo",
serde_json::json!({"from_call": true}),
&parent,
),
)
.await
.expect("overlay invoke did not time out");
assert_eq!(
response.result,
Ok(serde_json::json!({"from_call": true})),
"from_call forwarding handler must round-trip the input to the remote op"
);
}