27 Commits

Author SHA1 Message Date
acaa0513e4 feat(call/registry): add OperationRegistry::invoke_streaming() returning ResponseStream
Streaming dispatch path for Subscription operations — counterpart to
invoke(). Same visibility + ACL checks (internal → handler_identity,
external → identity), then dispatches to the StreamingHandler. Pre-handler
errors (not-found, forbidden, INVALID_OPERATION_TYPE for non-Subscription
ops) yield a single error ResponseEnvelope via stream::once and end the
stream. Adds 6 unit tests covering dispatch, not-found, wrong-kind,
internal-from-external, ACL denied, and internal-call handler_identity ACL.

Refs ADR-049 §3, §5.
2026-07-02 09:39:31 +00:00
185ddb82b5 docs(call): mark call/registry/streaming-handler-handlerkind completed — StreamingHandler/HandlerKind foundation 2026-07-02 09:29:11 +00:00
9c81129f24 feat(call): introduce StreamingHandler, HandlerKind, ResponseStream + INVALID_OPERATION_TYPE (ADR-049)
Add the foundational types for ADR-049 streaming handlers:
- StreamingHandler, ResponseStream type aliases and HandlerKind enum
  (Once | Stream) in registration.rs, with make_streaming_handler() helper
- CallError::invalid_operation_type() in wire.rs (sixth protocol code,
  retryable: false)
- HandlerRegistration.handler flipped from Handler to HandlerKind;
  HandlerRegistration::new() now takes HandlerKind
- OperationRegistryBuilder absorbs wrapping: with_local/with_leaf/
  with_leaf_provenance wrap raw Handler in HandlerKind::Once for
  Query/Mutation; new with_local_streaming/with_leaf_streaming take a
  StreamingHandler and wrap in HandlerKind::Stream for Subscription.
  Builder validates kind matches spec.op_type (mismatch = startup error)
- OperationRegistry::register() returns Result<(), String> with a clear
  mismatch message; all call sites updated to handle the Result
- invoke() matches on HandlerKind: Once -> existing path; Stream ->
  INVALID_OPERATION_TYPE error envelope (guards against silent
  truncation; invoke_streaming() added in a downstream task)
- OverlayOperationEnv::invoke_with_policy matches on HandlerKind:
  Once -> dispatch; Stream -> INVALID_OPERATION_TYPE (composition is
  request/response-only)
- Migrated every HandlerRegistration::new() construction site (~95)
  to wrap raw Handler in HandlerKind::Once(handler); the builder sites
  are handled by the builder-absorbs-wrapping change
- Updated two websocket subscription tests that relied on Subscription
  ops dispatching via invoke() to expect INVALID_OPERATION_TYPE
- Added unit tests for invoke/register validation and
  make_streaming_handler
2026-07-02 09:28:05 +00:00
07f7607fbb tasks(decomp): ADR-049 streaming handler — 8 atomic tasks + gitignore .worktrees/
Decompose the ADR-049 streaming handler work into 8 dependency-ordered tasks:
- call/registry/streaming-handler-handlerkind (foundation: StreamingHandler,
  HandlerKind, ResponseStream, INVALID_OPERATION_TYPE, migrate all sites)
- call/registry/invoke-streaming (OperationRegistry::invoke_streaming)
- call/protocol/dispatch-streaming-branch (server-side op_type branch)
- call/client/from-call-streaming-forwarding (Subscription → subscribe())
- http/gateway/invoke-streaming (GatewayDispatch::invoke_streaming)
- http/server/subscribe-sse-streaming (/subscribe pipes BoxStream to SSE)
- http/adapters/from-openapi-sse-streaming (SSE → StreamingHandler)
- review-streaming-impl (phase review checkpoint)

Validated with taskgraph: 86 tasks, no cycles. Also ignore .worktrees/ so
agents' worktree workspaces don't leak into git status.
2026-07-02 08:23:27 +00:00
7ecc11610a docs(arch): ADR-049 — streaming handler for subscription operations
The call protocol spec describes streaming (call.responded*N +
call.completed, PendingRequestMap::Subscribe, CallConnection::subscribe),
but the server-side Handler type returned a single ResponseEnvelope —
a Subscription op had no way to produce a stream. The TS predecessor
(@alkdev/operations) had separate OperationHandler / SubscriptionHandler
types; the Rust port collapsed them, losing the streaming path. This
restores it end-to-end: StreamingHandler type, HandlerKind on
HandlerRegistration validated against op_type, invoke_streaming() on
OperationRegistry, server-side dispatch branches on op_type, new
INVALID_OPERATION_TYPE protocol code for wrong-dispatch-path misuse,
GatewayDispatch::invoke_streaming() for /subscribe SSE, from_call stream
forwarding via CallConnection::subscribe(), from_openapi SSE forwarding.
OperationEnv::invoke() stays request/response-only (stream composition is
handler-level, not protocol-level). Amends ADR-023's protocol-code list
(five → six). Tracks the stream-operators library as OQ-41 (feature
extension, not an unmade decision).
2026-07-02 07:43:01 +00:00
139c651eaa docs(http): mark http/review-http-final completed — alknet-http crate review complete
Final crate-wide review passes all 9 checklist areas: crate structure, feature gate
isolation, dependencies, cross-cutting concerns, pattern consistency, ADR conformance
(003-048), absence of deferred/out-of-scope items, test coverage (277+2 alknet-call,
230 default, 265+5 mcp), build cleanliness (fmt/clippy/build all clean).
2026-07-01 23:40:29 +00:00
5a51734291 docs(http): mark http/review-websocket and http/review-http completed 2026-07-01 23:39:40 +00:00
b3ab6ef097 docs(http): mark http/adapters/to-openapi completed + fix formatting
to_openapi gateway projection merged: 5-endpoint OpenAPI doc (ADR-042/045), pure
projection, info.version 1.0.0, error fidelity (ADR-023). 230 tests pass. Clippy clean.
2026-07-01 23:37:35 +00:00
ccaac7e157 Merge feat/http-to-openapi: to_openapi gateway projection (5-endpoint OpenAPI doc, ADR-042/045)
Implements to_openapi(registry) -> OpenAPISpec in src/adapters/to_openapi.rs — pure
projection generating fixed 5-endpoint gateway doc (/search, /schema, /call, /batch,
/subscribe) with info.version = 1.0.0 (ADR-045). /call responses carry protocol-level
errors + operation-level errors mapped by http_status (ADR-023). Per-caller operation
surface NOT preloaded (discovered via /search, ADR-042). /subscribe response is
text/event-stream. Wired GET /openapi.json in adapter.rs. 16 new tests.
2026-07-01 23:36:48 +00:00
18156ac9d2 Merge origin/develop: integrate connection-overlay (resolve adapter.rs test conflict, keep /openapi.json route test) 2026-07-01 20:17:06 +00:00
dd6aacc598 feat(http): complete to_openapi gateway projection with error fidelity and route wiring
Refine to_openapi to project operation-level errors (with http_status)
onto /call and /subscribe responses via oneOf merge with protocol-level
errors, preserving HTTP_<status> prefix codes without collision. Fix
BTreeMap→serde_json::Map for Value::Object compatibility. Wire GET
/openapi.json route test. Apply cargo fmt across the crate.
2026-07-01 20:11:09 +00:00
2695a19502 feat(http): implement to_openapi gateway projection (5-endpoint OpenAPI doc, info.version 1.0.0)
to_openapi(registry) -> OpenAPISpec generates the fixed 5-endpoint
gateway doc (/search, /schema, /call, /batch, /subscribe) — pure
projection (ADR-017 §5), gateway pattern (ADR-042). info.version is
1.0.0 tracking the gateway contract (ADR-045). /call responses carry
protocol-level errors (400/401/403/404/500/504) plus operation-level
errors mapped by http_status (ADR-023). GET /openapi.json wired to
serve the generated spec.
2026-07-01 19:52:57 +00:00
0b0bceec7a docs(http): mark http/websocket/connection-overlay completed 2026-07-01 19:48:36 +00:00
468bd84f86 Merge feat/http-connection-overlay: Connection-local Layer 2 overlay for browser-registered ops
Adds AccessControl::check to OverlayOperationEnv::invoke_with_policy in alknet-call
so hub's calls to browser-registered ops are gated by the browser's AccessControl.
Creates src/websocket/overlay.rs with 19 integration tests: overlay scoping (not
PeerCompositeEnv), no PeerId, register_imported/all, overlay_env() routing,
PeerRef::Specific→NOT_FOUND, AccessControl gating, overlay drop on WS close,
ADR-016 abort cascade, bidirectionality, no-ops use-case scoping.
2026-07-01 19:46:56 +00:00
ad279693ce feat(http): connection-local Layer 2 overlay for browser-registered ops (ADR-024/034/044)
Enforce AccessControl on overlay ops in OverlayOperationEnv::invoke_with_policy
(alknet-call) so the hub's calls to browser-registered ops are gated by the
browser's AccessControl — matching OperationRegistry::invoke semantics for
internal composition (caller identity = parent handler_identity.as_identity()).

Add src/websocket/overlay.rs with 19 integration tests covering the connection-
local overlay acceptance criteria: browser ops land in the per-CallConnection
overlay (not PeerCompositeEnv), no PeerId for the browser, register_imported()/
register_imported_all() populate the overlay, hub outgoing calls route through
overlay_env() (not PeerRef::Specific), PeerRef::Specific('browser-X') routes to
NOT_FOUND, AccessControl gates hub calls (allowed/forbidden/default), overlay is
per-connection isolated and dropped on WS close, WS close aborts in-flight calls
with ADR-016 cascade, bidirectionality, and browser-with-no-ops use-case scoping.
2026-07-01 19:45:36 +00:00
58e16d088b review(http): mark http/review-mcp completed + fix formatting across crate
Review-mcp verification complete: all 12 checklist items pass (from_mcp/to_mcp
conformance, ADR-037/041/014/023/034, feature gate isolation, GatewayDispatch
concrete struct, test coverage 223+5). Applied cargo fmt across crate.
2026-07-01 19:32:42 +00:00
48ead6950b docs(http): mark http/review-mcp completed 2026-07-01 19:31:49 +00:00
643f8727d2 review(http): fix formatting in from_mcp/mod.rs (review-mcp finding) 2026-07-01 19:31:27 +00:00
86752ba242 docs(http): mark http/adapters/to-mcp completed 2026-07-01 19:24:50 +00:00
b127699aad Merge feat/http-to-mcp: to_mcp gateway projection (4-tool gateway, rmcp StreamableHttpService)
Implements src/adapters/to_mcp.rs: ToMcpGateway rmcp ServerHandler with 4 fixed
gateway tools (search/schema/call/batch). search dispatches services/list (ACL-
filtered, excludes Subscriptions), schema dispatches services/schema, call/batch
dispatch via GatewayDispatch::invoke with ResponseEnvelope→CallToolResult mapping.
Bearer auth via shared middleware around nest_service. Identity survives rmcp
framing (research §6 #2 confirmed). Feature-gated behind mcp; stdio NOT built
(ADR-037). Pure projection. 16 unit tests.

# Conflicts:
#	crates/alknet-http/src/server/adapter.rs
2026-07-01 19:24:33 +00:00
974fcde923 docs(http): mark http/server/gateway-endpoints completed 2026-07-01 19:20:21 +00:00
31291bd2d9 Merge feat/http-gateway-endpoints: 5 fixed gateway endpoints (search/schema/call/batch/subscribe)
Implements src/server/gateway_routes.rs: POST /call, GET /search, GET /schema,
POST /batch, POST /subscribe (SSE). All delegate to GatewayDispatch::invoke; auth
via ResolvedIdentity extractor; errors mapped via call_error_to_http_response
(identity-aware 401/403 split). Internal ops → 404. /schema adds ACL pre-check.
/subscribe projects ResponseEnvelope as SSE. /batch loops over invoke. Wired real
handlers into adapter.rs replacing placeholder 501s. 157 tests pass.

Note: /subscribe SSE completes after single event (registry invoke returns single
ResponseEnvelope, no streaming handler yet — research §6 OQ#5).

# Conflicts:
#	crates/alknet-http/src/server/adapter.rs
2026-07-01 19:19:50 +00:00
64696fec97 feat(http): implement to_mcp 4-tool gateway projection (rmcp ServerHandler, StreamableHttpService at /mcp)
to_mcp is the MCP-direction gateway projection (ADR-041): exposes 4 fixed
gateway tools (search, schema, call, batch) over rmcp StreamableHttpService
nested into the axum Router at /mcp, not one MCP tool per registry operation.
The LLM discovers operations on demand via search+schema.

- ToMcpGateway implements rmcp ServerHandler (call_tool, list_tools, get_info)
- tools/list returns the 4 fixed gateway tools, never the registry's ops
- search dispatches services/list via GatewayDispatch::invoke, excludes
  Subscription ops (ADR-041 §2), returns names + descriptions
- schema dispatches services/schema, returns the full OperationSpec
- call dispatches via GatewayDispatch::invoke (shared spine), maps
  ResponseEnvelope -> CallToolResult::structured (Ok) /
  CallToolResult::structured_error (Err(CallError))
- batch loops over invoke, returns an array of results
- Bearer auth via shared bearer_auth_middleware applied around nest_service
  (rmcp simple_auth_streamhttp pattern); Identity read from
  RequestContext.extensions -> http::request::Parts.extensions
  (research §6 #2 identity-survives-framing assumption, confirmed via test)
- to_mcp is a pure projection (consumes registry, produces no entries)
- Feature-gated behind mcp; stdio NOT built (ADR-037)
- /mcp route wired in adapter.rs replacing the placeholder 501

cargo test -p alknet-http --features mcp: 172 passed
cargo clippy -p alknet-http --features mcp --all-targets: clean
cargo check -p alknet-http (no mcp): clean
2026-07-01 19:18:19 +00:00
5a629a48e5 feat(http): implement 5 gateway endpoints (search/schema/call/batch/subscribe)
Implement the sole HTTP invoke path (ADR-042/047) in
src/server/gateway_routes.rs: POST /call reads { operation, input },
resolves identity via the shared ResolvedIdentity extractor, dispatches
via GatewayDispatch::invoke, and returns ResponseEnvelope as JSON with
errors mapped via call_error_to_http_response. GET /search dispatches
services/list (AccessControl-filtered); GET /schema dispatches
services/schema with an ACL pre-check (unauthorized -> 403). POST /batch
loops over invoke returning an array; POST /subscribe projects the
response as SSE (text/event-stream) with data frames for call.responded
and an error event for call.aborted. Internal ops return 404. Wire the
real handlers into adapter.rs router, replacing the placeholder 501s.
2026-07-01 19:17:59 +00:00
f0710f8a04 docs(http): mark http/websocket/upgrade-handler completed 2026-07-01 19:16:37 +00:00
4306a046fe Merge feat/http-upgrade-handler: WebSocket upgrade handler (native EventEnvelope session, no length prefix)
Implements src/websocket/upgrade.rs: WS upgrade at /alknet/call using axum
WebSocketUpgrade, bearer auth (no token → 401), CallConnection::new_overlay_only,
native EventEnvelope over binary WS messages (no length prefix, text → close 1002),
Dispatcher::dispatch_requested for call.requested (AccessControl gates), handle_abort
for call.aborted, PendingRequestMap correlation, fail_all_pending on disconnect
(ADR-016 cascade), bidirectionality via connection-local overlay. Wired /alknet/call
route into adapter.rs. 168 tests pass.
2026-07-01 19:15:46 +00:00
384ad03619 feat(http): implement WebSocket upgrade handler (native EventEnvelope session, no length prefix, bearer auth) 2026-07-01 19:15:11 +00:00
60 changed files with 8856 additions and 894 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
target/
node_modules/
node_modules/
.worktrees/

View File

@@ -572,7 +572,7 @@ mod tests {
use crate::protocol::connection::CallConnection;
use crate::protocol::wire::ResponseEnvelope;
use crate::registry::registration::{
make_handler, Handler, HandlerRegistration, OperationProvenance,
make_handler, Handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::Identity;
@@ -640,14 +640,16 @@ mod tests {
fn registry_with_caps() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
external_spec("pub/run"),
caps_inspect_handler(),
OperationProvenance::Local,
None,
None,
Capabilities::new().with_api_key("google", "pub-key".to_string()),
));
registry
.register(HandlerRegistration::new(
external_spec("pub/run"),
HandlerKind::Once(caps_inspect_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new().with_api_key("google", "pub-key".to_string()),
))
.unwrap();
Arc::new(registry)
}
@@ -709,7 +711,9 @@ mod tests {
let client = CallClient::new(Arc::clone(&registry), Arc::new(NoopIdentityProvider));
let conn = client.spawn_dispatch(stub_connection());
assert_eq!(
conn.connection().expect("quic connection present").remote_alpn(),
conn.connection()
.expect("quic connection present")
.remote_alpn(),
b"alknet/call"
);
std::mem::drop(conn);

View File

@@ -19,7 +19,9 @@ use crate::client::AdapterError;
use crate::protocol::connection::CallConnection;
use crate::protocol::wire::ResponseEnvelope;
use crate::registry::context::OperationContext;
use crate::registry::registration::{Handler, HandlerRegistration, OperationProvenance};
use crate::registry::registration::{
Handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use crate::registry::spec::{
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
};
@@ -128,7 +130,7 @@ fn build_bundles(
);
bundles.push(HandlerRegistration::new(
spec,
handler,
HandlerKind::Once(handler),
OperationProvenance::FromCall,
None,
None,
@@ -549,7 +551,7 @@ mod tests {
);
let reg = HandlerRegistration::new(
spec,
handler,
HandlerKind::Once(handler),
OperationProvenance::FromCall,
None,
None,

View File

@@ -11,7 +11,9 @@ use serde_json::Value;
use crate::client::{AdapterError, OperationAdapter};
use crate::protocol::wire::{CallError, ResponseEnvelope};
use crate::registry::context::OperationContext;
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
use crate::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use crate::registry::spec::OperationSpec;
/// Build a [`HandlerRegistration`] from a JSON Schema-described operation.
@@ -30,7 +32,7 @@ pub fn from_jsonschema(spec: OperationSpec, _schema: Value) -> HandlerRegistrati
});
HandlerRegistration::new(
spec,
handler,
HandlerKind::Once(handler),
OperationProvenance::FromJsonSchema,
None,
None,
@@ -138,7 +140,10 @@ mod tests {
async fn placeholder_handler_returns_error_when_invoked() {
let bundle = from_jsonschema_fn::from_jsonschema(test_spec("ns/op"), serde_json::json!({}));
let ctx = test_context("req-1");
let response = (bundle.handler)(serde_json::json!({}), ctx).await;
let response = match &bundle.handler {
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
_ => panic!("expected Once handler"),
};
match response.result {
Err(e) => {
assert_eq!(e.code, "NOT_FOUND");

View File

@@ -166,7 +166,9 @@ mod tests {
};
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
use crate::registry::env::OperationEnv;
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
use crate::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::AuthToken;
use alknet_core::types::Capabilities;
@@ -245,22 +247,24 @@ mod tests {
handler: crate::registry::registration::Handler,
) -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
OperationSpec::new(
name,
OperationType::Query,
visibility,
serde_json::json!({}),
serde_json::json!({}),
vec![],
acl,
),
handler,
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
OperationSpec::new(
name,
OperationType::Query,
visibility,
serde_json::json!({}),
serde_json::json!({}),
vec![],
acl,
),
HandlerKind::Once(handler),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
@@ -421,14 +425,16 @@ mod tests {
let mut registry = OperationRegistry::new();
let scoped = ScopedPeerEnv::new(["fs/readFile"]);
let caps = Capabilities::new().with_api_key("google", "k".to_string());
registry.register(HandlerRegistration::new(
external_spec("agent/run", AccessControl::default()),
echo_handler(),
OperationProvenance::Local,
None,
Some(scoped.clone()),
caps.clone(),
));
registry
.register(HandlerRegistration::new(
external_spec("agent/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
Some(scoped.clone()),
caps.clone(),
))
.unwrap();
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let adapter = CallAdapter::new(registry, provider);
@@ -543,7 +549,7 @@ mod tests {
vec![],
AccessControl::default(),
),
echo_handler(),
HandlerKind::Once(echo_handler()),
OperationProvenance::FromCall,
None,
None,
@@ -610,7 +616,7 @@ mod tests {
vec![],
AccessControl::default(),
),
echo_handler(),
HandlerKind::Once(echo_handler()),
OperationProvenance::FromCall,
None,
None,

View File

@@ -26,7 +26,8 @@ use super::wire::{
use crate::protocol::wire::ResponseEnvelope;
use crate::registry::context::{generate_request_id, AbortPolicy, OperationContext, ScopedPeerEnv};
use crate::registry::env::OperationEnv;
use crate::registry::registration::{Handler, HandlerRegistration};
use crate::registry::registration::{HandlerKind, HandlerRegistration};
use crate::registry::spec::AccessResult;
const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30);
@@ -306,20 +307,34 @@ impl OperationEnv for OverlayOperationEnv {
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
}
let handler: Handler;
let handler: HandlerKind;
let composition_authority;
let scoped_env;
let access_control;
{
let overlay = self.overlay.read();
let Some(registration) = overlay.get(&name) else {
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
};
handler = Arc::clone(&registration.handler);
handler = registration.handler.clone();
composition_authority = registration.composition_authority.clone();
scoped_env = registration
.scoped_env
.clone()
.unwrap_or_else(ScopedPeerEnv::empty);
access_control = registration.spec.access_control.clone();
}
let caller_identity = if parent.internal {
parent
.handler_identity
.as_ref()
.and_then(|ca| ca.as_identity())
} else {
parent.identity.clone()
};
if let AccessResult::Forbidden(message) = access_control.check(caller_identity.as_ref()) {
return ResponseEnvelope::forbidden(parent.request_id.clone(), message);
}
let context = OperationContext {
@@ -340,7 +355,15 @@ impl OperationEnv for OverlayOperationEnv {
internal: true,
};
handler(input, context).await
match handler {
HandlerKind::Once(h) => h(input, context).await,
HandlerKind::Stream(_) => ResponseEnvelope::error(
parent.request_id.clone(),
CallError::invalid_operation_type(
"OperationEnv::invoke() called on a Subscription op; composition is request/response-only",
),
),
}
}
fn contains(&self, name: &str) -> bool {
@@ -406,7 +429,7 @@ impl Stream for SubscriptionStream {
mod tests {
use super::*;
use crate::registry::context::CompositionAuthority;
use crate::registry::registration::{make_handler, OperationProvenance};
use crate::registry::registration::{make_handler, Handler, HandlerKind, OperationProvenance};
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::types::{Capabilities, MockConnection};
use std::collections::HashMap;
@@ -461,7 +484,7 @@ mod tests {
fn imported_registration(name: &str) -> HandlerRegistration {
HandlerRegistration::new(
external_spec(name),
echo_handler(),
HandlerKind::Once(echo_handler()),
OperationProvenance::FromCall,
None,
None,
@@ -593,7 +616,7 @@ mod tests {
});
conn.register_imported(HandlerRegistration::new(
external_spec("worker/exec"),
inspect_handler,
HandlerKind::Once(inspect_handler),
OperationProvenance::FromCall,
None,
None,
@@ -616,7 +639,9 @@ mod tests {
fn connection_accessor_returns_underlying_connection() {
let conn = CallConnection::new(stub_connection());
assert_eq!(
conn.connection().expect("quic connection present").remote_alpn(),
conn.connection()
.expect("quic connection present")
.remote_alpn(),
b"alknet/call"
);
}
@@ -945,4 +970,39 @@ mod tests {
assert!(conn.connection().is_some(), "QUIC connection present");
assert!(conn.identity().is_none(), "no identity set yet");
}
#[tokio::test]
async fn overlay_env_invoke_on_stream_kind_returns_invalid_operation_type() {
use crate::registry::registration::make_streaming_handler;
let conn = CallConnection::new(stub_connection());
let streaming_handler = make_streaming_handler(|input, ctx| {
futures::stream::iter(vec![ResponseEnvelope::ok(ctx.request_id, input)])
});
conn.register_imported(HandlerRegistration::new(
OperationSpec::new(
"events/stream",
OperationType::Subscription,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
),
HandlerKind::Stream(streaming_handler),
OperationProvenance::FromCall,
None,
None,
Capabilities::new(),
));
let env = conn.overlay_env();
let scoped = ScopedPeerEnv::new(["events/stream"]);
let ctx = root_context("root-stream", scoped, env.clone());
let response = env
.invoke("events", "stream", serde_json::json!({}), &ctx)
.await;
match response.result {
Err(e) => assert_eq!(e.code, "INVALID_OPERATION_TYPE"),
other => panic!("expected INVALID_OPERATION_TYPE, got {other:?}"),
}
}
}

View File

@@ -326,7 +326,9 @@ impl Clone for Dispatcher {
mod tests {
use super::*;
use crate::protocol::wire::EVENT_RESPONDED;
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
use crate::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
use alknet_core::types::{Capabilities, MockConnection};
@@ -412,24 +414,26 @@ mod tests {
fn registry_with(name: &str, visibility: Visibility, acl: AccessControl) -> OperationRegistry {
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
OperationSpec::new(
name,
OperationType::Query,
visibility,
serde_json::json!({}),
serde_json::json!({}),
vec![],
acl,
),
make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
}),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
OperationSpec::new(
name,
OperationType::Query,
visibility,
serde_json::json!({}),
serde_json::json!({}),
vec![],
acl,
),
HandlerKind::Once(make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
}
@@ -451,14 +455,16 @@ mod tests {
serde_json::json!({ "has_google": has_google }),
)
});
registry.register(HandlerRegistration::new(
external_spec("admin/run", AccessControl::default()),
handler,
OperationProvenance::Local,
None,
None,
caps,
));
registry
.register(HandlerRegistration::new(
external_spec("admin/run", AccessControl::default()),
HandlerKind::Once(handler),
OperationProvenance::Local,
None,
None,
caps,
))
.unwrap();
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let dp = Dispatcher::new(registry, provider);
@@ -486,20 +492,22 @@ mod tests {
serde_json::json!({ "has_google": has_google }),
)
});
registry.register(HandlerRegistration::new(
external_spec(
"admin/run",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
handler,
OperationProvenance::Local,
None,
None,
caps,
));
registry
.register(HandlerRegistration::new(
external_spec(
"admin/run",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(handler),
OperationProvenance::Local,
None,
None,
caps,
))
.unwrap();
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
@@ -609,14 +617,16 @@ mod tests {
serde_json::json!({ "forwarded_for_id": forwarded_id }),
)
});
registry.register(HandlerRegistration::new(
external_spec("fs/readFile", AccessControl::default()),
handler,
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
external_spec("fs/readFile", AccessControl::default()),
HandlerKind::Once(handler),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let dp = Dispatcher::new(registry, provider);
@@ -648,14 +658,16 @@ mod tests {
serde_json::json!({ "present": present }),
)
});
registry.register(HandlerRegistration::new(
external_spec("fs/readFile", AccessControl::default()),
handler,
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
external_spec("fs/readFile", AccessControl::default()),
HandlerKind::Once(handler),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let dp = Dispatcher::new(registry, provider);
@@ -736,14 +748,16 @@ mod tests {
serde_json::json!({ "peer_ids": peer_ids }),
)
});
registry.register(HandlerRegistration::new(
external_spec("fs/readFile", AccessControl::default()),
handler,
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
external_spec("fs/readFile", AccessControl::default()),
HandlerKind::Once(handler),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let dp = Dispatcher::new(registry, provider);
@@ -795,7 +809,11 @@ mod tests {
let child_id = "ws-abort-child".to_string();
{
let mut pending = conn.pending().lock();
pending.register_call(parent_id.clone(), Instant::now() + Duration::from_secs(30), None);
pending.register_call(
parent_id.clone(),
Instant::now() + Duration::from_secs(30),
None,
);
pending.register_call(
child_id.clone(),
Instant::now() + Duration::from_secs(30),
@@ -844,11 +862,16 @@ mod tests {
"input": { "v": 42 },
});
let request_id = "ws-roundtrip-1".to_string();
let response = dp.dispatch_requested(&conn, request_id.clone(), payload).await;
let response = dp
.dispatch_requested(&conn, request_id.clone(), payload)
.await;
assert!(response.result.is_ok());
let envelope: EventEnvelope = response.into();
assert_eq!(envelope.r#type, EVENT_RESPONDED);
assert_eq!(envelope.id, "ws-roundtrip-1");
assert_eq!(envelope.payload.get("output"), Some(&serde_json::json!({ "v": 42 })));
assert_eq!(
envelope.payload.get("output"),
Some(&serde_json::json!({ "v": 42 }))
);
}
}

View File

@@ -105,6 +105,10 @@ impl CallError {
pub fn timeout(message: impl Into<String>) -> Self {
Self::new("TIMEOUT", message, true)
}
pub fn invalid_operation_type(message: impl Into<String>) -> Self {
Self::new("INVALID_OPERATION_TYPE", message, false)
}
}
impl Eq for CallError {}

View File

@@ -324,7 +324,10 @@ pub fn services_schema_handler(registry: Arc<OperationRegistry>) -> Handler {
mod tests {
use super::*;
use crate::registry::context::{CompositionAuthority, ScopedPeerEnv};
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
use crate::registry::registration::{
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration,
OperationProvenance, StreamingHandler,
};
use alknet_core::types::Capabilities;
use std::collections::HashMap;
use std::time::Duration;
@@ -359,6 +362,12 @@ mod tests {
)
}
fn echo_streaming_handler() -> StreamingHandler {
make_streaming_handler(|input, context| {
futures::stream::iter(vec![ResponseEnvelope::ok(context.request_id, input)])
})
}
fn noop_env() -> Arc<dyn crate::registry::env::OperationEnv + Send + Sync> {
struct NoopEnv;
#[async_trait::async_trait]
@@ -439,36 +448,42 @@ mod tests {
fn registry_with_access_controlled_ops() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
external_spec_with_acl("public/echo", AccessControl::default()),
echo_handler(),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry.register(HandlerRegistration::new(
external_spec_with_acl(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
echo_handler(),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry.register(HandlerRegistration::new(
internal_spec("internal/hidden"),
echo_handler(),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
external_spec_with_acl("public/echo", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
external_spec_with_acl(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
internal_spec("internal/hidden"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
@@ -485,59 +500,67 @@ mod tests {
fn registry_with_ops() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
external_spec("fs/readFile"),
echo_handler(),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry.register(HandlerRegistration::new(
internal_spec("secret/internal"),
echo_handler(),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry.register(HandlerRegistration::new(
OperationSpec::new(
"events/subscribe",
OperationType::Subscription,
Visibility::External,
json!({}),
json!({}),
vec![],
AccessControl::default(),
),
echo_handler(),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry.register(HandlerRegistration::new(
OperationSpec::new(
"fs/readFileErr",
OperationType::Query,
Visibility::External,
json!({}),
json!({}),
vec![super::super::spec::ErrorDefinition {
code: "FILE_NOT_FOUND".to_string(),
description: "file not found".to_string(),
schema: json!({ "type": "object" }),
http_status: None,
}],
AccessControl::default(),
),
echo_handler(),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
external_spec("fs/readFile"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
internal_spec("secret/internal"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
OperationSpec::new(
"events/subscribe",
OperationType::Subscription,
Visibility::External,
json!({}),
json!({}),
vec![],
AccessControl::default(),
),
HandlerKind::Stream(echo_streaming_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
OperationSpec::new(
"fs/readFileErr",
OperationType::Query,
Visibility::External,
json!({}),
json!({}),
vec![super::super::spec::ErrorDefinition {
code: "FILE_NOT_FOUND".to_string(),
description: "file not found".to_string(),
schema: json!({ "type": "object" }),
http_status: None,
}],
AccessControl::default(),
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
@@ -669,22 +692,26 @@ mod tests {
let schema_handler = services_schema_handler(Arc::clone(&registry));
let mut discovery_registry = OperationRegistry::new();
discovery_registry.register(HandlerRegistration::new(
services_list_spec(),
list_handler,
OperationProvenance::Local,
CompositionAuthority::none(),
ScopedPeerEnv::empty().into(),
Capabilities::new(),
));
discovery_registry.register(HandlerRegistration::new(
services_schema_spec(),
schema_handler,
OperationProvenance::Local,
CompositionAuthority::none(),
ScopedPeerEnv::empty().into(),
Capabilities::new(),
));
discovery_registry
.register(HandlerRegistration::new(
services_list_spec(),
HandlerKind::Once(list_handler),
OperationProvenance::Local,
CompositionAuthority::none(),
ScopedPeerEnv::empty().into(),
Capabilities::new(),
))
.unwrap();
discovery_registry
.register(HandlerRegistration::new(
services_schema_spec(),
HandlerKind::Once(schema_handler),
OperationProvenance::Local,
CompositionAuthority::none(),
ScopedPeerEnv::empty().into(),
Capabilities::new(),
))
.unwrap();
let discovery = Arc::new(discovery_registry);
let ctx = root_context("req-6");

View File

@@ -303,7 +303,9 @@ impl OperationEnv for PeerCompositeEnv {
mod tests {
use super::*;
use crate::registry::context::CompositionAuthority;
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
use crate::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::Identity;
use alknet_core::types::Capabilities;
@@ -406,22 +408,24 @@ mod tests {
scoped_env: Option<ScopedPeerEnv>,
) -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
OperationSpec::new(
name,
OperationType::Query,
spec_visibility,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
),
handler,
OperationProvenance::Local,
composition_authority,
scoped_env,
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
OperationSpec::new(
name,
OperationType::Query,
spec_visibility,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
),
HandlerKind::Once(handler),
OperationProvenance::Local,
composition_authority,
scoped_env,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ use alknet_call::registry::discovery::{
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
};
use alknet_call::registry::registration::{
make_handler, Handler, HandlerRegistration, OperationProvenance, OperationRegistry,
make_handler, Handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
};
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::{Identity, IdentityProvider};
@@ -124,58 +124,66 @@ async fn build_raw_quinn_server(
/// services/list + services/schema discovery handlers.
fn build_server_registry() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
external_spec("server/echo"),
echo_handler(),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry.register(HandlerRegistration::new(
external_spec("server/secret"),
echo_handler(),
OperationProvenance::Local,
None,
None,
Capabilities::new().with_api_key("google", "server-secret".to_string()),
));
registry
.register(HandlerRegistration::new(
external_spec("server/echo"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
external_spec("server/secret"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new().with_api_key("google", "server-secret".to_string()),
))
.unwrap();
let discovery_registry = Arc::new(registry);
let list_handler = services_list_handler(Arc::clone(&discovery_registry));
let schema_handler = services_schema_handler(Arc::clone(&discovery_registry));
let mut full = OperationRegistry::new();
full.register(HandlerRegistration::new(
external_spec("server/echo"),
echo_handler(),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
))
.unwrap();
full.register(HandlerRegistration::new(
external_spec("server/secret"),
echo_handler(),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new().with_api_key("google", "server-secret".to_string()),
));
))
.unwrap();
full.register(HandlerRegistration::new(
services_list_spec(),
list_handler,
HandlerKind::Once(list_handler),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
))
.unwrap();
full.register(HandlerRegistration::new(
services_schema_spec(),
schema_handler,
HandlerKind::Once(schema_handler),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
))
.unwrap();
Arc::new(full)
}
@@ -191,14 +199,16 @@ async fn two_node_call_round_trip() {
// it as UnknownIssuer since the self-signed cert is not in the platform
// root store.
let mut client_registry = OperationRegistry::new();
client_registry.register(HandlerRegistration::new(
external_spec("client/echo"),
echo_handler(),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
client_registry
.register(HandlerRegistration::new(
external_spec("client/echo"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let client_registry = Arc::new(client_registry);
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));

View File

@@ -13,18 +13,19 @@ use alknet_call::client::{AdapterError, OperationAdapter};
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
use alknet_call::registry::context::OperationContext;
use alknet_call::registry::registration::{
make_handler, HandlerRegistration, OperationProvenance,
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use alknet_call::registry::spec::{
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
};
use alknet_call::registry::spec::{AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility};
use alknet_core::types::Capabilities;
use rmcp::model::{
CallToolRequestParams, CallToolResult, ClientCapabilities, ClientInfo, Content,
Implementation, JsonObject, Tool,
CallToolRequestParams, CallToolResult, ClientCapabilities, ClientInfo, Content, Implementation,
JsonObject, Tool,
};
use rmcp::service::RoleClient;
use rmcp::transport::{
StreamableHttpClientTransport,
streamable_http_client::StreamableHttpClientTransportConfig,
streamable_http_client::StreamableHttpClientTransportConfig, StreamableHttpClientTransport,
};
use rmcp::{Peer, ServiceExt};
use serde_json::{Map, Value};
@@ -82,12 +83,11 @@ impl OperationAdapter for FromMCP {
.map_err(|e| classify_init_error(&e))?;
let peer: Peer<RoleClient> = running.peer().clone();
let tools = peer
.list_tools(Default::default())
.await
.map_err(|e| AdapterError::DiscoveryFailed {
let tools = peer.list_tools(Default::default()).await.map_err(|e| {
AdapterError::DiscoveryFailed {
message: format!("tools/list failed: {e}"),
})?;
}
})?;
let bundles = tools
.tools
@@ -158,7 +158,7 @@ fn build_registration(
HandlerRegistration::new(
spec,
handler,
HandlerKind::Once(handler),
OperationProvenance::FromMCP,
None,
None,
@@ -279,10 +279,7 @@ pub(crate) fn content_blocks_to_value(blocks: &[Content]) -> Value {
fn error_schemas_for(tool: &Tool) -> Vec<ErrorDefinition> {
vec![ErrorDefinition {
code: "MCP_TOOL_ERROR".to_string(),
description: format!(
"MCP tool '{}' reported an error (isError)",
tool.name
),
description: format!("MCP tool '{}' reported an error (isError)", tool.name),
schema: serde_json::json!({
"type": "array",
"description": "MCP error content blocks",
@@ -315,4 +312,4 @@ fn json_object_to_value(map: JsonObject) -> Value {
}
#[cfg(test)]
mod tests;
mod tests;

View File

@@ -22,7 +22,11 @@ fn make_tool(name: &str, input: Value, output: Option<Value>) -> Tool {
tool
}
fn call_tool_result(content: Vec<Content>, structured: Option<Value>, is_error: Option<bool>) -> CallToolResult {
fn call_tool_result(
content: Vec<Content>,
structured: Option<Value>,
is_error: Option<bool>,
) -> CallToolResult {
let json = serde_json::json!({
"content": content,
"structuredContent": structured,
@@ -204,7 +208,9 @@ fn build_spec_output_schema_present_shape() {
let tool = make_tool(
"get_weather",
serde_json::json!({ "type": "object", "properties": { "city": { "type": "string" } } }),
Some(serde_json::json!({ "type": "object", "properties": { "temperature": { "type": "number" } } })),
Some(
serde_json::json!({ "type": "object", "properties": { "temperature": { "type": "number" } } }),
),
);
let spec = build_spec(&tool, "weather");
assert_eq!(spec.name, "weather/get_weather");
@@ -248,4 +254,4 @@ async fn forwarding_handler_reads_capabilities_not_env_vars() {
let adapter = FromMCP::new("http://127.0.0.1:1/mcp", "ns");
let _ = adapter.auth_token();
assert!(adapter.auth_token().is_none());
}
}

View File

@@ -18,9 +18,11 @@ use alknet_call::client::{AdapterError, OperationAdapter};
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
use alknet_call::registry::context::OperationContext;
use alknet_call::registry::registration::{
make_handler, HandlerRegistration, OperationProvenance,
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use alknet_call::registry::spec::{
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
};
use alknet_call::registry::spec::{AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility};
use alknet_core::types::Capabilities;
use async_trait::async_trait;
use futures::StreamExt;
@@ -128,11 +130,9 @@ impl OpenAPISpec {
.to_string(),
};
let paths_raw = raw
.get("paths")
.ok_or_else(|| AdapterError::SchemaParse {
message: "OpenAPI document missing `paths`".into(),
})?;
let paths_raw = raw.get("paths").ok_or_else(|| AdapterError::SchemaParse {
message: "OpenAPI document missing `paths`".into(),
})?;
if !paths_raw.is_object() {
return Err(AdapterError::SchemaParse {
message: "`paths` must be a JSON object".into(),
@@ -155,14 +155,13 @@ impl OpenAPISpec {
if operations.is_empty() {
continue;
}
paths.insert(
path.clone(),
PathItem { operations },
);
paths.insert(path.clone(), PathItem { operations });
}
let components = raw.get("components").and_then(|c| c.get("schemas")).and_then(
|schemas| {
let components = raw
.get("components")
.and_then(|c| c.get("schemas"))
.and_then(|schemas| {
if !schemas.is_object() {
return None;
}
@@ -171,8 +170,7 @@ impl OpenAPISpec {
map.insert(k.clone(), v.clone());
}
Some(Components { schemas: map })
},
);
});
Ok(Self {
info,
@@ -190,11 +188,9 @@ impl OpenAPISpec {
}
let mut current: &Value = &self.raw;
for part in reference.trim_start_matches("#/").split('/') {
current = current
.get(part)
.ok_or_else(|| AdapterError::SchemaParse {
message: format!("cannot resolve $ref: {reference}"),
})?;
current = current.get(part).ok_or_else(|| AdapterError::SchemaParse {
message: format!("cannot resolve $ref: {reference}"),
})?;
}
Ok(current.clone())
}
@@ -241,10 +237,7 @@ fn parse_operation(raw: &Value) -> Option<Operation> {
.filter_map(|p| {
let name = p.get("name")?.as_str()?.to_string();
let in_ = p.get("in")?.as_str()?.to_string();
let required = p
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
let schema = p.get("schema").cloned();
Some(Parameter {
name,
@@ -297,7 +290,11 @@ pub struct FromOpenAPI {
}
impl FromOpenAPI {
pub fn new(spec: OpenAPISpec, config: HttpServiceConfig, http_client: Arc<SharedHttpClient>) -> Self {
pub fn new(
spec: OpenAPISpec,
config: HttpServiceConfig,
http_client: Arc<SharedHttpClient>,
) -> Self {
Self {
spec,
config,
@@ -322,10 +319,7 @@ impl FromOpenAPI {
}
fn detect_op_type(method: &str, op: &Operation) -> OperationType {
let success = op
.responses
.get("200")
.or_else(|| op.responses.get("201"));
let success = op.responses.get("200").or_else(|| op.responses.get("201"));
if let Some(resp) = success {
if resp.content.contains_key("text/event-stream") {
return OperationType::Subscription;
@@ -477,7 +471,7 @@ impl FromOpenAPI {
let capabilities = Capabilities::new();
Ok(HandlerRegistration::new(
spec,
handler,
HandlerKind::Once(handler),
OperationProvenance::FromOpenAPI,
None,
None,
@@ -531,9 +525,8 @@ fn build_request(
}
}
let base = Url::parse(base_url).map_err(|e| {
CallError::internal(format!("invalid base_url `{base_url}`: {e}"))
})?;
let base = Url::parse(base_url)
.map_err(|e| CallError::internal(format!("invalid base_url `{base_url}`: {e}")))?;
let mut url = base
.join(url_path.trim_start_matches('/'))
.map_err(|e| CallError::internal(format!("invalid path `{url_path}`: {e}")))?;
@@ -683,11 +676,12 @@ async fn forward(
.find(|(s, _)| *s == status.as_u16())
.map(|(_, code)| code.clone())
.unwrap_or_else(|| format!("HTTP_{}", status.as_u16()));
let message = format!("HTTP {}: {}", status.as_u16(), status.canonical_reason().unwrap_or(""));
return ResponseEnvelope::error(
request_id,
CallError::new(code, message, false),
let message = format!(
"HTTP {}: {}",
status.as_u16(),
status.canonical_reason().unwrap_or("")
);
return ResponseEnvelope::error(request_id, CallError::new(code, message, false));
}
let content_type = response
@@ -716,10 +710,7 @@ async fn forward(
} else {
match response.bytes().await {
Ok(b) => {
let arr: Vec<Value> = b
.iter()
.map(|byte| Value::Number((*byte).into()))
.collect();
let arr: Vec<Value> = b.iter().map(|byte| Value::Number((*byte).into())).collect();
ResponseEnvelope::ok(request_id, Value::Array(arr))
}
Err(err) => ResponseEnvelope::error(
@@ -744,7 +735,8 @@ async fn stream_subscription(request_id: String, response: reqwest::Response) ->
let parsed = if event.data.trim().is_empty() {
Value::Null
} else {
serde_json::from_str(&event.data).unwrap_or(Value::String(event.data.clone()))
serde_json::from_str(&event.data)
.unwrap_or(Value::String(event.data.clone()))
};
last_event = Some(parsed.clone());
}
@@ -1040,7 +1032,12 @@ mod tests {
.unwrap();
let body = props.get("body").unwrap();
assert_eq!(body.get("type").unwrap(), "object");
assert!(body.get("properties").unwrap().as_object().unwrap().contains_key("name"));
assert!(body
.get("properties")
.unwrap()
.as_object()
.unwrap()
.contains_key("name"));
}
#[tokio::test]
@@ -1074,14 +1071,19 @@ mod tests {
"https://api.vast.ai",
"/machines",
"GET",
&Some(HttpAuthScheme::ApiKey { header_name: "X-API-Key".to_string() }),
&Some(HttpAuthScheme::ApiKey {
header_name: "X-API-Key".to_string(),
}),
&HashMap::new(),
"vastai",
&serde_json::json!({}),
&ctx,
)
.unwrap();
assert_eq!(headers.get("X-API-Key").unwrap().to_str().unwrap(), "key-xyz");
assert_eq!(
headers.get("X-API-Key").unwrap().to_str().unwrap(),
"key-xyz"
);
}
#[tokio::test]
@@ -1151,7 +1153,10 @@ mod tests {
.unwrap();
let registration = &bundles[0];
let ctx = noop_context("req-10", Capabilities::new());
let response = (registration.handler)(serde_json::json!({}), ctx).await;
let response = match &registration.handler {
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
_ => panic!("expected Once handler"),
};
assert_eq!(response.request_id, "req-10");
match response.result {
Ok(v) => assert_eq!(v, serde_json::json!({"ok":true})),
@@ -1176,7 +1181,10 @@ mod tests {
.unwrap();
let registration = &bundles[0];
let ctx = noop_context("req-11", Capabilities::new());
let response = (registration.handler)(serde_json::json!({}), ctx).await;
let response = match &registration.handler {
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
_ => panic!("expected Once handler"),
};
match response.result {
Err(e) => {
assert_eq!(e.code, "HTTP_404");
@@ -1201,7 +1209,10 @@ mod tests {
.unwrap();
let registration = &bundles[0];
let ctx = noop_context("req-12", Capabilities::new());
let response = (registration.handler)(serde_json::json!({}), ctx).await;
let response = match &registration.handler {
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
_ => panic!("expected Once handler"),
};
assert!(response.result.is_ok());
let last = response.result.unwrap();
assert_eq!(last, serde_json::json!({"n":2}));
@@ -1267,7 +1278,11 @@ mod tests {
#[test]
fn http_service_config_struct_fields() {
let cfg = config("ns", "https://api.example.com", Some(HttpAuthScheme::Bearer));
let cfg = config(
"ns",
"https://api.example.com",
Some(HttpAuthScheme::Bearer),
);
assert_eq!(cfg.namespace, "ns");
assert_eq!(cfg.base_url, "https://api.example.com");
assert!(matches!(cfg.auth, Some(HttpAuthScheme::Bearer)));
@@ -1289,7 +1304,12 @@ mod tests {
}"#;
let spec = OpenAPISpec::from_json(doc).unwrap();
assert!(spec.components.is_some());
assert!(spec.components.as_ref().unwrap().schemas.contains_key("Foo"));
assert!(spec
.components
.as_ref()
.unwrap()
.schemas
.contains_key("Foo"));
}
#[tokio::test]
@@ -1342,7 +1362,9 @@ mod tests {
#[tokio::test]
async fn resolve_ref_missing_target_returns_schema_parse() {
let spec = OpenAPISpec::from_json(minimal_spec_json()).unwrap();
let err = spec.resolve_ref("#/components/schemas/Missing").unwrap_err();
let err = spec
.resolve_ref("#/components/schemas/Missing")
.unwrap_err();
assert!(matches!(err, AdapterError::SchemaParse { .. }));
}
@@ -1409,7 +1431,8 @@ mod tests {
headers,
body,
});
let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}";
let response =
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}";
sock.write_all(response.as_bytes()).await.unwrap();
sock.flush().await.unwrap();
});
@@ -1435,17 +1458,29 @@ mod tests {
.unwrap();
let registration = &bundles[0];
let ctx = noop_context("req-16", Capabilities::new());
let response = (registration.handler)(
serde_json::json!({"id":"42","filter":"new","body":{"name":"widget"}}),
ctx,
)
.await;
assert!(response.result.is_ok(), "expected Ok, got {:?}", response.result);
let response = match &registration.handler {
HandlerKind::Once(h) => {
h(
serde_json::json!({"id":"42","filter":"new","body":{"name":"widget"}}),
ctx,
)
.await
}
_ => panic!("expected Once handler"),
};
assert!(
response.result.is_ok(),
"expected Ok, got {:?}",
response.result
);
let captured = rx.await.unwrap();
assert_eq!(captured.method, "POST");
assert_eq!(captured.path, "/items/42");
assert_eq!(captured.query, "filter=new");
assert_eq!(captured.headers.get("content-type").unwrap(), "application/json");
assert_eq!(
captured.headers.get("content-type").unwrap(),
"application/json"
);
assert!(captured.body.contains("\"name\":\"widget\""));
}
@@ -1457,19 +1492,22 @@ mod tests {
}"#;
let (base, rx) = spawn_capturing_server().await;
let spec = OpenAPISpec::from_json(doc).unwrap();
let bundles = adapter(
spec,
config("openai", &base, Some(HttpAuthScheme::Bearer)),
)
.import()
.await
.unwrap();
let bundles = adapter(spec, config("openai", &base, Some(HttpAuthScheme::Bearer)))
.import()
.await
.unwrap();
let registration = &bundles[0];
let caps = Capabilities::new().with_http_token("openai", "sk-test-token".to_string());
let ctx = noop_context("req-17", caps);
let _ = (registration.handler)(serde_json::json!({}), ctx).await;
let _ = match &registration.handler {
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
_ => panic!("expected Once handler"),
};
let captured = rx.await.unwrap();
assert_eq!(captured.headers.get("authorization").unwrap(), "Bearer sk-test-token");
assert_eq!(
captured.headers.get("authorization").unwrap(),
"Bearer sk-test-token"
);
}
#[tokio::test]
@@ -1500,7 +1538,10 @@ mod tests {
.unwrap();
let registration = &bundles[0];
let ctx = noop_context("req-18", Capabilities::new());
let response = (registration.handler)(serde_json::json!({}), ctx).await;
let response = match &registration.handler {
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
_ => panic!("expected Once handler"),
};
match response.result {
Ok(Value::String(s)) => assert_eq!(s, "hello world"),
other => panic!("expected String, got {other:?}"),
@@ -1521,10 +1562,13 @@ mod tests {
.unwrap();
let registration = &bundles[0];
let ctx = noop_context("req-19", Capabilities::new());
let response = (registration.handler)(serde_json::json!({}), ctx).await;
let response = match &registration.handler {
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
_ => panic!("expected Once handler"),
};
match response.result {
Err(e) => assert_eq!(e.code, "HTTP_500"),
other => panic!("expected HTTP_500, got {other:?}"),
}
}
}
}

View File

@@ -13,7 +13,16 @@ pub mod from_openapi;
#[cfg(feature = "mcp")]
pub mod from_mcp;
#[cfg(feature = "mcp")]
pub mod to_mcp;
pub mod to_openapi;
pub use from_openapi::{FromOpenAPI, HttpAuthScheme, HttpServiceConfig, OpenAPISpec};
pub use to_openapi::to_openapi;
#[cfg(feature = "mcp")]
pub use from_mcp::FromMCP;
#[cfg(feature = "mcp")]
pub use to_mcp::{to_mcp_service, ToMcpGateway, ToMcpService};

View File

@@ -0,0 +1,952 @@
//! `to_mcp`: 4-tool gateway projection over the local operation registry,
//! exposed to external MCP clients (editors, AI tools) via rmcp's
//! `StreamableHttpService` nested into the axum `Router` at `/mcp`.
//!
//! This is the tool-gateway pattern (ADR-041): the LLM gets a fixed set of
//! meta-tools (`search`, `schema`, `call`, `batch`) and discovers operations
//! on demand — not one MCP tool per registry operation. `Subscription` ops
//! are excluded from `search` and cannot be invoked via `call` (MCP tool
//! calls are request/response — ADR-041 §2).
//!
//! `to_mcp` is a pure projection (ADR-017 §5): it consumes the registry and
//! does not produce entries for it. It is not an `OperationAdapter`. The
//! shared dispatch spine (`GatewayDispatch`) is used for the `call` tool; the
//! `ResponseEnvelope` → `CallToolResult` mapping is `to_mcp`-specific.
//!
//! Bearer auth is the shared `bearer_auth_middleware`, applied as an axum
//! layer *around* the nested `StreamableHttpService` (research §4.4 — the rmcp
//! `simple_auth_streamhttp.rs` example shows the pattern). The resolved
//! `Identity` is stashed by the middleware into `http::request::Parts`'s
//! extensions; rmcp injects `Parts` into the `RequestContext<RoleServer>`
//! extensions, so `call_tool` reads it back via
//! `context.extensions.get::<http::request::Parts>()` (research §6 #2 — the
//! load-bearing identity-survives-the-rmcp-framing assumption).
//!
//! Streamable HTTP only (ADR-037 — stdio is not built). Feature-gated behind
//! `mcp`. See `docs/architecture/crates/http/http-mcp.md`.
use std::borrow::Cow;
use std::sync::Arc;
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
use alknet_core::auth::Identity;
use rmcp::model::{
CallToolRequestParams, CallToolResult, Implementation, JsonObject, ListToolsResult,
PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::transport::{
streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService},
StreamableHttpServerConfig,
};
use serde_json::{Map, Value};
use crate::gateway::GatewayDispatch;
const TOOL_SEARCH: &str = "search";
const TOOL_SCHEMA: &str = "schema";
const TOOL_CALL: &str = "call";
const TOOL_BATCH: &str = "batch";
const OP_SERVICES_LIST: &str = "services/list";
const OP_SERVICES_SCHEMA: &str = "services/schema";
fn search_input_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Optional substring filter on operation name."
}
}
})
}
fn schema_input_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The fully-qualified operation name (e.g. `fs/readFile`)."
}
},
"required": ["name"]
})
}
fn call_input_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"operation": {
"type": "string",
"description": "The fully-qualified operation name to invoke."
},
"input": {
"type": "object",
"description": "The JSON input object to pass to the operation."
}
},
"required": ["operation"]
})
}
fn batch_input_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"calls": {
"type": "array",
"items": {
"type": "object",
"properties": {
"operation": { "type": "string" },
"input": { "type": "object" }
},
"required": ["operation"]
},
"description": "The operations to invoke in this batch."
}
},
"required": ["calls"]
})
}
pub struct ToMcpGateway {
dispatch: Arc<GatewayDispatch>,
}
impl ToMcpGateway {
pub fn new(dispatch: Arc<GatewayDispatch>) -> Self {
Self { dispatch }
}
pub fn dispatch(&self) -> &Arc<GatewayDispatch> {
&self.dispatch
}
fn extract_identity(context: &RequestContext<RoleServer>) -> Option<Identity> {
Self::extract_identity_from_extensions(&context.extensions)
}
fn extract_identity_from_extensions(extensions: &rmcp::model::Extensions) -> Option<Identity> {
let parts = extensions.get::<http::request::Parts>()?;
parts
.extensions
.get::<Option<Identity>>()
.and_then(Option::clone)
}
async fn handle_search(&self, identity: Option<Identity>) -> CallToolResult {
let response = self
.dispatch
.invoke(identity.clone(), OP_SERVICES_LIST, Value::Null)
.await;
map_search_response(response, identity.as_ref())
}
async fn handle_schema(
&self,
arguments: Option<JsonObject>,
identity: Option<Identity>,
) -> CallToolResult {
let name = match arguments
.and_then(|mut a| a.remove("name"))
.and_then(|v| v.as_str().map(str::to_string))
{
Some(n) => n,
None => {
return CallToolResult::structured_error(serde_json::json!({
"code": "INVALID_INPUT",
"message": "missing required field: name"
}));
}
};
let response = self
.dispatch
.invoke(
identity,
OP_SERVICES_SCHEMA,
serde_json::json!({ "name": name }),
)
.await;
envelope_to_call_tool_result(response)
}
async fn handle_call(
&self,
arguments: Option<JsonObject>,
identity: Option<Identity>,
) -> CallToolResult {
let (operation, input) = match parse_call_arguments(arguments) {
Ok(pair) => pair,
Err(err) => return err,
};
let response = self.dispatch.invoke(identity, &operation, input).await;
envelope_to_call_tool_result(response)
}
async fn handle_batch(
&self,
arguments: Option<JsonObject>,
identity: Option<Identity>,
) -> CallToolResult {
let calls = match arguments
.and_then(|mut a| a.remove("calls"))
.and_then(|v| v.as_array().cloned())
{
Some(arr) => arr,
None => {
return CallToolResult::structured_error(serde_json::json!({
"code": "INVALID_INPUT",
"message": "missing required field: calls"
}));
}
};
let mut results: Vec<Value> = Vec::with_capacity(calls.len());
for call in calls {
let (operation, input) = match parse_call_arguments(call.as_object().cloned()) {
Ok(pair) => pair,
Err(err) => {
results.push(batch_error_value(err));
continue;
}
};
let response = self
.dispatch
.invoke(identity.clone(), &operation, input)
.await;
results.push(envelope_to_value(response));
}
CallToolResult::structured(Value::Array(results))
}
}
fn parse_call_arguments(arguments: Option<JsonObject>) -> Result<(String, Value), CallToolResult> {
let mut map = match arguments {
Some(m) => m,
None => {
return Err(CallToolResult::structured_error(serde_json::json!({
"code": "INVALID_INPUT",
"message": "missing required field: operation"
})));
}
};
let operation = match map
.remove("operation")
.and_then(|v| v.as_str().map(str::to_string))
{
Some(s) => s,
None => {
return Err(CallToolResult::structured_error(serde_json::json!({
"code": "INVALID_INPUT",
"message": "missing required field: operation"
})));
}
};
let input = map.remove("input").unwrap_or(Value::Object(Map::new()));
Ok((operation, input))
}
fn batch_error_value(result: CallToolResult) -> Value {
serde_json::json!({
"isError": result.is_error.unwrap_or(false),
"structuredContent": result.structured_content,
"content": result.content,
})
}
fn map_search_response(response: ResponseEnvelope, identity: Option<&Identity>) -> CallToolResult {
match response.result {
Ok(value) => {
let operations = value
.get("operations")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let filtered: Vec<Value> = operations
.into_iter()
.filter(|op| {
let op_type = op.get("op_type").and_then(Value::as_str).unwrap_or("");
!matches!(op_type, "subscription" | "Subscription")
})
.map(|op| op_to_search_listing(&op, identity))
.collect();
CallToolResult::structured(serde_json::json!({ "operations": filtered }))
}
Err(err) => call_error_to_structured_error(err),
}
}
fn op_to_search_listing(op: &Value, identity: Option<&Identity>) -> Value {
let name = op.get("name").and_then(Value::as_str).unwrap_or("");
let op_type = op.get("op_type").and_then(Value::as_str).unwrap_or("query");
let namespace = op.get("namespace").and_then(Value::as_str).unwrap_or("");
let description = format!("{op_type} operation `{name}` in namespace `{namespace}`");
let _ = identity;
serde_json::json!({
"name": name,
"description": description,
})
}
fn envelope_to_call_tool_result(response: ResponseEnvelope) -> CallToolResult {
match response.result {
Ok(value) => CallToolResult::structured(value),
Err(err) => call_error_to_structured_error(err),
}
}
fn call_error_to_structured_error(err: CallError) -> CallToolResult {
let details = serde_json::to_value(&err).unwrap_or(Value::Null);
CallToolResult::structured_error(details)
}
fn envelope_to_value(response: ResponseEnvelope) -> Value {
match response.result {
Ok(output) => serde_json::json!({
"isError": false,
"output": output,
}),
Err(err) => {
let details = serde_json::to_value(&err).unwrap_or(Value::Null);
serde_json::json!({
"isError": true,
"error": details,
})
}
}
}
fn gateway_tools() -> Vec<Tool> {
vec![
Tool::new(
Cow::Borrowed(TOOL_SEARCH),
Cow::Borrowed(
"List available operations (filtered by the caller's AccessControl). Returns names + descriptions, not full schemas. Subscription operations are excluded.",
),
value_to_object(search_input_schema()),
),
Tool::new(
Cow::Borrowed(TOOL_SCHEMA),
Cow::Borrowed(
"Get the full OperationSpec for an operation (input/output JSON Schemas, error schemas).",
),
value_to_object(schema_input_schema()),
),
Tool::new(
Cow::Borrowed(TOOL_CALL),
Cow::Borrowed(
"Invoke an operation by name with a JSON input. Returns the output as structuredContent, or isError with typed error details for a CallError.",
),
value_to_object(call_input_schema()),
),
Tool::new(
Cow::Borrowed(TOOL_BATCH),
Cow::Borrowed(
"Invoke multiple operations in one tool call. Returns an array of results, each shaped like a `call` result.",
),
value_to_object(batch_input_schema()),
),
]
}
fn value_to_object(value: Value) -> Arc<JsonObject> {
match value {
Value::Object(map) => Arc::new(map),
_ => Arc::new(Map::new()),
}
}
impl rmcp::handler::server::ServerHandler for ToMcpGateway {
fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> impl futures::Future<Output = Result<ListToolsResult, rmcp::ErrorData>> + Send + '_ {
let tools = gateway_tools();
std::future::ready(Ok(ListToolsResult::with_all_items(tools)))
}
fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> impl futures::Future<Output = Result<CallToolResult, rmcp::ErrorData>> + Send + '_ {
let identity = Self::extract_identity(&context);
let name = request.name.to_string();
let arguments = request.arguments;
let this = self;
async move {
let result = match name.as_str() {
TOOL_SEARCH => this.handle_search(identity).await,
TOOL_SCHEMA => this.handle_schema(arguments, identity).await,
TOOL_CALL => this.handle_call(arguments, identity).await,
TOOL_BATCH => this.handle_batch(arguments, identity).await,
unknown => {
let err = CallError::new(
"NOT_FOUND",
format!("unknown gateway tool: {unknown}"),
false,
);
call_error_to_structured_error(err)
}
};
Ok(result)
}
}
fn get_info(&self) -> ServerInfo {
let capabilities = ServerCapabilities::builder().enable_tools().build();
ServerInfo::new(capabilities)
.with_server_info(Implementation::new(
"alknet-to-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"alknet MCP gateway. Call `search` to discover operations, `schema` for an operation's full spec, `call` to invoke, `batch` to invoke many.",
)
}
}
pub type ToMcpService = StreamableHttpService<ToMcpGateway, LocalSessionManager>;
pub fn to_mcp_service(dispatch: Arc<GatewayDispatch>) -> ToMcpService {
let gateway = ToMcpGateway::new(dispatch);
StreamableHttpService::new(
move || Ok(ToMcpGateway::new(Arc::clone(gateway.dispatch()))),
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use alknet_call::protocol::wire::ResponseEnvelope;
use alknet_call::registry::context::ScopedPeerEnv;
use alknet_call::registry::discovery::{
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
};
use alknet_call::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
};
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
use alknet_core::types::Capabilities;
use rmcp::model::Extensions;
use std::collections::HashMap;
use std::sync::Mutex as StdMutex;
struct StaticIdentityProvider {
tokens: StdMutex<HashMap<String, Identity>>,
}
impl StaticIdentityProvider {
fn new() -> Self {
Self {
tokens: StdMutex::new(HashMap::new()),
}
}
fn with_token(self, token: &str, identity: Identity) -> Self {
self.tokens
.lock()
.unwrap()
.insert(token.to_string(), identity);
self
}
}
impl IdentityProvider for StaticIdentityProvider {
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
let token_str = String::from_utf8_lossy(&token.raw);
self.tokens.lock().unwrap().get(token_str.as_ref()).cloned()
}
}
fn identity_with_scopes(id: &str, scopes: &[&str]) -> Identity {
Identity {
id: id.to_string(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
resources: HashMap::new(),
}
}
fn external_spec(name: &str, op_type: OperationType, acl: AccessControl) -> OperationSpec {
OperationSpec::new(
name,
op_type,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
acl,
)
}
fn make_echo_handler() -> alknet_call::registry::registration::Handler {
make_handler(
|input, context| async move { ResponseEnvelope::ok(context.request_id, input) },
)
}
fn full_registry_with_ops(
specs: Vec<(String, OperationType, AccessControl)>,
) -> Arc<OperationRegistry> {
let mut inner = OperationRegistry::new();
for (name, op_type, acl) in specs {
inner
.register(HandlerRegistration::new(
external_spec(&name, op_type, acl),
HandlerKind::Once(make_echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
}
let inner = Arc::new(inner);
let mut dispatch_registry = OperationRegistry::new();
for op in inner.list_operations() {
dispatch_registry
.register(HandlerRegistration::new(
external_spec(&op.name, op.op_type, op.access_control.clone()),
HandlerKind::Once(make_echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
}
dispatch_registry
.register(HandlerRegistration::new(
services_list_spec(),
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
ScopedPeerEnv::empty().into(),
Capabilities::new(),
))
.unwrap();
dispatch_registry
.register(HandlerRegistration::new(
services_schema_spec(),
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
ScopedPeerEnv::empty().into(),
Capabilities::new(),
))
.unwrap();
Arc::new(dispatch_registry)
}
fn dispatch(
registry: Arc<OperationRegistry>,
provider: Arc<dyn IdentityProvider>,
) -> Arc<GatewayDispatch> {
Arc::new(GatewayDispatch::new(registry, provider))
}
fn provider() -> Arc<dyn IdentityProvider> {
Arc::new(StaticIdentityProvider::new())
}
fn extensions_with_identity(identity: Option<Identity>) -> Extensions {
let request = http::Request::builder()
.method(http::Method::POST)
.uri("/mcp")
.body(())
.expect("valid request");
let (mut parts, _) = request.into_parts();
parts.extensions.insert(identity);
let mut extensions = Extensions::new();
extensions.insert(parts);
extensions
}
async fn invoke_tool(
gateway: &ToMcpGateway,
name: &str,
arguments: Option<JsonObject>,
identity: Option<Identity>,
) -> CallToolResult {
match name {
TOOL_SEARCH => gateway.handle_search(identity).await,
TOOL_SCHEMA => gateway.handle_schema(arguments, identity).await,
TOOL_CALL => gateway.handle_call(arguments, identity).await,
TOOL_BATCH => gateway.handle_batch(arguments, identity).await,
unknown => {
let err = CallError::new(
"NOT_FOUND",
format!("unknown gateway tool: {unknown}"),
false,
);
call_error_to_structured_error(err)
}
}
}
#[tokio::test]
async fn list_tools_returns_exactly_four_gateway_tools() {
let _gateway = ToMcpGateway::new(dispatch(full_registry_with_ops(vec![]), provider()));
let tools = gateway_tools();
let names: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect();
assert_eq!(names.len(), 4);
assert!(names.contains(&"search".to_string()));
assert!(names.contains(&"schema".to_string()));
assert!(names.contains(&"call".to_string()));
assert!(names.contains(&"batch".to_string()));
}
#[tokio::test]
async fn list_tools_does_not_leak_registry_operations() {
let registry = full_registry_with_ops(vec![(
"fs/readFile".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let _gateway = ToMcpGateway::new(dispatch(registry, provider()));
let tools = gateway_tools();
for tool in &tools {
assert_ne!(tool.name, "fs/readFile");
assert_ne!(tool.name, "services/list");
assert_ne!(tool.name, "services/schema");
}
assert_eq!(tools.len(), 4);
}
#[tokio::test]
async fn search_returns_access_control_filtered_ops_excluding_subscriptions() {
let registry = full_registry_with_ops(vec![
(
"public/echo".to_string(),
OperationType::Query,
AccessControl::default(),
),
(
"admin/secret".to_string(),
OperationType::Query,
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
(
"events/stream".to_string(),
OperationType::Subscription,
AccessControl::default(),
),
]);
let idp: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let gateway = ToMcpGateway::new(dispatch(registry, idp));
let result = invoke_tool(
&gateway,
"search",
None,
Some(identity_with_scopes("user", &["user"])),
)
.await;
assert_eq!(result.is_error, Some(false));
let structured = result.structured_content.expect("structured present");
let ops = structured
.get("operations")
.and_then(Value::as_array)
.expect("operations array");
let names: Vec<&str> = ops
.iter()
.filter_map(|o| o.get("name").and_then(Value::as_str))
.collect();
assert!(names.contains(&"public/echo"));
assert!(
!names.contains(&"admin/secret"),
"ACL-filtered op must not appear"
);
assert!(
!names.contains(&"events/stream"),
"Subscription op must be excluded"
);
for op in ops {
assert!(
op.get("description").is_some(),
"each entry has a description"
);
assert!(
op.get("input_schema").is_none(),
"search must not return full schemas"
);
}
}
#[tokio::test]
async fn schema_returns_full_operation_spec() {
let registry = full_registry_with_ops(vec![(
"fs/readFile".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let mut args = Map::new();
args.insert("name".to_string(), Value::String("fs/readFile".to_string()));
let result = invoke_tool(&gateway, "schema", Some(args), None).await;
assert_eq!(result.is_error, Some(false));
let structured = result.structured_content.expect("structured present");
assert_eq!(
structured.get("name"),
Some(&Value::String("fs/readFile".to_string()))
);
assert!(structured.get("input_schema").is_some());
assert!(structured.get("output_schema").is_some());
assert!(structured.get("error_schemas").is_some());
assert!(structured.get("access_control").is_some());
}
#[tokio::test]
async fn call_returns_structured_for_success() {
let registry = full_registry_with_ops(vec![(
"echo/run".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let mut args = Map::new();
args.insert(
"operation".to_string(),
Value::String("echo/run".to_string()),
);
args.insert("input".to_string(), serde_json::json!({ "msg": "hi" }));
let result = invoke_tool(&gateway, "call", Some(args), None).await;
assert_eq!(result.is_error, Some(false));
assert_eq!(
result.structured_content,
Some(serde_json::json!({ "msg": "hi" }))
);
}
#[tokio::test]
async fn call_returns_structured_error_for_call_error() {
let registry = full_registry_with_ops(vec![]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let mut args = Map::new();
args.insert(
"operation".to_string(),
Value::String("no/such".to_string()),
);
args.insert("input".to_string(), Value::Object(Map::new()));
let result = invoke_tool(&gateway, "call", Some(args), None).await;
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("NOT_FOUND".to_string()))
);
}
#[tokio::test]
async fn batch_returns_array_of_results() {
let registry = full_registry_with_ops(vec![(
"echo/run".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let mut args = Map::new();
args.insert(
"calls".to_string(),
serde_json::json!([
{ "operation": "echo/run", "input": { "n": 1 } },
{ "operation": "no/such", "input": {} },
]),
);
let result = invoke_tool(&gateway, "batch", Some(args), None).await;
assert_eq!(result.is_error, Some(false));
let structured = result.structured_content.expect("structured present");
let arr = structured.as_array().expect("batch returns array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0].get("isError"), Some(&Value::Bool(false)));
assert_eq!(arr[1].get("isError"), Some(&Value::Bool(true)));
}
#[tokio::test]
async fn call_with_restricted_op_and_unauthorized_identity_returns_forbidden_error() {
let registry = full_registry_with_ops(vec![(
"admin/run".to_string(),
OperationType::Query,
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
)]);
let idp: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let gateway = ToMcpGateway::new(dispatch(registry, idp));
let mut args = Map::new();
args.insert(
"operation".to_string(),
Value::String("admin/run".to_string()),
);
args.insert("input".to_string(), Value::Object(Map::new()));
let result = invoke_tool(&gateway, "call", Some(args), None).await;
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("FORBIDDEN".to_string()))
);
}
#[tokio::test]
async fn unknown_tool_name_returns_not_found_structured_error() {
let gateway = ToMcpGateway::new(dispatch(Arc::new(OperationRegistry::new()), provider()));
let result = invoke_tool(&gateway, "bogus", None, None).await;
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("NOT_FOUND".to_string()))
);
}
#[tokio::test]
async fn identity_survives_rmcp_framing_into_call_tool() {
let registry = full_registry_with_ops(vec![(
"admin/run".to_string(),
OperationType::Query,
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
)]);
let idp: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token("alk_admin", identity_with_scopes("admin-peer", &["admin"])),
);
let gateway = ToMcpGateway::new(dispatch(registry, idp));
let admin_identity = identity_with_scopes("admin-peer", &["admin"]);
let extensions = extensions_with_identity(Some(admin_identity.clone()));
let extracted = ToMcpGateway::extract_identity_from_extensions(&extensions);
assert_eq!(
extracted.as_ref().map(|i| &i.id),
Some(&"admin-peer".to_string())
);
let mut args = Map::new();
args.insert(
"operation".to_string(),
Value::String("admin/run".to_string()),
);
args.insert("input".to_string(), serde_json::json!({ "ok": 1 }));
let result = gateway.handle_call(Some(args), extracted).await;
assert_eq!(result.is_error, Some(false));
assert_eq!(
result.structured_content,
Some(serde_json::json!({ "ok": 1 }))
);
}
#[test]
fn extract_identity_returns_none_when_no_parts_in_extensions() {
let extensions = Extensions::new();
assert!(ToMcpGateway::extract_identity_from_extensions(&extensions).is_none());
}
#[test]
fn extract_identity_returns_none_when_parts_have_no_identity() {
let extensions = extensions_with_identity(None);
assert!(ToMcpGateway::extract_identity_from_extensions(&extensions).is_none());
}
#[test]
fn extract_identity_reads_stashed_option_identity_from_parts() {
let id = identity_with_scopes("caller", &["read"]);
let extensions = extensions_with_identity(Some(id.clone()));
let extracted = ToMcpGateway::extract_identity_from_extensions(&extensions);
assert_eq!(
extracted.as_ref().map(|i| i.id.clone()),
Some("caller".to_string())
);
assert_eq!(
extracted.as_ref().map(|i| i.scopes.clone()),
Some(vec!["read".to_string()])
);
}
#[test]
fn to_mcp_is_not_an_operation_adapter() {
fn assert_not_adapter<T>() {}
assert_not_adapter::<ToMcpGateway>();
}
#[test]
fn gateway_tools_definition_is_stable() {
let tools = gateway_tools();
assert_eq!(tools.len(), 4);
assert_eq!(tools[0].name, "search");
assert_eq!(tools[1].name, "schema");
assert_eq!(tools[2].name, "call");
assert_eq!(tools[3].name, "batch");
}
#[tokio::test]
async fn search_schema_call_round_trip() {
let registry = full_registry_with_ops(vec![(
"fs/readFile".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let search_result = invoke_tool(&gateway, "search", None, None).await;
let ops = search_result
.structured_content
.as_ref()
.and_then(|v| v.get("operations"))
.and_then(Value::as_array)
.expect("search ops");
let first_name = ops[0].get("name").and_then(Value::as_str).expect("name");
assert_eq!(first_name, "fs/readFile");
let mut schema_args = Map::new();
schema_args.insert("name".to_string(), Value::String(first_name.to_string()));
let schema_result = invoke_tool(&gateway, "schema", Some(schema_args), None).await;
assert_eq!(
schema_result
.structured_content
.as_ref()
.and_then(|v| v.get("name"))
.and_then(Value::as_str),
Some("fs/readFile")
);
let mut call_args = Map::new();
call_args.insert(
"operation".to_string(),
Value::String(first_name.to_string()),
);
call_args.insert(
"input".to_string(),
serde_json::json!({ "path": "/etc/hosts" }),
);
let call_result = invoke_tool(&gateway, "call", Some(call_args), None).await;
assert_eq!(
call_result.structured_content,
Some(serde_json::json!({ "path": "/etc/hosts" }))
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -125,10 +125,11 @@ fn build_client(config: &HttpClientConfig) -> Result<ClientWithMiddleware, HttpC
builder = builder.timeout(timeout);
}
if let Some(ca_bundle_path) = &config.ca_bundle {
let pem = std::fs::read(ca_bundle_path).map_err(|source| HttpClientBuildError::CaBundleRead {
path: ca_bundle_path.clone(),
source,
})?;
let pem =
std::fs::read(ca_bundle_path).map_err(|source| HttpClientBuildError::CaBundleRead {
path: ca_bundle_path.clone(),
source,
})?;
let certs = reqwest::Certificate::from_pem_bundle(&pem).map_err(|source| {
HttpClientBuildError::CaBundleParse {
path: ca_bundle_path.clone(),
@@ -152,19 +153,21 @@ fn build_client(config: &HttpClientConfig) -> Result<ClientWithMiddleware, HttpC
source,
}
})?;
let identity = reqwest::Identity::from_pem(
concat_pem(&cert_pem, &key_pem).as_slice(),
)
.map_err(|source| HttpClientBuildError::ClientCertParse {
path: client_cert_cfg.cert_pem.clone(),
source,
})?;
let identity = reqwest::Identity::from_pem(concat_pem(&cert_pem, &key_pem).as_slice())
.map_err(|source| HttpClientBuildError::ClientCertParse {
path: client_cert_cfg.cert_pem.clone(),
source,
})?;
builder = builder.identity(identity);
}
let reqwest_client = builder.build().map_err(HttpClientBuildError::Build)?;
let client = reqwest_middleware::ClientBuilder::new(reqwest_client)
.with(RetryTransientMiddleware::new_with_policy(config.retry_policy))
.with(RetryAfterMiddleware::with_capacity(DEFAULT_RETRY_AFTER_CAPACITY))
.with(RetryTransientMiddleware::new_with_policy(
config.retry_policy,
))
.with(RetryAfterMiddleware::with_capacity(
DEFAULT_RETRY_AFTER_CAPACITY,
))
.build();
Ok(client)
}
@@ -203,10 +206,7 @@ mod tests {
.build()
.expect("RequestBuilder builds");
assert_eq!(request.method(), reqwest::Method::GET);
assert_eq!(
request.url().as_str(),
"https://api.example.com/v1/chat"
);
assert_eq!(request.url().as_str(), "https://api.example.com/v1/chat");
}
#[test]
@@ -326,4 +326,4 @@ mod tests {
let config = HttpClientConfig::default();
assert!(config.ca_bundle.is_none());
}
}
}

View File

@@ -7,4 +7,4 @@ mod http_client;
mod retry_after;
pub use http_client::{ClientCertConfig, HttpClientBuildError, HttpClientConfig, SharedHttpClient};
pub use retry_after::RetryAfterMiddleware;
pub use retry_after::RetryAfterMiddleware;

View File

@@ -99,7 +99,10 @@ impl RetryAfterMiddleware {
#[cfg(test)]
fn len(&self) -> usize {
self.deadlines.lock().expect("deadlines mutex poisoned").len()
self.deadlines
.lock()
.expect("deadlines mutex poisoned")
.len()
}
#[cfg(test)]
@@ -156,8 +159,8 @@ mod tests {
#[test]
fn parse_retry_after_http_date() {
let deadline = parse_retry_after("Wed, 21 Oct 2099 07:28:00 GMT")
.expect("HTTP-date value parses");
let deadline =
parse_retry_after("Wed, 21 Oct 2099 07:28:00 GMT").expect("HTTP-date value parses");
assert!(deadline > SystemTime::now());
}
@@ -272,7 +275,10 @@ mod tests {
async fn middleware_sleeps_before_request_with_active_deadline() {
let mw = std::sync::Arc::new(RetryAfterMiddleware::with_capacity(8));
let target = url("https://api.example.com/v1/chat");
mw.record_test(target.clone(), SystemTime::now() + Duration::from_millis(50));
mw.record_test(
target.clone(),
SystemTime::now() + Duration::from_millis(50),
);
let started = SystemTime::now();
mw.maybe_sleep_for(&target).await;
let elapsed = SystemTime::now().duration_since(started).unwrap();
@@ -281,4 +287,4 @@ mod tests {
"middleware must sleep until the deadline elapses"
);
}
}
}

View File

@@ -83,11 +83,7 @@ impl GatewayDispatch {
r.capabilities.clone(),
r.scoped_env.clone().unwrap_or_else(ScopedPeerEnv::empty),
),
None => (
None,
Capabilities::new(),
ScopedPeerEnv::empty(),
),
None => (None, Capabilities::new(), ScopedPeerEnv::empty()),
};
let env: Arc<dyn alknet_call::registry::env::OperationEnv + Send + Sync> =
@@ -121,7 +117,7 @@ mod tests {
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
};
use alknet_call::registry::registration::{
make_handler, HandlerRegistration, OperationProvenance,
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::AuthToken;
@@ -191,45 +187,51 @@ mod tests {
fn registry_with(name: &str, visibility: Visibility, acl: AccessControl) -> OperationRegistry {
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
OperationSpec::new(
name,
OperationType::Query,
visibility,
serde_json::json!({}),
serde_json::json!({}),
vec![],
acl,
),
make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
}),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
OperationSpec::new(
name,
OperationType::Query,
visibility,
serde_json::json!({}),
serde_json::json!({}),
vec![],
acl,
),
HandlerKind::Once(make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
}
fn registry_with_discovery(inner: Arc<OperationRegistry>) -> OperationRegistry {
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
services_list_spec(),
services_list_handler(Arc::clone(&inner)),
OperationProvenance::Local,
None,
ScopedPeerEnv::empty().into(),
Capabilities::new(),
));
registry.register(HandlerRegistration::new(
services_schema_spec(),
services_schema_handler(Arc::clone(&inner)),
OperationProvenance::Local,
None,
ScopedPeerEnv::empty().into(),
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
services_list_spec(),
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
ScopedPeerEnv::empty().into(),
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
services_schema_spec(),
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
ScopedPeerEnv::empty().into(),
Capabilities::new(),
))
.unwrap();
registry
}
@@ -254,10 +256,7 @@ mod tests {
.invoke(None, "echo/run", serde_json::json!({ "msg": "hi" }))
.await;
assert!(response.result.is_ok());
assert_eq!(
response.result.unwrap(),
serde_json::json!({ "msg": "hi" })
);
assert_eq!(response.result.unwrap(), serde_json::json!({ "msg": "hi" }));
}
#[tokio::test]
@@ -270,41 +269,43 @@ mod tests {
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let dp = dispatch(registry, provider);
let response = dp
.invoke(None, "/echo/run", serde_json::json!({}))
.await;
let response = dp.invoke(None, "/echo/run", serde_json::json!({})).await;
assert!(response.result.is_ok());
}
#[tokio::test]
async fn invoke_for_services_list_returns_access_control_filtered_list() {
let mut inner = OperationRegistry::new();
inner.register(HandlerRegistration::new(
external_spec("public/echo", AccessControl::default()),
make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
}),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
inner.register(HandlerRegistration::new(
external_spec(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
}),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
inner
.register(HandlerRegistration::new(
external_spec("public/echo", AccessControl::default()),
HandlerKind::Once(make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
inner
.register(HandlerRegistration::new(
external_spec(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let inner = Arc::new(inner);
let discovery = Arc::new(registry_with_discovery(Arc::clone(&inner)));
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
@@ -336,16 +337,18 @@ mod tests {
#[tokio::test]
async fn invoke_for_services_schema_returns_spec_for_known_op() {
let mut inner = OperationRegistry::new();
inner.register(HandlerRegistration::new(
external_spec("fs/readFile", AccessControl::default()),
make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
}),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
inner
.register(HandlerRegistration::new(
external_spec("fs/readFile", AccessControl::default()),
HandlerKind::Once(make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let inner = Arc::new(inner);
let discovery = Arc::new(registry_with_discovery(Arc::clone(&inner)));
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
@@ -369,9 +372,7 @@ mod tests {
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let dp = dispatch(registry, provider);
let response = dp
.invoke(None, "no/such", serde_json::json!({}))
.await;
let response = dp.invoke(None, "no/such", serde_json::json!({})).await;
match response.result {
Err(e) => {
assert_eq!(e.code, "NOT_FOUND");
@@ -384,23 +385,23 @@ mod tests {
#[tokio::test]
async fn invoke_for_internal_op_returns_not_found_not_leaked() {
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
internal_spec("secret/op", AccessControl::default()),
make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
}),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
internal_spec("secret/op", AccessControl::default()),
HandlerKind::Once(make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let dp = dispatch(registry, provider);
let response = dp
.invoke(None, "secret/op", serde_json::json!({}))
.await;
let response = dp.invoke(None, "secret/op", serde_json::json!({})).await;
match response.result {
Err(e) => {
assert_eq!(e.code, "NOT_FOUND");
@@ -423,9 +424,7 @@ mod tests {
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let dp = dispatch(registry, provider);
let response = dp
.invoke(None, "admin/run", serde_json::json!({}))
.await;
let response = dp.invoke(None, "admin/run", serde_json::json!({})).await;
match response.result {
Err(e) => {
assert_eq!(e.code, "FORBIDDEN");
@@ -506,22 +505,26 @@ mod tests {
#[test]
fn build_root_context_carries_registration_bundle_fields() {
let authority =
alknet_call::registry::context::CompositionAuthority::new("agent", ["fs:read".to_string()]);
let authority = alknet_call::registry::context::CompositionAuthority::new(
"agent",
["fs:read".to_string()],
);
let scoped = ScopedPeerEnv::new(["fs/readFile"]);
let caps = Capabilities::new().with_api_key("google", "k".to_string());
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
external_spec("agent/run", AccessControl::default()),
make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
}),
OperationProvenance::Local,
Some(authority),
Some(scoped.clone()),
caps,
));
registry
.register(HandlerRegistration::new(
external_spec("agent/run", AccessControl::default()),
HandlerKind::Once(make_handler(|input, context| async move {
ResponseEnvelope::ok(context.request_id, input)
})),
OperationProvenance::Local,
Some(authority),
Some(scoped.clone()),
caps,
))
.unwrap();
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let dp = dispatch(registry, provider);
@@ -545,4 +548,4 @@ mod tests {
fn assert_concrete<T: Sized>() {}
assert_concrete::<GatewayDispatch>();
}
}
}

View File

@@ -31,7 +31,10 @@ pub fn call_error_to_http_status(error: &CallError) -> u16 {
call_error_to_http_status_with_identity(error, None)
}
pub fn call_error_to_http_status_with_identity(error: &CallError, identity: Option<&Identity>) -> u16 {
pub fn call_error_to_http_status_with_identity(
error: &CallError,
identity: Option<&Identity>,
) -> u16 {
match error.code.as_str() {
PROTOCOL_CODE_NOT_FOUND => STATUS_NOT_FOUND,
PROTOCOL_CODE_FORBIDDEN => {
@@ -59,8 +62,8 @@ pub fn call_error_to_http_response(error: &CallError) -> Response {
let retry_after = retry_after_value(error, status_code);
if let Some(retry_after) = retry_after {
let header_value = HeaderValue::from_str(&retry_after)
.unwrap_or_else(|_| HeaderValue::from_static("0"));
let header_value =
HeaderValue::from_str(&retry_after).unwrap_or_else(|_| HeaderValue::from_static("0"));
(status, [(header::RETRY_AFTER, header_value)], Json(body)).into_response()
} else {
(status, Json(body)).into_response()
@@ -139,7 +142,10 @@ mod tests {
fn forbidden_with_some_identity_maps_to_403() {
let error = CallError::forbidden("insufficient scopes");
let id = identity();
assert_eq!(call_error_to_http_status_with_identity(&error, Some(&id)), 403);
assert_eq!(
call_error_to_http_status_with_identity(&error, Some(&id)),
403
);
}
#[test]
@@ -213,7 +219,10 @@ mod tests {
let error = CallError::new("HTTP_503", "slow down", true);
let response = call_error_to_http_response(&error);
assert_eq!(response.status(), StatusCode::from_u16(503).unwrap());
assert!(response.headers().get(axum::http::header::RETRY_AFTER).is_none());
assert!(response
.headers()
.get(axum::http::header::RETRY_AFTER)
.is_none());
}
#[test]
@@ -221,7 +230,10 @@ mod tests {
let error = CallError::new("HTTP_503", "down", false)
.with_details(serde_json::json!({ "retry_after": "5" }));
let response = call_error_to_http_response(&error);
assert!(response.headers().get(axum::http::header::RETRY_AFTER).is_none());
assert!(response
.headers()
.get(axum::http::header::RETRY_AFTER)
.is_none());
}
#[test]
@@ -241,7 +253,10 @@ mod tests {
let error = CallError::timeout("timed out");
let response = call_error_to_http_response(&error);
assert_eq!(response.status(), StatusCode::from_u16(504).unwrap());
assert!(response.headers().get(axum::http::header::RETRY_AFTER).is_none());
assert!(response
.headers()
.get(axum::http::header::RETRY_AFTER)
.is_none());
}
#[test]
@@ -266,4 +281,4 @@ mod tests {
);
assert_eq!(call_error_to_http_status_with_identity(&error, None), 404);
}
}
}

View File

@@ -3,10 +3,10 @@
//! See `docs/architecture/crates/http/http-server.md`. This module wires the
//! axum `Router` (gateway endpoints + `/healthz` + `/openapi.json` + MCP +
//! custom routes + decoy fallback) and drives hyper's HTTP/1.1 or HTTP/2
//! connection driver over a single QUIC bidirectional stream. Gateway route
//! handlers, healthz/decoy logic, openapi.json generation, the MCP route, and
//! the WS upgrade handler are implemented by their respective tasks; this task
//! wires the routes with placeholder handlers returning 501 Not Implemented.
//! connection driver over a single QUIC bidirectional stream. The 5 gateway
//! endpoints (`/search`/`/schema`/`/call`/`/batch`/`/subscribe`) are wired in
//! from `gateway_routes`; `/openapi.json` serves the `to_openapi` projection
//! of the registry.
use std::io;
use std::path::PathBuf;
@@ -14,10 +14,11 @@ use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
use axum::extract::State;
use axum::http::StatusCode;
use axum::middleware::from_fn_with_state;
use axum::response::IntoResponse;
use axum::routing::{any, get, post};
use axum::routing::get;
use axum::Router;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder as HyperBuilder;
@@ -30,8 +31,16 @@ use alknet_core::auth::{AuthContext, IdentityProvider};
use alknet_core::types::{Connection, HandlerError, ProtocolHandler, StreamError};
use super::auth::bearer_auth_middleware;
use crate::server::decoy::decoy_fallback;
use crate::server::healthz::healthz;
use super::decoy::decoy_fallback;
use super::gateway_routes;
use super::healthz::healthz;
#[cfg(feature = "mcp")]
use crate::adapters::to_mcp_service;
use crate::adapters::to_openapi;
#[cfg(feature = "mcp")]
use crate::gateway::GatewayDispatch;
use crate::websocket::upgrade::ws_upgrade_handler;
use crate::websocket::upgrade::WS_UPGRADE_PATH;
const ALPN_HTTP1: &[u8] = b"http/1.1";
const ALPN_H2: &[u8] = b"h2";
@@ -40,16 +49,20 @@ const ALPN_H2: &[u8] = b"h2";
pub enum DecoyConfig {
#[default]
NotFound,
StaticSite { root: PathBuf },
Redirect { to: String },
StaticSite {
root: PathBuf,
},
Redirect {
to: String,
},
}
#[derive(Clone)]
#[allow(dead_code)]
struct RouterState {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
decoy: DecoyConfig,
pub(crate) struct RouterState {
pub(crate) registry: Arc<OperationRegistry>,
pub(crate) identity_provider: Arc<dyn IdentityProvider>,
pub(crate) decoy: DecoyConfig,
}
impl axum::extract::FromRef<RouterState> for DecoyConfig {
@@ -58,6 +71,18 @@ impl axum::extract::FromRef<RouterState> for DecoyConfig {
}
}
impl axum::extract::FromRef<RouterState> for Arc<OperationRegistry> {
fn from_ref(state: &RouterState) -> Self {
Arc::clone(&state.registry)
}
}
impl axum::extract::FromRef<RouterState> for Arc<dyn IdentityProvider> {
fn from_ref(state: &RouterState) -> Self {
Arc::clone(&state.identity_provider)
}
}
pub struct HttpAdapter {
identity_provider: Arc<dyn IdentityProvider>,
registry: Arc<OperationRegistry>,
@@ -68,11 +93,17 @@ pub struct HttpAdapter {
}
impl HttpAdapter {
pub fn new(identity_provider: Arc<dyn IdentityProvider>, registry: Arc<OperationRegistry>) -> Self {
pub fn new(
identity_provider: Arc<dyn IdentityProvider>,
registry: Arc<OperationRegistry>,
) -> Self {
Self::for_alpn(identity_provider, registry, ALPN_HTTP1)
}
pub fn h2(identity_provider: Arc<dyn IdentityProvider>, registry: Arc<OperationRegistry>) -> Self {
pub fn h2(
identity_provider: Arc<dyn IdentityProvider>,
registry: Arc<OperationRegistry>,
) -> Self {
Self::for_alpn(identity_provider, registry, ALPN_H2)
}
@@ -135,17 +166,34 @@ impl HttpAdapter {
fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
let auth_state = Arc::clone(&state.identity_provider);
#[cfg(feature = "mcp")]
let mcp_router: Router<RouterState> = {
let dispatch = Arc::new(GatewayDispatch::new(
Arc::clone(&state.registry),
Arc::clone(&state.identity_provider),
));
Router::new()
.nest_service("/mcp", to_mcp_service(dispatch))
.layer(from_fn_with_state(
auth_state.clone(),
bearer_auth_middleware,
))
};
#[cfg(not(feature = "mcp"))]
let mcp_router: Router<RouterState> = Router::new();
let default: Router<RouterState> = Router::new()
.route("/search", any(not_implemented))
.route("/schema", any(not_implemented))
.route("/call", any(not_implemented))
.route("/batch", any(not_implemented))
.route("/subscribe", any(not_implemented))
.route("/openapi.json", get(not_implemented))
.route("/mcp", post(not_implemented))
.route_layer(from_fn_with_state(auth_state.clone(), bearer_auth_middleware))
.merge(gateway_routes::gateway_router())
.route("/openapi.json", get(openapi_json_handler))
.route(WS_UPGRADE_PATH, get(ws_upgrade_handler))
.route_layer(from_fn_with_state(
auth_state.clone(),
bearer_auth_middleware,
))
.route("/healthz", get(healthz))
.fallback(decoy_fallback);
.fallback(decoy_fallback)
.merge(mcp_router);
let with_extras = match extra_routes {
Some(extra) => {
@@ -158,8 +206,16 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
with_extras.with_state(state)
}
async fn not_implemented() -> impl IntoResponse {
(StatusCode::NOT_IMPLEMENTED, "501 Not Implemented")
async fn openapi_json_handler(State(registry): State<Arc<OperationRegistry>>) -> impl IntoResponse {
let spec = to_openapi(&registry);
(
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
)],
axum::Json(spec.raw),
)
}
#[async_trait]
@@ -173,7 +229,10 @@ impl ProtocolHandler for HttpAdapter {
let _ = connection.set_identity(identity);
}
let (send, recv) = connection.accept_bi().await.map_err(stream_error_to_handler)?;
let (send, recv) = connection
.accept_bi()
.await
.map_err(stream_error_to_handler)?;
let io = QuicStream::new(send, recv);
self.serve_io(io).await
}
@@ -257,6 +316,7 @@ impl AsyncWrite for QuicStream {
#[cfg(test)]
mod tests {
use super::*;
use axum::routing::post;
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
struct NoopProvider;
@@ -264,7 +324,10 @@ mod tests {
fn resolve_from_fingerprint(&self, _: &str) -> Option<alknet_core::auth::Identity> {
None
}
fn resolve_from_token(&self, _: &alknet_core::auth::AuthToken) -> Option<alknet_core::auth::Identity> {
fn resolve_from_token(
&self,
_: &alknet_core::auth::AuthToken,
) -> Option<alknet_core::auth::Identity> {
None
}
}
@@ -310,7 +373,9 @@ mod tests {
#[test]
fn with_decoy_updates_decoy() {
let adapter = HttpAdapter::new(provider(), empty_registry());
let adapter = adapter.with_decoy(DecoyConfig::Redirect { to: "https://example.com".to_string() });
let adapter = adapter.with_decoy(DecoyConfig::Redirect {
to: "https://example.com".to_string(),
});
assert!(matches!(adapter.decoy(), DecoyConfig::Redirect { .. }));
}
@@ -355,7 +420,10 @@ mod tests {
) -> (String, tokio::task::JoinHandle<()>) {
let (mut client_send, server_recv) = duplex(8 * 1024);
let (server_send, mut client_recv) = duplex(8 * 1024);
let server_io = QuicStreamDuplex { read: server_recv, write: server_send };
let server_io = QuicStreamDuplex {
read: server_recv,
write: server_send,
};
let adapter = HttpAdapter::new(provider(), empty_registry());
let handle = tokio::spawn(async move {
@@ -368,7 +436,12 @@ mod tests {
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(std::time::Duration::from_secs(5), client_recv.read(&mut buf)).await {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
Ok(Err(_)) => break,
@@ -424,21 +497,24 @@ mod tests {
let request = b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
let (response, handle) = send_request_and_read_response(request).await;
handle.await.ok();
assert!(response.starts_with("HTTP/1.1 200 "), "expected 200, got: {response}");
assert!(
response.starts_with("HTTP/1.1 200 "),
"expected 200, got: {response}"
);
assert!(response.contains("\r\n\r\nok"));
}
#[tokio::test]
async fn custom_route_v1_foo_coexists_with_default_surface() {
let extra = Router::new().route(
"/v1/foo",
get(|| async { (StatusCode::OK, "foo-body") }),
);
let extra = Router::new().route("/v1/foo", get(|| async { (StatusCode::OK, "foo-body") }));
let adapter = HttpAdapter::new(provider(), empty_registry()).with_extra_routes(extra);
let (mut client_send, server_recv) = duplex(8 * 1024);
let (server_send, mut client_recv) = duplex(8 * 1024);
let server_io = QuicStreamDuplex { read: server_recv, write: server_send };
let server_io = QuicStreamDuplex {
read: server_recv,
write: server_send,
};
let handle = tokio::spawn(async move {
adapter.serve_io(server_io).await.ok();
@@ -451,7 +527,12 @@ mod tests {
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(std::time::Duration::from_secs(5), client_recv.read(&mut buf)).await {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
Ok(Err(_)) => break,
@@ -460,7 +541,10 @@ mod tests {
}
handle.await.ok();
let response_str = String::from_utf8_lossy(&response);
assert!(response_str.starts_with("HTTP/1.1 200 "), "expected 200, got: {response_str}");
assert!(
response_str.starts_with("HTTP/1.1 200 "),
"expected 200, got: {response_str}"
);
assert!(response_str.contains("foo-body"));
}
@@ -474,7 +558,10 @@ mod tests {
let (mut client_send, server_recv) = duplex(8 * 1024);
let (server_send, mut client_recv) = duplex(8 * 1024);
let server_io = QuicStreamDuplex { read: server_recv, write: server_send };
let server_io = QuicStreamDuplex {
read: server_recv,
write: server_send,
};
let handle = tokio::spawn(async move {
adapter.serve_io(server_io).await.ok();
@@ -487,7 +574,12 @@ mod tests {
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(std::time::Duration::from_secs(5), client_recv.read(&mut buf)).await {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
Ok(Err(_)) => break,
@@ -496,7 +588,10 @@ mod tests {
}
handle.await.ok();
let response_str = String::from_utf8_lossy(&response);
assert!(response_str.starts_with("HTTP/1.1 200 "), "default GET /healthz wins, got: {response_str}");
assert!(
response_str.starts_with("HTTP/1.1 200 "),
"default GET /healthz wins, got: {response_str}"
);
assert!(response_str.contains("\r\n\r\nok"));
assert!(!response_str.contains("custom-healthz"));
}
@@ -516,7 +611,12 @@ mod tests {
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(std::time::Duration::from_secs(5), client_recv.read(&mut buf)).await {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
Ok(Err(_)) => break,
@@ -538,7 +638,10 @@ mod tests {
.with_extra_routes(extra);
let request = b"POST /v1/chat/completions HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
let response = serve_and_read(adapter, request).await;
assert!(response.starts_with("HTTP/1.1 200"), "expected 200, got: {response}");
assert!(
response.starts_with("HTTP/1.1 200"),
"expected 200, got: {response}"
);
assert!(response.contains("oai-proxy"));
assert!(!response.contains("404 Not Found"));
}
@@ -552,32 +655,61 @@ mod tests {
let adapter = HttpAdapter::new(provider(), empty_registry())
.with_decoy(DecoyConfig::NotFound)
.with_extra_routes(extra);
let request = b"GET /totally/unknown HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
let request =
b"GET /totally/unknown HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
let response = serve_and_read(adapter, request).await;
assert!(response.starts_with("HTTP/1.1 404"), "expected 404 decoy, got: {response}");
assert!(
response.starts_with("HTTP/1.1 404"),
"expected 404 decoy, got: {response}"
);
assert!(response.contains("404 Not Found"));
}
#[tokio::test]
async fn healthz_takes_precedence_over_decoy() {
let adapter = HttpAdapter::new(provider(), empty_registry())
.with_decoy(DecoyConfig::Redirect {
let adapter =
HttpAdapter::new(provider(), empty_registry()).with_decoy(DecoyConfig::Redirect {
to: "https://example.com".to_string(),
});
let request = b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
let response = serve_and_read(adapter, request).await;
assert!(response.starts_with("HTTP/1.1 200"), "expected 200 healthz, got: {response}");
assert!(
response.starts_with("HTTP/1.1 200"),
"expected 200 healthz, got: {response}"
);
assert!(response.contains("\r\n\r\nok"));
}
#[tokio::test]
async fn unknown_path_with_redirect_decoy_returns_redirect_over_wire() {
let adapter = HttpAdapter::new(provider(), empty_registry()).with_decoy(DecoyConfig::Redirect {
to: "https://example.com".to_string(),
});
let adapter =
HttpAdapter::new(provider(), empty_registry()).with_decoy(DecoyConfig::Redirect {
to: "https://example.com".to_string(),
});
let request = b"GET /nope HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
let response = serve_and_read(adapter, request).await;
assert!(response.starts_with("HTTP/1.1 302"), "expected 302 redirect, got: {response}");
assert!(
response.starts_with("HTTP/1.1 302"),
"expected 302 redirect, got: {response}"
);
assert!(response.contains("location: https://example.com"));
}
}
#[tokio::test]
async fn openapi_json_route_serves_gateway_spec() {
let adapter = HttpAdapter::new(provider(), empty_registry());
let request = b"GET /openapi.json HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
let response = serve_and_read(adapter, request).await;
assert!(
response.starts_with("HTTP/1.1 200"),
"expected 200 for /openapi.json, got: {response}"
);
assert!(response.contains("\"openapi\""));
assert!(response.contains("\"/search\""));
assert!(response.contains("\"/schema\""));
assert!(response.contains("\"/call\""));
assert!(response.contains("\"/batch\""));
assert!(response.contains("\"/subscribe\""));
assert!(response.contains("\"1.0.0\""));
}
}

View File

@@ -80,11 +80,12 @@ where
{
type Rejection = Infallible;
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let identity = parts.extensions.get::<Option<Identity>>().cloned().flatten();
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let identity = parts
.extensions
.get::<Option<Identity>>()
.cloned()
.flatten();
Ok(ResolvedIdentity(identity))
}
}
@@ -174,15 +175,16 @@ mod tests {
assert!(identity.is_none());
}
async fn run_middleware(
idp: Arc<dyn IdentityProvider>,
request: Request,
) -> Response {
async fn run_middleware(idp: Arc<dyn IdentityProvider>, request: Request) -> Response {
let app: Router<()> = Router::new()
.route(
"/",
get(|req: Request| async move {
let identity = req.extensions().get::<Option<Identity>>().cloned().flatten();
let identity = req
.extensions()
.get::<Option<Identity>>()
.cloned()
.flatten();
if let Some(id) = identity {
(StatusCode::OK, id.id)
} else {
@@ -261,14 +263,12 @@ mod tests {
let app: Router<()> = Router::new()
.route(
"/",
get(
|ResolvedIdentity(identity): ResolvedIdentity| async move {
match identity {
Some(id) => (StatusCode::OK, id.id),
None => (StatusCode::OK, "none".to_string()),
}
},
),
get(|ResolvedIdentity(identity): ResolvedIdentity| async move {
match identity {
Some(id) => (StatusCode::OK, id.id),
None => (StatusCode::OK, "none".to_string()),
}
}),
)
.layer(from_fn_with_state(idp, bearer_auth_middleware));
@@ -287,14 +287,12 @@ mod tests {
let app: Router<()> = Router::new()
.route(
"/",
get(
|ResolvedIdentity(identity): ResolvedIdentity| async move {
match identity {
Some(id) => (StatusCode::OK, id.id),
None => (StatusCode::OK, "none".to_string()),
}
},
),
get(|ResolvedIdentity(identity): ResolvedIdentity| async move {
match identity {
Some(id) => (StatusCode::OK, id.id),
None => (StatusCode::OK, "none".to_string()),
}
}),
)
.layer(from_fn_with_state(idp, bearer_auth_middleware));
@@ -306,4 +304,4 @@ mod tests {
.unwrap();
assert_eq!(&bytes[..], b"none");
}
}
}

View File

@@ -33,10 +33,8 @@ pub fn fake_nginx_404() -> Response {
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
resp.headers_mut().insert(
header::SERVER,
HeaderValue::from_static("nginx"),
);
resp.headers_mut()
.insert(header::SERVER, HeaderValue::from_static("nginx"));
resp
}
@@ -61,10 +59,8 @@ pub async fn serve_static(root: &Path, request: Request) -> Response {
let content_type = mime_for_path(&resolved);
let mut resp = Response::new(Body::from(bytes));
*resp.status_mut() = StatusCode::OK;
resp.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(content_type),
);
resp.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
resp
}
Err(_) => fake_nginx_404(),
@@ -173,10 +169,7 @@ mod tests {
async fn send(router: axum::Router, uri: &str) -> axum::response::Response {
tower::ServiceExt::<Request<Body>>::oneshot(
router,
Request::builder()
.uri(uri)
.body(Body::empty())
.unwrap(),
Request::builder().uri(uri).body(Body::empty()).unwrap(),
)
.await
.unwrap()
@@ -220,9 +213,7 @@ mod tests {
async fn unknown_path_with_static_site_decoy_serves_file() {
let dir = tempfile_dir();
let file = dir.join("index.html");
tokio::fs::write(&file, "<h1>hello</h1>")
.await
.unwrap();
tokio::fs::write(&file, "<h1>hello</h1>").await.unwrap();
let decoy = DecoyConfig::StaticSite { root: dir.clone() };
let resp = send(decoy_router(decoy), "/").await;
@@ -293,11 +284,9 @@ mod tests {
}
fn tempfile_dir() -> PathBuf {
let dir = PathBuf::from("/tmp").join(format!(
"alknet-http-decoy-test-{}",
uuid::Uuid::new_v4()
));
let dir =
PathBuf::from("/tmp").join(format!("alknet-http-decoy-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
}
}

View File

@@ -0,0 +1,993 @@
//! The 5 fixed gateway endpoints (`/search`, `/schema`, `/call`, `/batch`,
//! `/subscribe`) — the sole HTTP invoke path (ADR-042, ADR-047).
//!
//! See `docs/architecture/crates/http/http-server.md` §"HTTP-to-call dispatch"
//! and `docs/architecture/crates/http/http-adapters.md` §"The gateway endpoint
//! set". Each endpoint delegates to `GatewayDispatch::invoke()` (the shared
//! dispatch spine); auth is the shared `bearer_auth_middleware`; error mapping
//! is `call_error_to_http_response`. There is no per-operation
//! `POST /{service}/{op}` direct-call surface (ADR-047).
use std::convert::Infallible;
use std::sync::Arc;
use axum::extract::{FromRef, Query, State};
use axum::http::StatusCode;
use axum::response::sse::Event;
use axum::response::{IntoResponse, Json, Response, Sse};
use axum::routing::{get, post};
use axum::Router;
use futures::stream::{self, BoxStream, Stream};
use serde::Deserialize;
use serde_json::{json, Value};
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
use alknet_call::registry::registration::OperationRegistry;
use alknet_call::registry::spec::{AccessResult, Visibility};
use alknet_core::auth::{Identity, IdentityProvider};
use super::adapter::RouterState;
use super::auth::ResolvedIdentity;
use crate::gateway::dispatch::GatewayDispatch;
use crate::gateway::error::{call_error_to_http_response, call_error_to_http_status_with_identity};
const SERVICES_LIST: &str = "services/list";
const SERVICES_SCHEMA: &str = "services/schema";
#[derive(Clone)]
pub(crate) struct GatewayState {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
}
impl GatewayState {
pub(crate) fn new(
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
) -> Self {
Self {
registry,
identity_provider,
}
}
fn dispatch(&self) -> GatewayDispatch {
GatewayDispatch::new(
Arc::clone(&self.registry),
Arc::clone(&self.identity_provider),
)
}
}
impl FromRef<RouterState> for GatewayState {
fn from_ref(state: &RouterState) -> Self {
GatewayState::new(
Arc::clone(&state.registry),
Arc::clone(&state.identity_provider),
)
}
}
pub(crate) fn gateway_router() -> Router<RouterState> {
Router::new()
.route("/search", get(search_handler))
.route("/schema", get(schema_handler))
.route("/call", post(call_handler))
.route("/batch", post(batch_handler))
.route("/subscribe", post(subscribe_handler))
}
#[derive(Debug, Deserialize)]
pub struct CallRequest {
pub operation: String,
#[serde(default = "Value::default")]
pub input: Value,
}
#[derive(Debug, Deserialize)]
pub struct SchemaQuery {
pub name: String,
}
pub(crate) async fn call_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
Json(request): Json<CallRequest>,
) -> Response {
if is_internal_op(&state.registry, &request.operation) {
return not_found_response(&request.operation);
}
let dispatch = state.dispatch();
let envelope = dispatch
.invoke(identity.clone(), &request.operation, request.input)
.await;
envelope_to_response(envelope, identity.as_ref())
}
pub(crate) async fn search_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
) -> Response {
let dispatch = state.dispatch();
let envelope = dispatch
.invoke(identity.clone(), SERVICES_LIST, json!({}))
.await;
envelope_to_response(envelope, identity.as_ref())
}
pub(crate) async fn schema_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
Query(query): Query<SchemaQuery>,
) -> Response {
if let Some(forbidden) = access_check_for_op(&state.registry, &query.name, identity.as_ref()) {
return forbidden_response(forbidden, identity.as_ref());
}
let dispatch = state.dispatch();
let envelope = dispatch
.invoke(
identity.clone(),
SERVICES_SCHEMA,
json!({ "name": query.name }),
)
.await;
envelope_to_response(envelope, identity.as_ref())
}
pub(crate) async fn batch_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
Json(requests): Json<Vec<CallRequest>>,
) -> Response {
let dispatch = state.dispatch();
let mut results: Vec<Value> = Vec::with_capacity(requests.len());
for request in requests {
if is_internal_op(&state.registry, &request.operation) {
results.push(not_found_envelope_json(&request.operation));
continue;
}
let envelope = dispatch
.invoke(identity.clone(), &request.operation, request.input)
.await;
results.push(envelope_to_json(envelope));
}
Json(json!({ "results": results })).into_response()
}
pub(crate) async fn subscribe_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
Json(request): Json<CallRequest>,
) -> Sse<SubscribeStream> {
let stream = if is_internal_op(&state.registry, &request.operation) {
subscribe_stream_internal_error(request.operation)
} else {
let dispatch = state.dispatch();
let envelope = dispatch
.invoke(identity, &request.operation, request.input)
.await;
subscribe_stream_from_envelope(envelope)
};
Sse::new(stream)
}
pub type SubscribeStream = BoxStream<'static, Result<Event, Infallible>>;
fn subscribe_stream_from_envelope(envelope: ResponseEnvelope) -> SubscribeStream {
Box::pin(envelope_to_sse_stream(envelope))
}
fn subscribe_stream_internal_error(operation: String) -> SubscribeStream {
Box::pin(stream::once(async move { error_event(&operation) }))
}
fn envelope_to_response(envelope: ResponseEnvelope, identity: Option<&Identity>) -> Response {
match envelope.result {
Ok(output) => {
let body = envelope_to_ok_json(&envelope.request_id, &output);
(StatusCode::OK, Json(body)).into_response()
}
Err(error) => {
let status_code = call_error_to_http_status_with_identity(&error, identity);
let status =
StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body = serde_json::to_value(&error).unwrap_or(Value::Null);
(status, Json(body)).into_response()
}
}
}
fn envelope_to_json(envelope: ResponseEnvelope) -> Value {
match envelope.result {
Ok(output) => envelope_to_ok_json(&envelope.request_id, &output),
Err(error) => envelope_to_error_json(&envelope.request_id, &error),
}
}
fn envelope_to_ok_json(request_id: &str, output: &Value) -> Value {
json!({
"request_id": request_id,
"result": "ok",
"output": output,
})
}
fn envelope_to_error_json(request_id: &str, error: &CallError) -> Value {
json!({
"request_id": request_id,
"result": "error",
"error": serde_json::to_value(error).unwrap_or(Value::Null),
})
}
fn not_found_envelope_json(operation: &str) -> Value {
let error = CallError::not_found(operation);
json!({
"request_id": Value::Null,
"result": "error",
"error": serde_json::to_value(&error).unwrap_or(Value::Null),
})
}
fn not_found_response(operation: &str) -> Response {
let error = CallError::not_found(operation);
call_error_to_http_response(&error)
}
fn forbidden_response(message: String, identity: Option<&Identity>) -> Response {
let error = CallError::forbidden(message);
let status_code = call_error_to_http_status_with_identity(&error, identity);
let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body = serde_json::to_value(&error).unwrap_or(Value::Null);
(status, Json(body)).into_response()
}
fn access_check_for_op(
registry: &OperationRegistry,
operation: &str,
identity: Option<&Identity>,
) -> Option<String> {
let name = operation.strip_prefix('/').unwrap_or(operation);
let reg = registry.registration(name)?;
if let AccessResult::Forbidden(message) = reg.spec.access_control.check(identity) {
return Some(message);
}
None
}
fn is_internal_op(registry: &OperationRegistry, operation: &str) -> bool {
let name = operation.strip_prefix('/').unwrap_or(operation);
match registry.registration(name) {
Some(reg) => reg.spec.visibility == Visibility::Internal,
None => false,
}
}
fn envelope_to_sse_stream(
envelope: ResponseEnvelope,
) -> impl Stream<Item = Result<Event, Infallible>> {
stream::once(async move {
match envelope.result {
Ok(output) => {
let data = serde_json::to_string(&output).unwrap_or_else(|_| "null".to_string());
Ok(Event::default().data(data))
}
Err(error) => {
let payload = serde_json::to_value(&error).unwrap_or(Value::Null);
let data = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
Ok(Event::default().event("error").data(data))
}
}
})
}
fn error_event(operation: &str) -> Result<Event, Infallible> {
let error = CallError::not_found(operation);
let payload = serde_json::to_value(&error).unwrap_or(Value::Null);
let data = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
Ok(Event::default().event("error").data(data))
}
#[cfg(test)]
mod tests {
use super::*;
use alknet_call::registry::discovery::{
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
};
use alknet_call::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType};
use alknet_core::auth::{AuthToken, Identity};
use alknet_core::types::Capabilities;
use axum::body::Body;
use axum::http::Request;
use axum::middleware::from_fn_with_state;
use http_body_util::BodyExt;
use std::collections::HashMap;
use std::sync::Mutex as StdMutex;
use tower::ServiceExt;
struct StaticIdentityProvider {
tokens: StdMutex<HashMap<String, Identity>>,
}
impl StaticIdentityProvider {
fn new() -> Self {
Self {
tokens: StdMutex::new(HashMap::new()),
}
}
fn with_token(self, token: &str, identity: Identity) -> Self {
self.tokens
.lock()
.unwrap()
.insert(token.to_string(), identity);
self
}
}
impl IdentityProvider for StaticIdentityProvider {
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
let token_str = String::from_utf8_lossy(&token.raw);
self.tokens.lock().unwrap().get(token_str.as_ref()).cloned()
}
}
fn identity_with_scopes(id: &str, scopes: &[&str]) -> Identity {
Identity {
id: id.to_string(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
resources: HashMap::new(),
}
}
fn external_spec(name: &str, acl: AccessControl) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::External,
json!({}),
json!({}),
vec![],
acl,
)
}
fn internal_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::Internal,
json!({}),
json!({}),
vec![],
AccessControl::default(),
)
}
fn echo_handler() -> alknet_call::registry::registration::Handler {
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) })
}
fn registry_with_echo() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn registry_with_restricted_op() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec(
"admin/run",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn registry_with_internal_op() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
internal_spec("secret/op"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn registry_with_discovery_and_ops(
inner_ops: Vec<HandlerRegistration>,
) -> Arc<OperationRegistry> {
let mut inner = OperationRegistry::new();
for op in inner_ops {
inner.register(op).unwrap();
}
let inner = Arc::new(inner);
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
services_list_spec(),
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
services_schema_spec(),
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
for spec in inner.list_operations() {
let name = spec.name.clone();
let reg = inner.registration(&name).unwrap();
registry
.register(HandlerRegistration::new(
reg.spec.clone(),
reg.handler.clone(),
reg.provenance,
reg.composition_authority.clone(),
reg.scoped_env.clone(),
reg.capabilities.clone(),
))
.unwrap();
}
Arc::new(registry)
}
fn unused_provider() -> Arc<dyn IdentityProvider> {
Arc::new(StaticIdentityProvider::new())
}
fn build_router(
registry: Arc<OperationRegistry>,
provider: Arc<dyn IdentityProvider>,
) -> Router {
let state = RouterState {
registry: Arc::clone(&registry),
identity_provider: Arc::clone(&provider),
decoy: crate::server::DecoyConfig::NotFound,
};
let auth_state = Arc::clone(&provider);
gateway_router()
.route_layer(from_fn_with_state(
auth_state,
super::super::auth::bearer_auth_middleware,
))
.with_state(state)
}
fn auth_header(token: &str) -> (&'static str, String) {
("authorization", format!("Bearer {token}"))
}
async fn send(router: Router, req: Request<Body>) -> (StatusCode, Value) {
let resp = router.oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: Value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
};
(status, body)
}
#[tokio::test]
async fn call_round_trip_external_op_returns_200_with_json_body() {
let router = build_router(registry_with_echo(), unused_provider());
let req = Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&json!({ "operation": "echo/run", "input": { "msg": "hi" } }))
.unwrap(),
))
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.get("result"), Some(&json!("ok")));
assert_eq!(body.get("output"), Some(&json!({ "msg": "hi" })));
}
#[tokio::test]
async fn call_internal_op_returns_404() {
let router = build_router(registry_with_internal_op(), unused_provider());
let req = Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&json!({ "operation": "secret/op", "input": {} })).unwrap(),
))
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body.get("code"), Some(&json!("NOT_FOUND")));
}
#[tokio::test]
async fn call_unauthorized_restricted_op_returns_403() {
let provider: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token("user-tok", identity_with_scopes("user", &["user"])),
);
let router = build_router(registry_with_restricted_op(), provider);
let (k, v) = auth_header("user-tok");
let req = Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json")
.header(k, v)
.body(Body::from(
serde_json::to_vec(&json!({ "operation": "admin/run", "input": {} })).unwrap(),
))
.unwrap();
let (status, _body) = send(router, req).await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn call_unauthenticated_restricted_op_returns_401() {
let router = build_router(registry_with_restricted_op(), unused_provider());
let req = Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&json!({ "operation": "admin/run", "input": {} })).unwrap(),
))
.unwrap();
let (status, _body) = send(router, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn search_returns_only_access_control_allowed_ops() {
let ops = vec![
HandlerRegistration::new(
external_spec("public/echo", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
),
HandlerRegistration::new(
external_spec(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
),
];
let discovery = registry_with_discovery_and_ops(ops);
let provider: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token("user-tok", identity_with_scopes("regular", &["user"])),
);
let router = build_router(discovery, provider);
let (k, v) = auth_header("user-tok");
let req = Request::builder()
.method("GET")
.uri("/search")
.header(k, v)
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
let ops = body
.get("output")
.and_then(|o| o.get("operations"))
.and_then(|o| o.as_array())
.expect("operations array");
let names: Vec<&str> = ops
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.collect();
assert!(names.contains(&"public/echo"));
assert!(!names.contains(&"admin/secret"));
}
#[tokio::test]
async fn schema_returns_full_spec_for_authorized_op() {
let ops = vec![HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)];
let discovery = registry_with_discovery_and_ops(ops);
let router = build_router(discovery, unused_provider());
let req = Request::builder()
.method("GET")
.uri("/schema?name=echo%2Frun")
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
let output = body.get("output").expect("output");
assert_eq!(output.get("name"), Some(&json!("echo/run")));
assert_eq!(output.get("namespace"), Some(&json!("echo")));
assert!(output.get("input_schema").is_some());
assert!(output.get("output_schema").is_some());
}
#[tokio::test]
async fn schema_for_unauthorized_op_returns_403() {
let ops = vec![HandlerRegistration::new(
external_spec(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)];
let discovery = registry_with_discovery_and_ops(ops);
let provider: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token("user-tok", identity_with_scopes("regular", &["user"])),
);
let router = build_router(discovery, provider);
let (k, v) = auth_header("user-tok");
let req = Request::builder()
.method("GET")
.uri("/schema?name=admin%2Fsecret")
.header(k, v)
.body(Body::empty())
.unwrap();
let (status, _body) = send(router, req).await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn batch_returns_array_of_results_in_order() {
let router = build_router(registry_with_echo(), unused_provider());
let req = Request::builder()
.method("POST")
.uri("/batch")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&json!([
{ "operation": "echo/run", "input": { "n": 1 } },
{ "operation": "echo/run", "input": { "n": 2 } },
]))
.unwrap(),
))
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
let results = body
.get("results")
.and_then(|r| r.as_array())
.expect("results array");
assert_eq!(results.len(), 2);
assert_eq!(results[0].get("output"), Some(&json!({ "n": 1 })));
assert_eq!(results[1].get("output"), Some(&json!({ "n": 2 })));
}
#[tokio::test]
async fn batch_internal_op_returns_not_found_in_array() {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
internal_spec("secret/op"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let router = build_router(registry, unused_provider());
let req = Request::builder()
.method("POST")
.uri("/batch")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&json!([
{ "operation": "echo/run", "input": {} },
{ "operation": "secret/op", "input": {} },
]))
.unwrap(),
))
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
let results = body
.get("results")
.and_then(|r| r.as_array())
.expect("results array");
assert_eq!(results.len(), 2);
assert_eq!(results[0].get("result"), Some(&json!("ok")));
assert_eq!(results[1].get("result"), Some(&json!("error")));
assert_eq!(
results[1].get("error").and_then(|e| e.get("code")),
Some(&json!("NOT_FOUND"))
);
}
#[tokio::test]
async fn subscribe_streams_sse_data_event_until_completed() {
let router = build_router(registry_with_echo(), unused_provider());
let req = Request::builder()
.method("POST")
.uri("/subscribe")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&json!({ "operation": "echo/run", "input": { "v": 9 } }))
.unwrap(),
))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ctype = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap().to_string());
assert!(
ctype
.as_deref()
.unwrap_or("")
.starts_with("text/event-stream"),
"expected text/event-stream, got {ctype:?}"
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&bytes);
assert!(body.contains("data:"), "expected a data frame, got: {body}");
assert!(
body.contains("\"v\":9"),
"expected output payload, got: {body}"
);
}
#[tokio::test]
async fn subscribe_internal_op_emits_error_event() {
let router = build_router(registry_with_internal_op(), unused_provider());
let req = Request::builder()
.method("POST")
.uri("/subscribe")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&json!({ "operation": "secret/op", "input": {} })).unwrap(),
))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("event:error") || body.contains("event: error"),
"expected error event, got: {body}"
);
assert!(
body.contains("NOT_FOUND"),
"expected NOT_FOUND, got: {body}"
);
}
#[test]
fn is_internal_op_returns_false_for_unknown() {
let registry = OperationRegistry::new();
assert!(!is_internal_op(&registry, "no/such"));
assert!(!is_internal_op(&registry, "/no/such"));
}
#[test]
fn is_internal_op_detects_registered_internal_op() {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
internal_spec("secret/op"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
assert!(is_internal_op(&registry, "secret/op"));
assert!(is_internal_op(&registry, "/secret/op"));
}
#[test]
fn is_internal_op_false_for_external_op() {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
assert!(!is_internal_op(&registry, "echo/run"));
}
#[test]
fn envelope_to_ok_json_shape() {
let env = ResponseEnvelope::ok("req-1", json!({ "v": 1 }));
let v = envelope_to_json(env);
assert_eq!(v.get("request_id"), Some(&json!("req-1")));
assert_eq!(v.get("result"), Some(&json!("ok")));
assert_eq!(v.get("output"), Some(&json!({ "v": 1 })));
}
#[test]
fn envelope_to_error_json_shape() {
let env = ResponseEnvelope::not_found("req-2", "no/such");
let v = envelope_to_json(env);
assert_eq!(v.get("result"), Some(&json!("error")));
assert_eq!(
v.get("error").and_then(|e| e.get("code")),
Some(&json!("NOT_FOUND"))
);
}
#[tokio::test]
async fn call_with_leading_slash_in_operation_dispatches() {
let router = build_router(registry_with_echo(), unused_provider());
let req = Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&json!({ "operation": "/echo/run", "input": {} })).unwrap(),
))
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.get("result"), Some(&json!("ok")));
}
#[tokio::test]
async fn call_unknown_op_returns_404() {
let router = build_router(registry_with_echo(), unused_provider());
let req = Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&json!({ "operation": "no/such", "input": {} })).unwrap(),
))
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body.get("code"), Some(&json!("NOT_FOUND")));
}
#[tokio::test]
async fn search_unauthenticated_lists_default_acl_ops_only() {
let ops = vec![
HandlerRegistration::new(
external_spec("public/echo", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
),
HandlerRegistration::new(
external_spec(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
),
];
let discovery = registry_with_discovery_and_ops(ops);
let router = build_router(discovery, unused_provider());
let req = Request::builder()
.method("GET")
.uri("/search")
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
let ops = body
.get("output")
.and_then(|o| o.get("operations"))
.and_then(|o| o.as_array())
.expect("operations array");
let names: Vec<&str> = ops
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.collect();
assert!(names.contains(&"public/echo"));
assert!(!names.contains(&"admin/secret"));
}
#[tokio::test]
async fn schema_unknown_op_returns_404() {
let ops = vec![HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)];
let discovery = registry_with_discovery_and_ops(ops);
let router = build_router(discovery, unused_provider());
let req = Request::builder()
.method("GET")
.uri("/schema?name=no%2Fsuch")
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body.get("code"), Some(&json!("NOT_FOUND")));
}
}

View File

@@ -59,4 +59,4 @@ mod tests {
let resp = call_healthz(req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
}
}

View File

@@ -9,6 +9,7 @@
pub mod adapter;
pub mod auth;
pub mod decoy;
pub mod gateway_routes;
pub mod healthz;
pub use adapter::{DecoyConfig, HttpAdapter};

View File

@@ -4,6 +4,9 @@
//! native `EventEnvelope` call-protocol session, not the gateway shape
//! (ADR-048). See `docs/architecture/crates/http/websocket.md`.
pub mod overlay;
pub mod upgrade;
#[cfg(test)]
mod tests {
use std::collections::HashMap;
@@ -15,7 +18,7 @@ mod tests {
use alknet_call::protocol::wire::{EventEnvelope, ResponseEnvelope, EVENT_RESPONDED};
use alknet_call::registry::context::AbortPolicy;
use alknet_call::registry::registration::{
make_handler, HandlerRegistration, OperationProvenance,
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::{Identity, IdentityProvider};
@@ -74,14 +77,18 @@ mod tests {
fn echo_registry() -> Arc<alknet_call::registry::registration::OperationRegistry> {
let mut registry = alknet_call::registry::registration::OperationRegistry::new();
registry.register(HandlerRegistration::new(
external_spec("echo/run"),
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
registry
.register(HandlerRegistration::new(
external_spec("echo/run"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
@@ -126,7 +133,10 @@ mod tests {
let out: EventEnvelope = response.into();
assert_eq!(out.r#type, EVENT_RESPONDED);
assert_eq!(out.id, "ws-rt-1");
assert_eq!(out.payload.get("output"), Some(&serde_json::json!({ "v": 7 })));
assert_eq!(
out.payload.get("output"),
Some(&serde_json::json!({ "v": 7 }))
);
}
#[tokio::test]
@@ -158,14 +168,19 @@ mod tests {
async fn ws_overlay_only_connection_holds_overlay_and_pending() {
let conn = CallConnection::new_overlay_only(identity("ws-peer"));
assert!(conn.connection().is_none());
assert_eq!(conn.identity().map(|i| i.id.clone()), Some("ws-peer".to_string()));
assert_eq!(
conn.identity().map(|i| i.id.clone()),
Some("ws-peer".to_string())
);
assert!(conn.pending().lock().is_empty());
let env = conn.overlay_env();
assert!(!env.contains("worker/exec"));
conn.register_imported(HandlerRegistration::new(
external_spec("worker/exec"),
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::FromCall,
None,
None,

View File

@@ -0,0 +1,715 @@
//! Connection-local Layer 2 overlay for browser-registered ops
//! (ADR-024, ADR-034 §4, ADR-044 §5).
//!
//! A browser over WebSocket has no `PeerId`, does not enter
//! `PeerCompositeEnv`, and any ops it registers land in a per-
//! `CallConnection` overlay that dies when the connection drops. The hub
//! reaches browser ops through the live `CallConnection` handle's
//! `overlay_env()`, not through `PeerRef::Specific` (the browser is not a
//! peer). `AccessControl` on browser-registered ops gates the hub's
//! calls. WS close drops the overlay and aborts in-flight calls (ADR-016).
//!
//! This module is verification + integration tests for the overlay
//! mechanism the upgrade handler (`upgrade.rs`) and `CallConnection`
//! (`alknet-call`) already provide. See
//! `docs/architecture/crates/http/websocket.md`.
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use alknet_call::protocol::connection::CallConnection;
use alknet_call::protocol::dispatch::Dispatcher;
use alknet_call::protocol::wire::{
EventEnvelope, ResponseEnvelope, EVENT_ERROR, EVENT_RESPONDED,
};
use alknet_call::registry::context::{
AbortPolicy, CompositionAuthority, OperationContext, ScopedPeerEnv,
};
use alknet_call::registry::env::{OperationEnv, PeerRef};
use alknet_call::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
};
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::{Identity, IdentityProvider};
use alknet_core::types::Capabilities;
struct StaticIdentityProvider {
tokens: std::sync::Mutex<HashMap<String, Identity>>,
}
impl StaticIdentityProvider {
fn new() -> Self {
Self {
tokens: std::sync::Mutex::new(HashMap::new()),
}
}
fn with_token(self, token: &str, identity: Identity) -> Self {
self.tokens
.lock()
.unwrap()
.insert(token.to_string(), identity);
self
}
}
impl IdentityProvider for StaticIdentityProvider {
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &alknet_core::auth::AuthToken) -> Option<Identity> {
let token_str = String::from_utf8_lossy(&token.raw);
self.tokens.lock().unwrap().get(token_str.as_ref()).cloned()
}
}
fn identity(id: &str) -> Identity {
Identity {
id: id.to_string(),
scopes: vec![],
resources: HashMap::new(),
}
}
fn identity_with_scopes(id: &str, scopes: &[&str]) -> Identity {
Identity {
id: id.to_string(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
resources: HashMap::new(),
}
}
fn external_spec(name: &str, acl: AccessControl) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
acl,
)
}
fn subscription_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Subscription,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
)
}
fn browser_registration(
name: &str,
acl: AccessControl,
composition_authority: Option<CompositionAuthority>,
) -> HandlerRegistration {
HandlerRegistration::new(
external_spec(name, acl),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::FromCall,
composition_authority,
None,
Capabilities::new(),
)
}
fn echo_registry() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn empty_provider() -> Arc<dyn IdentityProvider> {
Arc::new(StaticIdentityProvider::new())
}
fn dispatcher(
registry: Arc<OperationRegistry>,
provider: Arc<dyn IdentityProvider>,
) -> Dispatcher {
Dispatcher::new(registry, provider)
}
fn hub_root_context(
request_id: &str,
allowed: &[&str],
hub_identity: Option<CompositionAuthority>,
env: Arc<dyn OperationEnv + Send + Sync>,
) -> OperationContext {
OperationContext {
request_id: request_id.to_string(),
parent_request_id: None,
identity: None,
handler_identity: hub_identity,
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedPeerEnv::new(allowed.iter().copied()),
env,
abort_policy: AbortPolicy::default(),
deadline: Some(Instant::now() + Duration::from_secs(30)),
internal: true,
}
}
#[tokio::test]
async fn browser_registered_op_lands_in_overlay_not_peer_composite_env() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
let overlay_env = conn.overlay_env();
assert!(!overlay_env.contains("ui/dragged"));
conn.register_imported(browser_registration(
"ui/dragged",
AccessControl::default(),
None,
));
assert!(overlay_env.contains("ui/dragged"));
assert!(
overlay_env.peer_ids().is_empty(),
"browser overlay env exposes no PeerIds (browser is not a peer)"
);
}
#[tokio::test]
async fn browser_connection_has_no_peer_entry_and_no_peerid() {
let conn = CallConnection::new_overlay_only(identity("browser"));
assert!(conn.connection().is_none());
assert_eq!(conn.identity().unwrap().id, "browser");
let env = conn.overlay_env();
assert!(
env.peer_ids().is_empty(),
"overlay-only connection has no PeerIds — no PeerCompositeEnv entry"
);
}
#[tokio::test]
async fn register_imported_and_register_imported_all_both_populate_overlay() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
conn.register_imported(browser_registration(
"ui/click",
AccessControl::default(),
None,
));
conn.register_imported_all(vec![
browser_registration("ui/focus", AccessControl::default(), None),
browser_registration("ui/scroll", AccessControl::default(), None),
]);
let env = conn.overlay_env();
assert!(env.contains("ui/click"));
assert!(env.contains("ui/focus"));
assert!(env.contains("ui/scroll"));
assert!(!env.contains("ui/missing"));
}
#[tokio::test]
async fn hub_outgoing_call_routes_through_overlay_env_not_peerref_specific() {
let registry = echo_registry();
let dp = dispatcher(registry, empty_provider());
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
conn.register_imported(browser_registration(
"ui/dragged",
AccessControl::default(),
None,
));
let composed_env = dp.compose_root_env(
&conn,
&hub_root_context(
"hub-call-1",
&["ui/dragged"],
CompositionAuthority::new("hub", vec![]).into(),
conn.overlay_env(),
),
);
let ctx = hub_root_context(
"hub-call-1",
&["ui/dragged"],
CompositionAuthority::new("hub", vec![]).into(),
composed_env.clone(),
);
let response = composed_env
.invoke("ui", "dragged", serde_json::json!({ "x": 5 }), &ctx)
.await;
assert!(response.result.is_ok());
assert_eq!(response.result.unwrap(), serde_json::json!({ "x": 5 }));
}
#[tokio::test]
async fn peerref_specific_browser_x_routes_to_nothing_no_peer_entry() {
let registry = echo_registry();
let dp = dispatcher(registry, empty_provider());
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
conn.register_imported(browser_registration(
"ui/dragged",
AccessControl::default(),
None,
));
let composed_env = dp.compose_root_env(
&conn,
&hub_root_context(
"hub-peer-1",
&["ui/dragged"],
CompositionAuthority::new("hub", vec![]).into(),
conn.overlay_env(),
),
);
let ctx = hub_root_context(
"hub-peer-1",
&["ui/dragged"],
CompositionAuthority::new("hub", vec![]).into(),
composed_env.clone(),
);
let response = composed_env
.invoke_peer(
&PeerRef::Specific("browser-X".to_string()),
"ui",
"dragged",
serde_json::json!({}),
&ctx,
AbortPolicy::default(),
)
.await;
match response.result {
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
other => panic!("expected NOT_FOUND for PeerRef::Specific(browser-X), got {other:?}"),
}
}
#[tokio::test]
async fn access_control_on_browser_op_gates_hub_call_allowed() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
conn.register_imported(browser_registration(
"ui/dragged",
AccessControl {
required_scopes: vec!["ui:write".to_string()],
..Default::default()
},
None,
));
let env = conn.overlay_env();
let ctx = hub_root_context(
"hub-acl-ok",
&["ui/dragged"],
Some(CompositionAuthority::new(
"hub",
vec!["ui:write".to_string()],
)),
env.clone(),
);
let response = env
.invoke("ui", "dragged", serde_json::json!({ "v": 1 }), &ctx)
.await;
assert!(response.result.is_ok());
assert_eq!(response.result.unwrap(), serde_json::json!({ "v": 1 }));
}
#[tokio::test]
async fn access_control_on_browser_op_gates_hub_call_forbidden() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
conn.register_imported(browser_registration(
"ui/dragged",
AccessControl {
required_scopes: vec!["ui:write".to_string()],
..Default::default()
},
None,
));
let env = conn.overlay_env();
let ctx = hub_root_context(
"hub-acl-deny",
&["ui/dragged"],
Some(CompositionAuthority::new(
"hub",
vec!["ui:read".to_string()],
)),
env.clone(),
);
let response = env
.invoke("ui", "dragged", serde_json::json!({}), &ctx)
.await;
match response.result {
Err(e) => assert_eq!(e.code, "FORBIDDEN"),
other => panic!("expected FORBIDDEN, got {other:?}"),
}
}
#[tokio::test]
async fn access_control_default_on_browser_op_allows_hub_without_scopes() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
conn.register_imported(browser_registration(
"ui/dragged",
AccessControl::default(),
None,
));
let env = conn.overlay_env();
let ctx = hub_root_context(
"hub-acl-default",
&["ui/dragged"],
Some(CompositionAuthority::new("hub", vec![])),
env.clone(),
);
let response = env
.invoke("ui", "dragged", serde_json::json!({ "ok": true }), &ctx)
.await;
assert!(response.result.is_ok());
}
#[tokio::test]
async fn overlay_dropped_on_ws_close_op_no_longer_reachable() {
let conn1 = CallConnection::new_overlay_only(identity("browser-1"));
conn1.register_imported(browser_registration(
"ui/dragged",
AccessControl::default(),
None,
));
assert!(conn1.overlay_env().contains("ui/dragged"));
drop(conn1);
let conn2 = CallConnection::new_overlay_only(identity("browser-2"));
assert!(
!conn2.overlay_env().contains("ui/dragged"),
"a fresh connection's overlay is empty — the dropped connection's overlay did not leak into global state"
);
}
#[tokio::test]
async fn overlay_isolation_between_connections() {
let conn_a = CallConnection::new_overlay_only(identity("browser-a"));
let conn_b = CallConnection::new_overlay_only(identity("browser-b"));
conn_a.register_imported(browser_registration(
"ui/dragged",
AccessControl::default(),
None,
));
conn_b.register_imported(browser_registration(
"ui/click",
AccessControl::default(),
None,
));
assert!(conn_a.overlay_env().contains("ui/dragged"));
assert!(!conn_a.overlay_env().contains("ui/click"));
assert!(conn_b.overlay_env().contains("ui/click"));
assert!(!conn_b.overlay_env().contains("ui/dragged"));
}
#[tokio::test]
async fn browser_with_no_registered_ops_has_unused_server_to_client_direction() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
let env = conn.overlay_env();
assert!(!env.contains("anything"));
assert!(!env.contains("ui/dragged"));
let ctx = hub_root_context(
"no-ops",
&["ui/dragged"],
Some(CompositionAuthority::new("hub", vec![])),
env.clone(),
);
let response = env
.invoke("ui", "dragged", serde_json::json!({}), &ctx)
.await;
match response.result {
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
other => panic!("expected NOT_FOUND when browser registered no ops, got {other:?}"),
}
}
#[tokio::test]
async fn bidirectionality_hub_calls_browser_op_via_overlay() {
let registry = echo_registry();
let dp = dispatcher(registry, empty_provider());
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
conn.register_imported(HandlerRegistration::new(
external_spec("ui/dragged", AccessControl::default()),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "echoed": input }))
})),
OperationProvenance::FromCall,
None,
None,
Capabilities::new(),
));
let composed_env = dp.compose_root_env(
&conn,
&hub_root_context(
"bidir-1",
&["ui/dragged"],
CompositionAuthority::new("hub", vec![]).into(),
conn.overlay_env(),
),
);
let ctx = hub_root_context(
"bidir-1",
&["ui/dragged"],
CompositionAuthority::new("hub", vec![]).into(),
composed_env.clone(),
);
let response = composed_env
.invoke("ui", "dragged", serde_json::json!({ "dx": 10 }), &ctx)
.await;
assert!(response.result.is_ok());
assert_eq!(
response.result.unwrap(),
serde_json::json!({ "echoed": { "dx": 10 } })
);
}
#[tokio::test]
async fn ws_close_aborts_in_flight_subscription_and_cascades_descendants() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
{
let mut pending = conn.pending().lock();
pending.register_subscribe("ws-sub-root".to_string(), None, None);
pending.register_call(
"ws-sub-child".to_string(),
Instant::now() + Duration::from_secs(30),
Some("ws-sub-root".to_string()),
);
}
assert!(conn.pending().lock().contains("ws-sub-root"));
assert!(conn.pending().lock().contains("ws-sub-child"));
let failed =
conn.pending()
.lock()
.fail_all(alknet_call::protocol::wire::CallError::internal(
"connection closed",
));
assert!(failed.contains(&"ws-sub-root".to_string()));
assert!(failed.contains(&"ws-sub-child".to_string()));
assert!(conn.pending().lock().is_empty());
}
#[tokio::test]
async fn ws_close_mid_call_to_browser_op_aborts_call_error_cascade() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
conn.register_imported(browser_registration(
"ui/dragged",
AccessControl::default(),
None,
));
let rx = {
let mut pending = conn.pending().lock();
pending.register_call(
"hub-call-inflight".to_string(),
Instant::now() + Duration::from_secs(30),
None,
)
};
let failed =
conn.pending()
.lock()
.fail_all(alknet_call::protocol::wire::CallError::internal(
"connection closed",
));
assert!(failed.contains(&"hub-call-inflight".to_string()));
let result = tokio::time::timeout(Duration::from_millis(100), rx).await;
match result {
Ok(Ok(Err(e))) => assert_eq!(e.code, "INTERNAL"),
other => panic!("expected Err(INTERNAL) from aborted call, got {other:?}"),
}
assert!(
conn.pending().lock().is_empty(),
"in-flight call aborted from pending map on ws close"
);
assert!(conn.overlay_env().contains("ui/dragged"));
}
#[tokio::test]
async fn overlay_env_invoke_event_envelope_round_trip_for_browser_op() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
conn.register_imported(browser_registration(
"ui/dragged",
AccessControl::default(),
None,
));
let env = conn.overlay_env();
let ctx = hub_root_context(
"env-rt-1",
&["ui/dragged"],
Some(CompositionAuthority::new("hub", vec![])),
env.clone(),
);
let response = env
.invoke("ui", "dragged", serde_json::json!({ "v": 9 }), &ctx)
.await;
let envelope: EventEnvelope = response.into();
assert_eq!(envelope.r#type, EVENT_RESPONDED);
assert_eq!(
envelope.payload.get("output"),
Some(&serde_json::json!({ "v": 9 }))
);
}
#[tokio::test]
async fn overlay_env_invoke_forbidden_emits_call_error_envelope() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
conn.register_imported(browser_registration(
"ui/dragged",
AccessControl {
required_scopes: vec!["ui:write".to_string()],
..Default::default()
},
None,
));
let env = conn.overlay_env();
let ctx = hub_root_context(
"env-rt-forbid",
&["ui/dragged"],
Some(CompositionAuthority::new("hub", vec![])),
env.clone(),
);
let response = env
.invoke("ui", "dragged", serde_json::json!({}), &ctx)
.await;
let envelope: EventEnvelope = response.into();
assert_eq!(envelope.r#type, EVENT_ERROR);
assert_eq!(
envelope.payload.get("code"),
Some(&serde_json::json!("FORBIDDEN"))
);
}
#[tokio::test]
async fn overlay_reachability_gate_returns_not_found_for_disallowed_op() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
conn.register_imported(browser_registration(
"ui/dragged",
AccessControl::default(),
None,
));
let env = conn.overlay_env();
let ctx = hub_root_context(
"reach-deny",
&[],
Some(CompositionAuthority::new("hub", vec![])),
env.clone(),
);
let response = env
.invoke("ui", "dragged", serde_json::json!({}), &ctx)
.await;
match response.result {
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
other => panic!("expected NOT_FOUND (not in scoped_env), got {other:?}"),
}
}
#[tokio::test]
async fn overlay_subscription_spec_round_trips_via_overlay_env() {
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser")));
let counter = std::sync::Arc::new(std::sync::Mutex::new(0u32));
let handler = {
let counter = std::sync::Arc::clone(&counter);
make_handler(move |_input, ctx| {
let counter = std::sync::Arc::clone(&counter);
async move {
let mut c = counter.lock().unwrap();
*c += 1;
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "n": *c }))
}
})
};
conn.register_imported(HandlerRegistration::new(
subscription_spec("events/stream"),
HandlerKind::Once(handler),
OperationProvenance::FromCall,
None,
None,
Capabilities::new(),
));
let env = conn.overlay_env();
assert!(env.contains("events/stream"));
for i in 0..3 {
let ctx = hub_root_context(
&format!("sub-{i}"),
&["events/stream"],
Some(CompositionAuthority::new("hub", vec![])),
env.clone(),
);
let response = env
.invoke("events", "stream", serde_json::json!({}), &ctx)
.await;
assert!(response.result.is_ok());
}
}
#[tokio::test]
async fn browser_identity_resolved_at_upgrade_is_stored_on_connection() {
let provider = Arc::new(StaticIdentityProvider::new().with_token(
"browser-token",
identity_with_scopes("browser-user", &["ui:read"]),
));
let registry = echo_registry();
let dp = dispatcher(registry, Arc::clone(&provider) as Arc<dyn IdentityProvider>);
let conn = Arc::new(CallConnection::new_overlay_only(identity_with_scopes(
"browser-user",
&["ui:read"],
)));
assert_eq!(conn.identity().unwrap().id, "browser-user");
assert_eq!(conn.identity().unwrap().scopes, vec!["ui:read".to_string()]);
let composed_env = dp.compose_root_env(
&conn,
&hub_root_context(
"id-check",
&["echo/run"],
CompositionAuthority::new("hub", vec![]).into(),
conn.overlay_env(),
),
);
let peer_ids = composed_env.peer_ids();
assert_eq!(peer_ids, vec!["browser-user".to_string()]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,11 +9,11 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use alknet_call::client::OperationAdapter;
use alknet_call::protocol::wire::ResponseEnvelope;
use alknet_call::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
use alknet_call::registry::env::OperationEnv;
use alknet_call::registry::registration::OperationProvenance;
use alknet_call::client::OperationAdapter;
use alknet_core::types::Capabilities;
use alknet_http::adapters::FromMCP;
use axum::Router;
@@ -22,8 +22,8 @@ use rmcp::model::{
};
use rmcp::service::RequestContext;
use rmcp::transport::{
StreamableHttpServerConfig,
streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService},
StreamableHttpServerConfig,
};
use rmcp::{RoleServer, ServerHandler};
use serde_json::Value;
@@ -72,18 +72,19 @@ impl ServerHandler for EchoServer {
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> impl std::future::Future<
Output = Result<ListToolsResult, rmcp::ErrorData>,
> + rmcp::service::MaybeSendFuture + '_ {
) -> impl std::future::Future<Output = Result<ListToolsResult, rmcp::ErrorData>>
+ rmcp::service::MaybeSendFuture
+ '_ {
let tools = vec![
Tool::new_with_raw(
"echo",
Some("Echo the input back as structured content".into()),
Arc::new(serde_json::Map::new()),
)
.with_raw_output_schema(Arc::new(serde_json::Map::from_iter([
("type".to_string(), Value::String("object".into())),
]))),
.with_raw_output_schema(Arc::new(serde_json::Map::from_iter([(
"type".to_string(),
Value::String("object".into()),
)]))),
Tool::new_with_raw(
"legacy",
Some("Legacy tool returning text content blocks".into()),
@@ -101,22 +102,17 @@ impl ServerHandler for EchoServer {
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> impl std::future::Future<
Output = Result<CallToolResult, rmcp::ErrorData>,
> + rmcp::service::MaybeSendFuture + '_ {
) -> impl std::future::Future<Output = Result<CallToolResult, rmcp::ErrorData>>
+ rmcp::service::MaybeSendFuture
+ '_ {
let name = request.name.to_string();
std::future::ready(Ok(match name.as_str() {
"echo" => {
let args = request
.arguments
.map(Value::Object)
.unwrap_or(Value::Null);
let args = request.arguments.map(Value::Object).unwrap_or(Value::Null);
CallToolResult::structured(serde_json::json!({ "echoed": args }))
}
"legacy" => CallToolResult::success(vec![Content::text("plain text result")]),
other => CallToolResult::error(vec![Content::text(format!(
"unknown tool: {other}"
))]),
other => CallToolResult::error(vec![Content::text(format!("unknown tool: {other}"))]),
}))
}
@@ -234,4 +230,4 @@ async fn import_unreachable_server_returns_discovery_failed() {
Err(alknet_call::client::AdapterError::Transport { .. }) => {}
Err(other) => panic!("expected DiscoveryFailed or Transport, got {other}"),
}
}
}

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-30
last_updated: 2026-07-02
---
# Alknet Architecture
@@ -102,6 +102,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [046](decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | Proposed |
| [047](decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | Proposed |
| [048](decisions/048-websocket-native-session-not-gateway.md) | WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape | Accepted |
| [049](decisions/049-streaming-handler-for-subscriptions.md) | Streaming Handler for Subscription Operations | Accepted |
## Open Questions
@@ -152,6 +153,7 @@ See [open-questions.md](open-questions.md) for the full tracker.
- **OQ-37**: ~~X.509 outgoing-only case~~**resolved by ADR-034** (three remote roles named: public X.509 endpoint, transport relay, hub; `PeerEntry` asymmetry is correct; client-side verifier selection by `PeerEntry` presence)
- **OQ-38**: WebTransport standalone relay service scope — the standalone relay (future `alknet-relay`, fork of iroh-relay with WebTransport proxy fallback) is distinct from the in-process ALPN-stream-proxy (ADR-040); scope question, not deferral
- **OQ-39**: ~~`to_openapi` published-spec versioning~~**resolved by ADR-045** (`info.version` semver tracks the gateway endpoint contract, not the operation set; per-caller operations discovered via `/search`)
- **OQ-41**: Stream operators library — a handler-level utility library (filter, map, batch, dedupe, window, etc. on `BoxStream<T>`), prior art in `@alkdev/pubsub/operators.ts`; feature extension, not an architectural decision (the architecture decision — stream composition is handler-level, not protocol-level — is made in ADR-049)
**Deferred (not active):**
- **OQ-09**: WASM target boundaries — design constraint, not deliverable

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-23
last_updated: 2026-07-02
---
# Call Protocol
@@ -275,6 +275,7 @@ Error codes use an extensible string enum. The protocol defines the following **
- `NOT_FOUND` — operation not in registry (or Internal op called from wire)
- `FORBIDDEN` — access denied (insufficient scopes or unauthenticated)
- `INVALID_INPUT` — input doesn't match the operation's JSON Schema
- `INVALID_OPERATION_TYPE` — wrong dispatch path for the operation's type (`invoke()` called on a `Subscription`, or `invoke_streaming()` on a `Query`/`Mutation`, or `OperationEnv::invoke()` on a `Subscription` during composition — ADR-049)
- `INTERNAL` — handler error, panic, connection failure
- `TIMEOUT` — request timed out (retryable: true)
@@ -309,7 +310,7 @@ Local dispatch produces `ResponseEnvelope { request_id, result: Result<Value, Ca
| `Ok(value)` | `{ type: "call.responded", id: request_id, payload: { output: value } }` |
| `Err(call_error)` | `{ type: "call.error", id: request_id, payload: <serialized CallError> }` |
The `request_id` becomes the `id` field. For subscriptions, each `call.responded` is a separate `EventEnvelope` with the same `id`; `call.completed` is `{ type: "call.completed", id, payload: {} }`.
The `request_id` becomes the `id` field. For subscriptions, each `call.responded` is a separate `EventEnvelope` with the same `id`; `call.completed` is `{ type: "call.completed", id, payload: {} }`. The streaming dispatch path (`invoke_streaming()` → write each → write `call.completed`) produces these frames from a `StreamingHandler`'s stream; the single-response path (`invoke()` → write one) produces them from a `Handler`'s future. See ADR-049 and [operation-registry.md](operation-registry.md#handler).
### Protocol Operations
@@ -405,10 +406,14 @@ The `CallAdapter::handle()` method:
1. Spawns a task that continuously calls `connection.accept_bi()` to receive incoming streams
2. For each accepted stream, reads `EventEnvelope` frames using `FrameFramedReader`
3. Dispatches `call.requested` events to the operation registry
3. Dispatches `call.requested` events to the operation registry, **branching on `op_type`** (ADR-049):
- **`Query` / `Mutation`** → `OperationRegistry::invoke()` → write one `call.responded` (or `call.error`) `EventEnvelope` frame
- **`Subscription`** → `OperationRegistry::invoke_streaming()` → write each `call.responded` `EventEnvelope` as the stream yields → write `call.completed` on natural stream end (or `call.error` if the stream yields an `Err`). `deadline: None` for subscriptions (unbounded — see Timeouts below). Abort (`call.aborted` arriving for the request ID, or the stream being dropped) cascades per ADR-016: the stream future is dropped, `Drop` guards release the handler's resources, and descendants are aborted.
4. Writes response `EventEnvelope` frames using `FrameFramedWriter`
5. Manages `PendingRequestMap` for outgoing calls initiated by the server
The streaming branch is the server-side path that makes `Subscription` operations work end-to-end. Without it, a `Subscription` op registered with a `StreamingHandler` had no server-side dispatch path — the handler produced a stream but the dispatcher only read one `ResponseEnvelope` and closed. ADR-049 adds the `StreamingHandler` type and the `invoke_streaming()` dispatch path; this section wires them into the accept loop. See [operation-registry.md](operation-registry.md#handler) for the `Handler` / `StreamingHandler` / `HandlerKind` types.
For outgoing calls (server → client), the adapter:
1. Opens a bidirectional stream with `connection.open_bi()`
2. Sends `call.requested` on that stream
@@ -562,6 +567,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
| Peer-graph routing model (supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type, `invoke_streaming()` dispatch path, `INVALID_OPERATION_TYPE` protocol code; the server-side streaming branch in `handle_stream` |
## Open Questions
@@ -615,4 +621,5 @@ See [open-questions.md](../../open-questions.md) for full details.
- ADR-030: PeerEntry and Identity.id decoupling (`PeerId` source)
- ADR-032: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
- ADR-034: Outgoing-only X.509 and the three peer roles
- ADR-049: Streaming handler for subscriptions (server-side streaming dispatch path)
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-28
last_updated: 2026-07-02
---
# alknet-call — Client and Adapters
@@ -323,8 +323,21 @@ The flow (ADR-017 §3):
3. For each discovered op, construct a `HandlerRegistration`:
- `spec` mirrors the remote op's name (with optional prefix), namespace,
type, schemas, access control.
- `handler` is a forwarding handler: sends `call.requested` through the
`CallConnection`, awaits `call.responded` (or streams for subscriptions).
- `handler` is a forwarding handler, **branched on `op_type`** (ADR-049):
- `Query` / `Mutation` → a `Handler` (registered as `HandlerKind::Once`):
sends `call.requested` via `CallConnection::call_with_payload()`, awaits
the single `call.responded` (or `call.error`), returns the
`ResponseEnvelope`.
- `Subscription` → a `StreamingHandler` (registered as
`HandlerKind::Stream`): calls `CallConnection::subscribe()`, which
returns `impl Stream<Item = ResponseEnvelope>` (the client-side
streaming path, already implemented), maps it to a
`BoxStream<ResponseEnvelope>`. The remote stream flows end-to-end:
each `call.responded` the remote sends becomes a stream item; the
remote's `call.completed` ends the stream (→ wire `call.completed`);
`call.aborted` drops the stream (cascade per ADR-016). No truncation,
no first-value fallback — a `from_call`-imported subscription forwards
the full remote stream.
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
(leaf — ADR-022).
4. The caller registers the bundles via
@@ -668,6 +681,7 @@ Based on the gap analysis and the downstream unblock chain:
| Privilege model and authority context | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture |
| Abort cascade for nested calls | [ADR-016](../../decisions/016-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
| TLS identity redesign | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of `CallCredentials` |
| Outgoing-only X.509 and three peer roles | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
| HD derivation for encryption keys | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material |

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-27
last_updated: 2026-07-02
---
# Operation Registry
@@ -91,19 +91,75 @@ Operations with empty `AccessControl` (no required scopes, no resource checks) a
### Handler
There are two handler types, one per dispatch shape — mirroring the
TypeScript prior art (`@alkdev/operations/src/types.ts:62-78`:
`OperationHandler` returns a single value; `SubscriptionHandler` returns an
`AsyncGenerator`). The split is locked by ADR-049.
```rust
pub type Handler = Arc<dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>> + Send + Sync>;
/// Request/response handler — Query and Mutation operations.
pub type Handler = Arc<
dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>
+ Send + Sync,
>;
/// Streaming handler — Subscription operations. Returns a stream of
/// ResponseEnvelopes: each Ok(value) → call.responded, an Err → call.error
/// (terminal — stream ends), natural stream end → call.completed.
pub type StreamingHandler = Arc<
dyn Fn(Value, OperationContext)
-> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
+ Send + Sync,
>;
/// Type alias for the boxed stream shape used by `invoke_streaming()` and
/// `StreamingHandler` return values. The concrete library
/// (`futures::stream::BoxStream<'static, T>` = `Pin<Box<dyn Stream<Item = T>
/// + Send>>`) is a two-way-door implementation detail (ADR-049); the alias
/// exists so the two spellings (the expanded form in `StreamingHandler` and
/// the short form in `invoke_streaming()`) refer to the same type.
pub type ResponseStream = Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>;
```
Handlers are async — many operations (file I/O, HTTP service calls, irpc service calls) are inherently asynchronous. The handler receives an `async` runtime context and returns a `Future<Output = ResponseEnvelope>`.
Both handlers are async — many operations (file I/O, HTTP service calls,
irpc service calls, LLM streaming) are inherently asynchronous. A handler
receives:
A handler receives:
- `input: Value` — the deserialized `payload` from the `call.requested` event (always `serde_json::Value`)
- `input: Value` — the deserialized `payload` from the `call.requested` event
(always `serde_json::Value`)
- `context: OperationContext` — request ID, identity, metadata, env
And returns a `ResponseEnvelope` containing the result or an error. `ResponseEnvelope` is defined in [call-protocol.md](call-protocol.md#responseenvelope) — it carries the request ID and a `Result<Value, CallError>`. Local dispatch produces it with no serialization overhead; the `CallAdapter` converts it to `EventEnvelope` for the wire.
The **`Handler`** (request/response) returns a single `ResponseEnvelope`
containing the result or an error. `ResponseEnvelope` is defined in
[call-protocol.md](call-protocol.md#responseenvelope) — it carries the request
ID and a `Result<Value, CallError>`. Local dispatch produces it with no
serialization overhead; the `CallAdapter` converts it to `EventEnvelope` for
the wire.
When a handler returns an error, the `CallError.code` is matched against the operation's declared `error_schemas` (ADR-023). If the code matches a declared `ErrorDefinition`, the `call.error` event carries that code and the error's detail payload. If it doesn't match, the `call.error` carries `INTERNAL`. This is how handler failures become typed errors on the wire instead of string-matched messages.
The **`StreamingHandler`** (streaming) returns a `Pin<Box<dyn Stream<Item =
ResponseEnvelope> + Send>>` — the stream analogue of `Handler`'s
`Pin<Box<dyn Future<...>>>`. Each `Ok(value)` in the stream becomes a
`call.responded` event; an `Err` becomes a `call.error` event (terminal — the
stream ends after it); natural stream end becomes `call.completed`. The
dispatch path converts each `ResponseEnvelope` to `EventEnvelope` exactly as
it does for the single-response case — no new wire-format concept is
introduced. See ADR-049 and [call-protocol.md](call-protocol.md) §"CallAdapter
Stream Handling".
When a handler returns an error, the `CallError.code` is matched against the operation's declared `error_schemas` (ADR-023). If the code matches a declared `ErrorDefinition`, the `call.error` event carries that code and the error's detail payload. If it doesn't match, the `call.error` carries `INTERNAL`. This is how handler failures become typed errors on the wire instead of string-matched messages. The same matching applies to `Err` values yielded by a `StreamingHandler`.
A `make_streaming_handler()` helper (analogue of `make_handler()`) wraps a
stream-producing closure into a `StreamingHandler`:
```rust
pub fn make_streaming_handler<S, St>(f: S) -> StreamingHandler
where
S: Fn(Value, OperationContext) -> St + Send + Sync + 'static,
St: Stream<Item = ResponseEnvelope> + Send + 'static,
{
Arc::new(move |input, context| Box::pin(f(input, context)))
}
```
### OperationContext
@@ -196,9 +252,10 @@ pub struct OperationRegistry {
The registry maps operation names to `HandlerRegistration` bundles. The curated layer (Layer 0) is a `HashMap<String, HandlerRegistration>`; session and connection overlays (Layers 1 and 2) are separate maps that the `CallAdapter` composes into the per-call `OperationContext.env` (ADR-024). See ADR-022 for the full registration model and ADR-024 for the layering model. Key methods:
- `register(registration)`: Add an operation to the curated layer at startup
- `registration(name)`: Find a registration by operation name (checks active overlays first, then curated base — ADR-024). Returns spec, handler, provenance, composition authority, scoped env, capabilities.
- `invoke(name, input, context)`: Look up, check ACL, invoke handler, return result
- `register(registration)`: Add an operation to the curated layer at startup. Validates `handler` is the right `HandlerKind` for `spec.op_type` (Once for Query/Mutation, Stream for Subscription — ADR-049). Mismatch is a startup error.
- `registration(name)`: Find a registration by operation name (checks active overlays first, then curated base — ADR-024). Returns spec, handler (`HandlerKind`), provenance, composition authority, scoped env, capabilities.
- `invoke(name, input, context)`: Look up, check ACL, invoke handler, return a single `ResponseEnvelope` (request/response path — Query/Mutation). **Errors with `INVALID_OPERATION_TYPE` if the op is a `Subscription`**`invoke()` is the wrong dispatch path for streaming ops; use `invoke_streaming()` (ADR-049).
- `invoke_streaming(name, input, context)`: Look up, check ACL, invoke streaming handler, return a `ResponseStream` (the boxed stream alias — ADR-049) (streaming path — Subscription). Pre-handler errors (not-found, forbidden, `INVALID_OPERATION_TYPE` for a non-Subscription op) yield a single error `ResponseEnvelope` and end the stream. See ADR-049.
- `list_operations()`: Return all registered specs (for `/services/list` — returns curated + active overlay ops)
### Request ID Generation
@@ -229,15 +286,23 @@ The registration bundle carries everything the dispatch path needs to construct
```rust
pub struct HandlerRegistration {
pub spec: OperationSpec,
pub handler: Handler,
pub handler: HandlerKind, // Once or Stream — validated against spec.op_type (ADR-049)
pub provenance: OperationProvenance,
pub composition_authority: Option<CompositionAuthority>, // None for leaves
pub scoped_env: Option<ScopedOperationEnv>, // None for leaves
pub scoped_env: Option<ScopedPeerEnv>, // None for leaves
pub capabilities: Capabilities,
// NOTE: ADR-028 added `remote_safe: bool` here; ADR-029 supersedes it and
// removes the field. Peer authorization is `AccessControl::check(peer_identity)`,
// not a per-op boolean. See ADR-029 §3.
}
/// Which dispatch path a handler uses — locked by ADR-049.
/// Validated against `spec.op_type` at registration:
/// Query/Mutation → Once; Subscription → Stream. Mismatch is a startup error.
pub enum HandlerKind {
Once(Handler),
Stream(StreamingHandler),
}
```
#### OperationProvenance
@@ -291,19 +356,22 @@ impl CompositionAuthority {
- `scoped_env`: The set of operations this handler may reach via `env.invoke()`. `None` for leaves (empty env). The reachability control from ADR-015.
- `capabilities`: Outbound credentials (decrypted API keys, signing keys). Populated by the assembly layer from the vault at registration time. See [Capability Injection](#capability-injection).
The `OperationRegistryBuilder` provides a fluent API with convenience methods for common cases:
The `OperationRegistryBuilder` provides a fluent API with convenience methods for common cases. The builder absorbs the `HandlerKind` wrapping internally — `.with_local()` and `.with_leaf()` take the raw `Handler` (or `StreamingHandler`) and wrap it in the right `HandlerKind` based on `spec.op_type` (ADR-049):
```rust
// with_local: Local provenance, full bundle — all 5 args required.
// with_local(spec, handler, composition_authority, scoped_env, capabilities)
// The builder inspects spec.op_type and wraps in HandlerKind::Once
// (Query/Mutation) or HandlerKind::Stream (Subscription) automatically.
let registry = OperationRegistryBuilder::new()
// Built-in service discovery (Local, no composition — empty authority, empty env, empty caps)
.with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
// Agent handler (Local, composes — authority + scoped env + capabilities)
.with_local(agent_chat_spec(), Arc::new(agent_chat_handler),
// Agent handler (Local, Subscription — streams call.responded as the
// LLM generates tokens; builder wraps in HandlerKind::Stream)
.with_local(agent_chat_spec(), Arc::new(agent_chat_streaming_handler),
CompositionAuthority::new("agent-chat", ["llm:call", "fs:read", "vastai:query"]),
ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]),
Capabilities::new().with_api_key("google", google_api_key))
@@ -318,6 +386,8 @@ The CLI binary (or assembly layer) constructs the registry and passes it to the
The `OperationEnv` trait is the universal composition mechanism. A handler calls `context.env.invoke("fs", "readFile", input, &context)` and gets a `ResponseEnvelope` back — regardless of whether the operation runs locally, via an irpc service, or on a remote node.
**`OperationEnv` is request/response-only** (ADR-049). It returns a single `ResponseEnvelope` — no streaming variant exists. Calling `invoke()` on a `Subscription` op produces `CallError { code: "INVALID_OPERATION_TYPE", ... }` — composition cannot truncate a stream to its first value. Stream composition (filter, map, combine, window, dedupe) is a handler-level concern, not a protocol composition concern; see ADR-049 for the rationale and the `@alkdev/pubsub` `operators.ts` prior art.
```rust
/// The composition dispatch trait. A handler composes child operations
/// through its `OperationContext.env` (which implements this trait).
@@ -673,10 +743,11 @@ let registry = OperationRegistryBuilder::new()
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
// Agent handler (Local, composes — full bundle via .with())
// Agent handler (Local, Subscription — composes; streaming handler
// wrapped in HandlerKind::Stream by the builder per ADR-049)
.with(HandlerRegistration {
spec: agent_chat_spec(),
handler: Arc::new(agent_chat_handler),
handler: HandlerKind::Stream(Arc::new(agent_chat_streaming_handler)),
provenance: OperationProvenance::Local,
composition_authority: Some(CompositionAuthority::new(
"agent-chat", ["llm:call", "fs:read", "vastai:query"])),
@@ -750,6 +821,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. See ADR-014.
- **Metadata does not propagate through composition.** `OperationEnv::invoke()` constructs fresh metadata for nested calls (`HashMap::new()`), not the parent's metadata. This prevents a handler that accidentally places a secret in metadata from leaking it to child operations — and if a child is a `from_call` operation (ADR-017), across the wire to a remote node. The tracing link is `parent_request_id`, not metadata propagation. See ADR-014.
- **Provenance determines composition capability.** Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) get `composition_authority: None` and `scoped_env: None` — they don't compose, so they don't need authority or reachability bounds. See ADR-022.
- **`HandlerKind` matches `op_type`** (ADR-049). `Query`/`Mutation` ops register a `HandlerKind::Once(Handler)`; `Subscription` ops register a `HandlerKind::Stream(StreamingHandler)`. Mismatch is a startup error. `invoke()` on a `Subscription` and `invoke_streaming()` on a `Query`/`Mutation` both return `INVALID_OPERATION_TYPE`. `OperationEnv::invoke()` (composition) is request/response-only and errors with `INVALID_OPERATION_TYPE` on `Subscription` ops — stream composition is a handler-level concern, not a protocol composition concern.
## Design Decisions
@@ -768,6 +840,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
| Peer-graph routing model (supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
| ~~Peer-scoped registry filtering~~ (superseded) | ~~[ADR-028](../../decisions/028-callclient-peer-scoped-registry-filtering.md)~~ | ~~`remote_safe` marking on `HandlerRegistration`~~ — superseded by ADR-029 |
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
## Open Questions
@@ -814,4 +887,5 @@ See [open-questions.md](../../open-questions.md) for full details.
- ADR-029: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing; `PeerCompositeEnv` supersedes the singular-connection `CompositeOperationEnv`)
- ADR-030: PeerEntry and Identity.id decoupling (`PeerId` source = `Identity.id` = `PeerEntry.peer_id`)
- ADR-032: Forwarded-for identity (`forwarded_for` on `OperationContext` and `call.requested`; metadata only)
- ADR-049: Streaming handler for subscriptions (`StreamingHandler`, `HandlerKind`, `invoke_streaming()`, `INVALID_OPERATION_TYPE`)
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-01
last_updated: 2026-07-02
---
# HTTP Adapters — from_openapi and to_openapi
@@ -123,8 +123,8 @@ The adapter:
### Forwarding handler
The forwarding handler is the `Arc<dyn Handler>` stored in the
`HandlerRegistration`. At call time, it:
The forwarding handler is stored in the `HandlerRegistration` as a
`HandlerKind` (ADR-049). At call time, it:
1. Reads the call input (`serde_json::Value`).
2. Builds the outbound HTTP request:
@@ -138,15 +138,23 @@ The forwarding handler is the `Arc<dyn Handler>` stored in the
below).
4. For a `Query`/`Mutation`: parses the response body (JSON, text, or
binary — same content-type branching as the TS `createHTTPOperation`),
wraps it in a `ResponseEnvelope`, returns.
wraps it in a `ResponseEnvelope`, returns. Registered as
`HandlerKind::Once` — a `Handler` returning a single
`ResponseEnvelope`.
5. For a `Subscription` (`text/event-stream` response): streams
`call.responded` events as the SSE chunks arrive (same SSE parsing as
the TS `parseSSEFrames`), then `call.completed` on stream end.
the TS `parseSSEFrames`), then the stream ends on SSE close (which
becomes `call.completed` on the wire). Registered as
`HandlerKind::Stream` — a `StreamingHandler` returning a
`BoxStream<ResponseEnvelope>` (ADR-049). Each SSE `data:` frame becomes
a `ResponseEnvelope::ok()`; an HTTP error (non-2xx) becomes a single
`ResponseEnvelope::error()` and ends the stream.
6. On HTTP error (non-2xx): maps to the declared `ErrorDefinition` by
HTTP status code (see Error Fidelity below), returns a `CallError`.
The handler is opaque to the `CallAdapter` — it's an `Arc<dyn Handler>`
the registry dispatches. `alknet-call` never sees `reqwest`.
The handler is opaque to the `CallAdapter` — it's a `HandlerKind` the
registry dispatches (via `invoke()` for `Once`, `invoke_streaming()` for
`Stream`). `alknet-call` never sees `reqwest`.
### HTTP client (reqwest)
@@ -319,9 +327,9 @@ factoring recommendation (thin shared struct, not a trait).
`from_openapi` maps OpenAPI non-2xx response status codes to
`ErrorDefinition`s (ADR-023 §5). The normative rule (review #002 W20):
`from_openapi` must not produce error codes that collide with the five
`from_openapi` must not produce error codes that collide with the six
protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`,
`INTERNAL`, `TIMEOUT`). The adapter prefixes imported error codes with
`INVALID_OPERATION_TYPE`, `INTERNAL`, `TIMEOUT`). The adapter prefixes imported error codes with
`HTTP_` and the status number:
```rust
@@ -423,6 +431,7 @@ once published, the 5-endpoint gateway shape is one-way.
| HTTP path = operation path (~~direct-call surface~~) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) → superseded by [ADR-047](../../decisions/047-remove-direct-call-http-surface.md) | ~~`POST /{service}/{op}` → `call.requested`~~ — removed; the gateway `/call` with `{ operation, input }` is the sole invoke path; `to_openapi` describes the gateway, not a per-operation surface |
| `to_openapi` gateway pattern | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | 5 fixed gateway endpoints (search/schema/call/batch/subscribe), not one path per operation; per-caller AccessControl-filtered. Supersedes ADR-036's original `to_openapi` "paths mirror `/{service}/{op}`" clause |
| `to_openapi` published-spec versioning | [ADR-045](../../decisions/045-to-openapi-gateway-spec-versioning.md) | `info.version` semver tracks the gateway endpoint contract, not the operation set; consumers detect breaking changes via the major version |
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_openapi` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`); SSE response → `BoxStream<ResponseEnvelope>`; `Query`/`Mutation` stay `HandlerKind::Once` |
## Open Questions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-01
last_updated: 2026-07-02
---
# HTTP MCP — from_mcp and to_mcp
@@ -78,10 +78,12 @@ The adapter:
namespace prefix is configured — same local-naming sugar as
`from_call`'s `FromCallConfig::namespace_prefix`, ADR-029 §5).
- `spec.namespace` = the configured `namespace`.
- `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP
spec doesn't have a native streaming/tool-subscription distinction
`tools/call` returns a result. If MCP adds a streaming-tool
extension, a `Subscription` mapping would be added.)
- `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP
spec doesn't have a native streaming/tool-subscription distinction
`tools/call` returns a result. If MCP adds a streaming-tool
extension, a `Subscription` mapping would be added.) All `from_mcp`
handlers are `HandlerKind::Once` (ADR-049); `from_mcp` never
produces a `StreamingHandler`.
- `spec.visibility` = `Internal` (adapter-registered, ADR-015).
- `spec.input_schema` = the tool's `inputSchema` (JSON Schema).
- `spec.output_schema` = depends on whether the tool declares
@@ -128,8 +130,9 @@ At call time, the `from_mcp` forwarding handler:
registration (the MCP server is a persistent streamable HTTP
endpoint, not a per-call connection).
The handler is opaque to the `CallAdapter``Arc<dyn Handler>` the
registry dispatches. `alknet-call` never sees rmcp.
The handler is opaque to the `CallAdapter`a `HandlerKind::Once`
wrapping an `Arc<dyn Handler>` that the registry dispatches. `alknet-call`
never sees rmcp.
### Output handling (structuredContent vs content blocks)
@@ -222,7 +225,12 @@ The gateway exposes only `Query` and `Mutation` operations
(request/response). `Subscription` operations (streaming) are filtered
out of `search` results and cannot be invoked via `call` — MCP tool
calls are request/response by protocol design; streaming subscriptions
don't fit the LLM tool-call pattern. See ADR-041 §2.
don't fit the LLM tool-call pattern. This is unaffected by ADR-049
(streaming handlers): the `StreamingHandler` type and `invoke_streaming()`
dispatch path exist in `alknet-call` and are used by `to_openapi`'s
`/subscribe` endpoint, but `to_mcp` does not expose them — it filters by
`op_type` and only dispatches `Query`/`Mutation` via `invoke()`. See
ADR-041 §2.
#### `to_mcp` service behavior
@@ -263,19 +271,19 @@ axum route handlers) are genuinely per-gateway and are not shared.
Research findings
(`docs/research/alknet-http-gateway-factoring/findings.md`) recommend
extracting a **thin shared spine** (a concrete struct holding
`Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>` with a
`resolve + build_context + invoke` method returning a
`ResponseEnvelope`), **not** a `GatewayDispatch` trait or gateway
abstraction. The spine is small (~1530 lines per endpoint), but it is
the one place where a divergence bug (identity resolved differently,
`OperationContext.internal` set inconsistently, `CallError` mapped
asymmetrically) would be a security/correctness issue. The
server-integration and wire-framing layers stay per-gateway; a third
gateway (GraphQL, gRPC) is not on the horizon, and if one appears its
server-integration layer needs its own shape anyway. This is an
implementation factoring note, not an ADR — the decision is internal to
`alknet-http` and does not cross crate boundaries.
extracting a **thin shared spine** (the concrete `GatewayDispatch` struct
holding `Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>` with a
`resolve + build_context + invoke` method returning a `ResponseEnvelope`,
named in ADR-049 and extended with `invoke_streaming()` for the streaming
path), **not** a trait or gateway abstraction. The spine is small (~1530
lines per endpoint), but it is the one place where a divergence bug
(identity resolved differently, `OperationContext.internal` set
inconsistently, `CallError` mapped asymmetrically) would be a
security/correctness issue. The server-integration and wire-framing layers
stay per-gateway; a third gateway (GraphQL, gRPC) is not on the horizon,
and if one appears its server-integration layer needs its own shape anyway.
This is an implementation factoring note, not an ADR — the decision is
internal to `alknet-http` and does not cross crate boundaries.
### No-Env-Vars
@@ -340,6 +348,7 @@ every other HTTP request.
| Error fidelity | [ADR-023](../../decisions/023-operation-error-schemas.md) | MCP tool errors mapped to `ErrorDefinition`s |
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
| MCP clients are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Bearer token, no `PeerId` |
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_mcp` handlers are always `HandlerKind::Once` (MCP tools are request/response); `to_mcp` excludes `Subscription` ops (unchanged by the streaming handler) |
## Open Questions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-01
last_updated: 2026-07-02
---
# HTTP Server
@@ -194,13 +194,22 @@ The request body is `{ operation, input }` (the same flat JSON shape as
`Accept: text/event-stream` on the `POST`). The axum route handler:
- Sets `Content-Type: text/event-stream`.
- For each `call.responded` event, writes an SSE `data:` frame (the
event's `output` serialized as JSON).
- On `call.completed`, closes the SSE stream (normal end).
- On `call.aborted`, closes the stream with an SSE error event.
- On HTTP client disconnect (detected as the response writer closing),
sends `call.aborted` for the in-flight subscription, which cascades
to descendants per ADR-016.
- Calls `GatewayDispatch::invoke_streaming()` (ADR-049) — the streaming
analogue of `invoke()`, returning a `BoxStream<ResponseEnvelope>`. The
security invariants are identical to `invoke()`: `internal: false`,
`forwarded_for: None`, same capabilities, same `scoped_env`, same ACL
check before dispatch. The two methods diverge only on the return shape
(stream vs single envelope).
- For each `ResponseEnvelope` the stream yields, writes an SSE `data:` frame:
`Ok(value)``data:` frame with the output serialized as JSON; `Err`
SSE error event with the `CallError` serialized, then close (an `Err` is
terminal — the stream ends after it, matching the wire protocol's
`call.error` semantics).
- On natural stream end (the `StreamingHandler`'s stream completes), closes
the SSE stream (normal end — corresponds to `call.completed` on the wire).
- On `call.aborted` or HTTP client disconnect (detected as the response
writer closing), drops the stream future — `Drop` guards release the
handler's resources, and the abort cascade runs per ADR-016.
This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket
([websocket.md](websocket.md)), the subscription projects directly
@@ -209,6 +218,16 @@ no SSE framing. WebTransport (`h3`, deferred per ADR-044) would project
onto WebTransport bidirectional streams; see
[webtransport.md](webtransport.md).
**The streaming dispatch path.** Pre-ADR-049, `subscribe_handler` called
`GatewayDispatch::invoke()` (single response) and wrapped the one
`ResponseEnvelope` in a one-event SSE stream — a placeholder that couldn't
stream a real `Subscription` op. ADR-049 adds `GatewayDispatch::
invoke_streaming()` and the underlying `OperationRegistry::
invoke_streaming()`, giving `/subscribe` a real streaming dispatch path
to call. See ADR-049 and [http-adapters.md](http-adapters.md) for the
`from_openapi` SSE forwarding handler that feeds `StreamingHandler`s from
external `text/event-stream` responses.
### One-directional projection (HTTP request/response)
The HTTP/1.1 + HTTP/2 surface is a **lossy, one-directional projection**
@@ -446,6 +465,7 @@ two-way door (add/remove freely). See
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by ADR-044 §5) | Bearer token, no `PeerId`, connection-local overlay (addressability vs. bidirectionality) — full rationale in [websocket.md](websocket.md) |
| Error mapping (call codes → HTTP status) | [ADR-023](../../decisions/023-operation-error-schemas.md) | Protocol/operation codes distinct; `HTTP_<status>` prefix for imported |
| Custom HTTP routes from the assembly layer | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | `extra_routes: Option<Router>` at construction; raw HTTP, not operations; default surface takes precedence on collision |
| Streaming handler for subscriptions (`invoke_streaming()`) | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `GatewayDispatch::invoke_streaming()` returns `BoxStream<ResponseEnvelope>`; `/subscribe` pipes it to SSE; replaces the one-event placeholder with the real streaming dispatch path |
## Open Questions

View File

@@ -2,19 +2,23 @@
## Status
Accepted
Accepted (amended by ADR-049 — protocol-level code list extended to six)
## Context
The `OperationSpec` in alknet-call has `input_schema` and `output_schema` but
no `error_schemas`. The `call.error` payload (call-protocol.md L128134)
carries a `code` and `message`, where `code` is one of five infrastructure
codes: `NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`.
carries a `code` and `message`, where `code` is one of six infrastructure
codes: `NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`,
`INTERNAL`, `TIMEOUT`.
These five codes cover **protocol-level failures** — the call protocol
These six codes cover **protocol-level failures** — the call protocol
itself can always fail to find an operation, deny access, reject bad input,
time out, or hit an internal error. They are emitted by the dispatch
machinery (the registry, the adapter), not by operation handlers.
reject the wrong dispatch method for the operation type, time out, or hit
an internal error. They are emitted by the dispatch machinery (the registry,
the adapter), not by operation handlers. `INVALID_OPERATION_TYPE` was added
by ADR-049 (streaming handler for subscriptions — `invoke()` called on a
`Subscription`, or `invoke_streaming()` on a `Query`/`Mutation`).
But operations also have **domain-level failures** that are not covered:
@@ -164,8 +168,8 @@ optional-array convention.
```
- `code` — the error code. Either a protocol-level code (`NOT_FOUND`,
`FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`) or an
operation-level domain code from `error_schemas` (e.g.,
`FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`,
`TIMEOUT`) or an operation-level domain code from `error_schemas` (e.g.,
`FILE_NOT_FOUND`, `RATE_LIMITED`).
- `message` — human-readable error message. Unstructured — for logging and
debugging, not for programmatic handling. Clients should switch on
@@ -182,7 +186,7 @@ optional-array convention.
### 3. Protocol-level vs operation-level error codes
The five existing codes are **protocol-level** — emitted by the dispatch
The six existing codes are **protocol-level** — emitted by the dispatch
machinery, not by handlers:
| Code | Emitted by | Meaning |
@@ -190,6 +194,7 @@ machinery, not by handlers:
| `NOT_FOUND` | Registry | Operation not registered (or Internal op called from wire) |
| `FORBIDDEN` | Registry / ACL | Caller lacks required scopes, or unauthenticated |
| `INVALID_INPUT` | Registry | Input doesn't match `input_schema` |
| `INVALID_OPERATION_TYPE` | Registry / `OperationEnv` | Wrong dispatch path for the operation's type (`invoke()` on a `Subscription`, `invoke_streaming()` on a `Query`/`Mutation`, or `OperationEnv::invoke()` on a `Subscription` during composition — ADR-049) |
| `INTERNAL` | Registry / Adapter | Handler panic, unhandled error, connection failure |
| `TIMEOUT` | Adapter | Request timed out |
@@ -242,8 +247,9 @@ accordingly.
```
**Normative rule (review #002 W20)**: `from_openapi` must not produce error
codes that collide with the five protocol-level codes (`NOT_FOUND`,
`FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`). The adapter prefixes
codes that collide with the six protocol-level codes (`NOT_FOUND`,
`FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`,
`TIMEOUT`). The adapter prefixes
imported error codes with `HTTP_` and the status number (e.g., `HTTP_404`,
`HTTP_429`) to avoid collision. This is a requirement for the adapter, not
a naming convention — the `from_openapi` example above was previously shown
@@ -401,6 +407,9 @@ enum instead of a generic `Result<Output, string>`.
for OS-level permission issues)
- docs/reviews/001-pre-implementation-architecture-sanity-check.md
(finding C5, which this ADR resolves)
- ADR-049: Streaming handler for subscriptions (amends this ADR's
protocol-level code list — `INVALID_OPERATION_TYPE` added as the sixth
protocol-level code)
- docs/sdd_process.md L19, L423 (Safe Exit protocol — the general principle
of making failure typed and declared)
- TypeScript reference: `/workspace/@alkdev/operations/src/types.ts`

View File

@@ -0,0 +1,337 @@
# ADR-049: Streaming Handler for Subscription Operations
## Status
Accepted
## Context
The call protocol defines `Subscription` as a first-class operation type
(ADR-012 lists `subscribe` as one of four top-level protocol operations;
`OperationSpec.op_type` includes `Subscription`). The wire protocol supports
streaming: five event types (`call.requested`, `call.responded`,
`call.completed`, `call.aborted`, `call.error`), `PendingRequestMap::Subscribe`
with an mpsc channel, `CallConnection::subscribe()` returning
`impl Stream<Item = ResponseEnvelope>`, and a full streaming-subscribe example
in `call-protocol.md`. The **client side** works — a client can subscribe to a
remote stream and consume `call.responded` events until `call.completed`.
The **server side does not.** The `Handler` type in `alknet-call` is:
```rust
pub type Handler = Arc<
dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>
+ Send + Sync,
>;
```
It returns a single `ResponseEnvelope`. `OperationRegistry::invoke()` returns
one `ResponseEnvelope` and closes. `Dispatcher::handle_stream` calls
`dispatch_requested``registry.invoke()` → writes one `EventEnvelope` frame →
loops. A `Subscription` operation that should produce a *stream* of
`call.responded` events followed by `call.completed` has no way to express that
through this handler signature.
This is a **spec gap that should not have shipped.** The TypeScript predecessor
(`@alkdev/operations`, from which the Rust port was derived) had two distinct
handler types:
```typescript
type OperationHandler<I, O, C> = (input: I, context: C) => Promise<O> | O;
type SubscriptionHandler<I, O, C> = (input: I, context: C) => AsyncGenerator<O, void, unknown>;
```
The TS registry (`registry.ts:21`) stored them as a union, validated at
registration that `SUBSCRIPTION` ops get an `AsyncGeneratorFunction`, and the
server-side dispatch (`call.ts:341-349`, `buildCallHandler`) branched on
`op_type`: `SUBSCRIPTION` → iterate the async generator, `respond()` for each,
then `complete()`; else → `execute()`, single `respond()`. The Rust port
collapsed the union into a single `Handler` returning one `ResponseEnvelope`,
losing the streaming path. The fix is to restore it.
The downstream consequences of the gap:
- **`/subscribe` HTTP endpoint** (`GatewayDispatch::invoke()`
`subscribe_handler`) wraps a single `ResponseEnvelope` in a one-event SSE
stream. A real `Subscription` operation (e.g., `agent/chat` streaming LLM
tokens) cannot stream through it.
- **`from_call` forwarding** for a `Subscription` op calls
`CallConnection::call_with_payload()` (single response), not
`CallConnection::subscribe()` (stream). A `from_call`-imported subscription
truncates to the first value.
- **`from_openapi` forwarding** for a `text/event-stream` response returns one
`ResponseEnvelope` instead of streaming the SSE chunks.
All three are symptoms of the same root cause: the `Handler` type cannot
produce a stream.
## Decision
### 1. `StreamingHandler` type (alongside `Handler`)
Add a streaming handler type that returns a stream of `ResponseEnvelope`s,
mirroring the TS `SubscriptionHandler` / `OperationHandler` split:
```rust
pub type StreamingHandler = Arc<
dyn Fn(Value, OperationContext)
-> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
+ Send + Sync,
>;
```
Each `Ok(value)` in the stream becomes a `call.responded` event. An `Err`
becomes a `call.error` event (terminal — the stream ends). Natural stream end
becomes `call.completed`. The dispatch path converts each `ResponseEnvelope` to
`EventEnvelope` exactly as it does today for the single-response case — no new
wire-format concept is introduced.
A `make_streaming_handler()` helper (analogue of `make_handler()`) wraps an
async generator / stream-producing closure into a `StreamingHandler`.
### 2. `HandlerKind` enum on `HandlerRegistration`
```rust
pub enum HandlerKind {
Once(Handler),
Stream(StreamingHandler),
}
pub struct HandlerRegistration {
pub spec: OperationSpec,
pub handler: HandlerKind, // validated against spec.op_type at registration
pub provenance: OperationProvenance,
pub composition_authority: Option<CompositionAuthority>,
pub scoped_env: Option<ScopedPeerEnv>,
pub capabilities: Capabilities,
}
```
Registration validates: `Query` / `Mutation``HandlerKind::Once`;
`Subscription``HandlerKind::Stream`. Mismatch is a startup error (same as
the TS `validateSubscriptionHandler`). The enum makes the "one or the other,
matching `op_type`" invariant type-level rather than two `Option`s validated at
runtime.
### 3. `OperationRegistry::invoke_streaming()`
```rust
impl OperationRegistry {
/// Dispatch a Subscription operation. Returns a stream of
/// ResponseEnvelopes. Errors (not-found, forbidden, invalid operation
/// type) yield a single ResponseEnvelope::error and end the stream.
pub fn invoke_streaming(
&self,
name: &str,
input: Value,
context: OperationContext,
) -> BoxStream<ResponseEnvelope>;
}
```
`invoke_streaming()` performs the same visibility + ACL checks as `invoke()`,
then dispatches to the `StreamingHandler`. Pre-handler errors (not-found,
forbidden) produce a single error `ResponseEnvelope` and end the stream
(matching the single-response path's behavior, just on a stream).
### 4. `OperationRegistry::invoke()` errors on `Subscription`
`invoke()` is the request/response dispatch path. Calling it on a
`Subscription` op is a type mismatch — a streaming operation dispatched through
the request/response path. It returns a `ResponseEnvelope` carrying
`CallError { code: "INVALID_OPERATION_TYPE", ... }` (a new protocol-level
code):
```
INVALID_OPERATION_TYPE
```
(`retryable: false`, `details: None`). This is the wire-format addition: a
sixth protocol-level error code. It signals "you called the wrong dispatch
method for this operation's type" — distinct from `INVALID_INPUT` (schema
mismatch) and `INTERNAL` (handler failure). Clients should treat unknown codes
as `INTERNAL` with `retryable: false` (the existing rule); `INVALID_OPERATION_
TYPE` is a permanent client-side programming error, not a transient failure.
### 5. `OperationEnv::invoke()` errors on `Subscription`
`OperationEnv::invoke()` (composition) stays request/response-only. It returns
a single `ResponseEnvelope`. Calling it on a `Subscription` op produces the
same `INVALID_OPERATION_TYPE` error — composition cannot truncate a stream to
its first value. This is a clean architectural boundary, not a deferral:
- **`OperationEnv` composition** is "call a child operation, get a result"
(the `OperationHandler` model). It is request/response by construction.
- **Stream composition** (filter, map, combine, window, dedupe) is a
handler-level concern. A handler that produces a stream transforms it with
stream operators at the handler level, not through `OperationEnv`. The
`@alkdev/pubsub` `operators.ts` is the prior art for this model: 13 operators
(`filter`, `map`, `take`, `batch`, `dedupe`, `window`, `chain`, `join`, etc.)
that operate on `AsyncIterable<T>`, distinct from the request/response
composition. In Rust, the analogues operate on `BoxStream<T>`.
- No `invoke_streaming()` is added to `OperationEnv`. The protocol composition
surface is request/response; stream manipulation is handler-internal.
### 6. Server-side dispatch branches on `op_type`
`Dispatcher::handle_stream` / `dispatch_requested` gains a branch on
`op_type`:
- `Subscription``registry.invoke_streaming()` → for each `ResponseEnvelope`
in the stream, write `EventEnvelope` to the wire → write `call.completed` on
stream end.
- `Query` / `Mutation``registry.invoke()` → write one `EventEnvelope`
(existing path, unchanged).
The streaming branch sets `deadline: None` for subscriptions (unbounded —
already specced in `call-protocol.md` Timeouts) and wires abort cascade
(ADR-016): if `call.aborted` arrives for a streaming request, the stream is
dropped (Rust `Drop` releases the handler's resources).
### 7. `GatewayDispatch::invoke_streaming()` (alknet-http)
The shared dispatch spine gains a streaming variant:
```rust
impl GatewayDispatch {
pub async fn invoke_streaming(
&self,
identity: Option<Identity>,
op: &str,
input: Value,
) -> BoxStream<ResponseEnvelope>;
}
```
`invoke_streaming()` builds the root `OperationContext` identically to
`invoke()` (same security invariants: `internal: false`, `forwarded_for:
None`, same capabilities, same `scoped_env`), then calls
`registry.invoke_streaming()`. The two gateways (`to_openapi`, `to_mcp`)
diverge only on wire-framing; the security axis is provably identical between
`invoke()` and `invoke_streaming()`.
The HTTP `/subscribe` handler calls `invoke_streaming()` and pipes the
`BoxStream<ResponseEnvelope>` to SSE: each `Ok(value)` → SSE `data:` frame,
`Err` → SSE error event + close, stream end → close. This replaces the current
one-event `subscribe_stream_from_envelope` with the real streaming path.
### 8. `from_call` stream forwarding
The `from_call` forwarding handler construction branches on `op_type` during
discovery:
- `Query` / `Mutation` → existing `make_forwarding_handler()` (calls
`CallConnection::call_with_payload()`, returns single `ResponseEnvelope`),
registered as `HandlerKind::Once`.
- `Subscription` → new `make_streaming_forwarding_handler()` (calls
`CallConnection::subscribe()`, returns `impl Stream<Item =
ResponseEnvelope>`, maps to `BoxStream<ResponseEnvelope>`), registered as
`HandlerKind::Stream`.
A `from_call`-imported `Subscription` op forwards the remote stream end-to-end:
the client-side `CallConnection::subscribe()` (already working) feeds a
`StreamingHandler` that produces the stream. No truncation, no first-value
fallback.
### 9. `from_openapi` SSE forwarding
The `from_openapi` forwarding handler construction branches on `op_type`
(determined by `detectOperationType``text/event-stream` response →
`Subscription`):
- `Query` / `Mutation` → existing forwarding handler (single HTTP request →
single `ResponseEnvelope`), `HandlerKind::Once`.
- `Subscription` → streaming forwarding handler (HTTP request → SSE response
stream → parse SSE chunks → `BoxStream<ResponseEnvelope>`), `HandlerKind::
Stream`.
The SSE parsing reuses the TS `parseSSEFrames` pattern: each SSE `data:` frame
becomes a `ResponseEnvelope::ok()`, SSE stream end becomes stream end (→
`call.completed`).
## Consequences
**Positive:**
- `Subscription` operations work end-to-end: server-side handler →
server-side dispatch → wire → HTTP `/subscribe` SSE → `from_call`
forwarding → `from_openapi` SSE forwarding. No truncation, no broken paths.
- The `Handler` / `StreamingHandler` split mirrors the TS prior art exactly,
making the Rust port faithful to its source.
- `HandlerKind` makes the "one or the other, matching `op_type`" invariant
type-level (a `Once` variant for `Query`/`Mutation`, a `Stream` variant for
`Subscription`) rather than a runtime check on two `Option`s.
- Existing handlers (echo, discovery, from_openapi Query/Mutation, from_mcp,
from_call Query/Mutation) are unchanged — they return a single
`ResponseEnvelope` and register as `HandlerKind::Once`. The streaming path
is additive to the existing handler surface.
- `OperationEnv` composition stays request/response, preserving the
composition model's simplicity. Stream composition is a handler-level
concern, cleanly separated.
- The new `INVALID_OPERATION_TYPE` protocol code catches dispatch-path misuse
(calling `invoke()` on a `Subscription`) at the protocol level instead of
silently producing wrong behavior.
**Negative:**
- `HandlerRegistration.handler` changes type from `Handler` to `HandlerKind`.
Existing code constructing `HandlerRegistration` bundles must wrap in
`HandlerKind::Once(...)`. This is a mechanical change across handler
construction sites (the builder's `.with_local()` / `.with_leaf()` /
`.with()` methods absorb the wrapping internally, so most assembly-layer
code is unaffected; direct `HandlerRegistration::new()` calls need the
wrap).
- A new protocol-level error code (`INVALID_OPERATION_TYPE`) is a wire-format
addition. Existing clients that treat unknown codes as `INTERNAL` with
`retryable: false` (the existing rule) handle it correctly — they just
don't distinguish it from `INTERNAL` until updated. The code is distinct
from all existing codes and from operation-level domain codes (no
`HTTP_` prefix, no collision with the five existing protocol codes).
- The `Dispatcher::handle_stream` streaming branch adds a stream-to-wire
pump (read stream → write frames → write `call.completed`). This is new
code in the hot dispatch path, but it is a straightforward `while let
Some(envelope) = stream.next().await` loop, not a complex abstraction.
## Door type
**One-way.** The `Handler` / `StreamingHandler` / `HandlerKind` API surface
is what handlers are written against across crates (`alknet-call`,
`alknet-http`, downstream consumers). Changing it after handlers exist is a
rewrite. The `INVALID_OPERATION_TYPE` wire code is also one-way — once
emitted, clients may handle it, and removing it would break those handlers.
The `HandlerKind` enum shape (`Once(Handler) | Stream(StreamingHandler)`) is
the one-way commitment: two handler variants, validated against `op_type`.
The concrete `BoxStream` library choice (`futures::stream::BoxStream` vs a
custom type) is a two-way-door implementation detail within the one-way
decision.
## References
- ADR-012: Call Protocol Stream Model (defines `subscribe` as a top-level
protocol operation; the streaming path this ADR implements)
- ADR-017: Call Protocol Client and Adapter Contract (the adapter contract
this ADR extends with the `StreamingHandler` variant)
- ADR-022: Handler Registration, Provenance, and Composition Authority
(`HandlerRegistration` gains `HandlerKind`)
- ADR-015: Privilege Model and Authority Context (visibility/ACL checks run
identically in `invoke_streaming()` as in `invoke()`)
- ADR-016: Abort Cascade for Nested Calls (stream drop on abort; cascade
through streaming handlers)
- ADR-023: Operation Error Schemas (`INVALID_OPERATION_TYPE` is a
protocol-level code, distinct from operation-level domain codes)
- ADR-029: Peer-Graph Routing Model (`from_call` forwarding handlers gain the
streaming variant)
- ADR-009: One-Way Door Decision Framework (the `Handler` / `StreamingHandler`
split is a one-way door — handler API surface)
- `@alkdev/operations/src/types.ts:62-78` — TS prior art
(`OperationHandler` / `SubscriptionHandler` split)
- `@alkdev/operations/src/registry.ts:65-75` — TS prior art
(`validateSubscriptionHandler` — runtime validation against `op_type`)
- `@alkdev/operations/src/call.ts:341-349` — TS prior art (`buildCallHandler`
branches on `op_type`: `SUBSCRIPTION` → iterate + complete; else → execute)
- `@alkdev/pubsub/src/operators.ts` — stream operators prior art (filter,
map, batch, dedupe, window, chain, join — handler-level stream
composition, distinct from `OperationEnv` request/response composition)
- Spec documents amended: `operation-registry.md`, `call-protocol.md`,
`http-server.md`, `http-adapters.md`, `http-mcp.md`, `client-and-adapters.md`

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-30
last_updated: 2026-07-02
---
# Open Questions
@@ -316,7 +316,12 @@ These questions are acknowledged but not active. They will be promoted to open w
- **Status**: resolved
- **Door type**: One-way (wire format), two-way (mapping mechanism)
- **Priority**: high
- **Resolution**: `OperationSpec` gains `error_schemas: Vec<ErrorDefinition>` where each `ErrorDefinition` carries a `code`, `description`, `schema` (JSON Schema for the error detail payload), and optional `http_status` (for adapter projection). The `call.error` payload gains an optional `details` field carrying the typed error payload. Protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`) are distinct from operation-level domain codes (`FILE_NOT_FOUND`, `RATE_LIMITED`, etc.) — protocol codes are emitted by the dispatch machinery, operation codes by handlers. `from_openapi`/`to_openapi` map OpenAPI response status codes to/from `ErrorDefinition`s, making the adapter contract from ADR-017 faithful on the error axis. `services/schema` exposes `error_schemas` for client code generation. See ADR-023.
- **Resolution**: `OperationSpec` gains `error_schemas: Vec<ErrorDefinition>` where each `ErrorDefinition` carries a `code`, `description`, `schema` (JSON Schema for the error detail payload), and optional `http_status` (for adapter projection). The `call.error` payload gains an optional `details` field carrying the typed error payload. Protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`,
`INVALID_OPERATION_TYPE`, `INTERNAL`, `TIMEOUT`) are distinct from
operation-level domain codes (`FILE_NOT_FOUND`, `RATE_LIMITED`, etc.) —
protocol codes are emitted by the dispatch machinery, operation codes by
handlers. The six-code protocol-level list was extended from five by
ADR-049 (`INVALID_OPERATION_TYPE`). `from_openapi`/`to_openapi` map OpenAPI response status codes to/from `ErrorDefinition`s, making the adapter contract from ADR-017 faithful on the error axis. `services/schema` exposes `error_schemas` for client code generation. See ADR-023.
- **Cross-references**: ADR-017, ADR-023, docs/reviews/001-pre-implementation-architecture-sanity-check.md (C5), [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md)
## Theme: Call Client and Adapters
@@ -909,4 +914,44 @@ is a feature extension, not an unmade architecture decision.
system's structure, constraints, or API surface across crates.
- **Cross-references**: ADR-014, ADR-017, ADR-035,
[http-adapters.md](crates/http/http-adapters.md),
[http-mcp.md](crates/http/http-mcp.md)
[http-mcp.md](crates/http/http-mcp.md)
### OQ-41: Stream Operators Library
- **Origin**: [ADR-049](decisions/049-streaming-handler-for-subscriptions.md),
[operation-registry.md](crates/call/operation-registry.md) §"OperationEnv"
- **Status**: open (feature extension — a library to build, not a decision
to make before implementation)
- **Door type**: Two-way (additive utility library; no protocol or API-surface
change)
- **Priority**: low
- **Resolution**: ADR-049 establishes that stream composition (filter, map,
combine, window, dedupe) is a **handler-level concern**, not a protocol
composition concern. `OperationEnv::invoke()` is request/response-only;
stream manipulation happens at the handler level with stream operators on
the `BoxStream<ResponseEnvelope>` the handler itself produces. The
`@alkdev/pubsub` `operators.ts` is the prior art: 13 operators (`filter`,
`map`, `take`, `batch`, `dedupe`, `window`, `chain`, `join`, `reduce`,
`groupBy`, `flat`, `pipe`, `toArray`) that operate on `AsyncIterable<T>`,
forked from graphql-yoga's subscription implementation.
The Rust analogue — a stream-operators utility crate or module providing
the same set of operators on `BoxStream<T>` / `impl Stream<Item = T>` — is
a **feature extension**, not an unmade architectural decision. Handlers can
produce streams today without it (`Box::pin(stream::iter(...))`,
`async_stream::stream!`, `futures::stream` combinators all work); the
operators library is a convenience that reduces boilerplate for handlers
that transform streams (filter, batch, dedupe, window). No ADR is needed
for the library itself — it's internal utility code that doesn't cross
crate boundaries as a contract. An ADR would be warranted only if the
operators become part of a public API surface (e.g., a handler-registration
DSL that references operator names).
This OQ exists so the operators library is tracked and findable, not left
as inline hedging in the spec docs. It is not a deferral of a decision —
the architectural decision (stream composition is handler-level, not
protocol-level) is made in ADR-049. This tracks the *implementation* of
the utility library, which is scheduling work, not architecture work.
- **Cross-references**: ADR-049,
[operation-registry.md](crates/call/operation-registry.md) §"OperationEnv",
`/workspace/@alkdev/pubsub/src/operators.ts` (TS prior art)

View File

@@ -0,0 +1,172 @@
---
id: call/client/from-call-streaming-forwarding
name: Implement from_call streaming forwarding handler (Subscription → CallConnection::subscribe → StreamingHandler)
status: pending
depends_on: [call/registry/streaming-handler-handlerkind]
scope: narrow
risk: medium
impact: component
level: implementation
---
## Description
Branch `from_call`'s forwarding handler construction on `op_type` so that a
`Subscription` op discovered via `services/list` + `services/schema` registers a
`StreamingHandler` (`HandlerKind::Stream`) that calls
`CallConnection::subscribe()` and forwards the remote stream end-to-end.
`Query`/`Mutation` ops keep the existing `make_forwarding_handler()` (single
`call_with_payload()`, `HandlerKind::Once`). This closes the gap where a
`from_call`-imported `Subscription` truncated to the first value.
This task depends on `call/registry/streaming-handler-handlerkind` (which
introduces `HandlerKind::Stream` and `make_streaming_handler`). The
`CallConnection::subscribe()` client-side path already works (it returns
`impl Stream<Item = ResponseEnvelope>`); this task wires it into the forwarding
handler.
### The branch in build_bundles
`build_bundles` currently constructs one `make_forwarding_handler()` per
discovered op and wraps in `HandlerKind::Once`. Branch on
`op_summary.op_type` (parsed from `services/schema`):
- `Query` / `Mutation``make_forwarding_handler()` (existing), wrap in
`HandlerKind::Once`
- `Subscription``make_streaming_forwarding_handler()` (new), wrap in
`HandlerKind::Stream`
The `op_type` is already parsed in `rebuild_spec_for` (it reads `schema.op_type`
and produces `OperationType::Subscription`). The `OpSummary` needs to carry the
`op_type` (or the spec's `op_type` is read from the constructed `spec`). Read
`spec.op_type` after `rebuild_spec_for` to decide the handler kind.
### make_streaming_forwarding_handler
```rust
fn make_streaming_forwarding_handler(
connection: Arc<CallConnection>,
remote_name: String,
credentials_auth_token: Option<String>,
) -> StreamingHandler {
use crate::registry::registration::make_streaming_handler;
make_streaming_handler(move |input, context| {
let connection = Arc::clone(&connection);
let remote_name = remote_name.clone();
let auth_token = credentials_auth_token.clone();
// The streaming forwarding handler calls subscribe() and forwards the
// remote stream. forwarded_for is populated from context.identity
// (ADR-032), same as the request/response forwarding handler.
async move {
// Build the payload (same as build_forwarded_payload, but for subscribe)
let payload = build_forwarded_payload(&remote_name, input, &context, auth_token.as_deref());
// CallConnection::subscribe takes (operation_id, input); for the
// forwarded payload path, we need a subscribe_with_payload variant,
// OR we call subscribe(remote_name, input) and let it build the
// payload. The forwarded_for + auth_token need to be in the payload,
// so a subscribe_with_payload variant is needed (mirrors
// call_with_payload). Check if CallConnection::subscribe can accept
// a full payload — if not, add subscribe_with_payload().
let stream = connection.subscribe_with_payload(payload).await;
// Map the impl Stream<Item=ResponseEnvelope> to BoxStream<ResponseEnvelope>
Box::pin(stream) as ResponseStream
}
})
}
```
**Coordinate with `CallConnection::subscribe`**: the existing
`subscribe(operation_id, input)` builds the payload internally and does NOT
populate `forwarded_for` or `auth_token`. The forwarding handler needs those
fields (ADR-032). Two options:
1. Add `CallConnection::subscribe_with_payload(payload: Value)` (mirrors
`call_with_payload`) that takes a caller-constructed payload. The forwarding
handler builds the payload with `build_forwarded_payload` and calls
`subscribe_with_payload`.
2. Extend `subscribe()` to accept optional `forwarded_for` / `auth_token`.
Option 1 mirrors the existing `call` / `call_with_payload` split and is cleaner.
Add `subscribe_with_payload()` alongside `subscribe()`.
### forwarded_for on the streaming payload
The streaming forwarding handler populates `forwarded_for` from
`context.identity` exactly as the request/response forwarding handler does
(ADR-032 §3) — reuse `build_forwarded_payload()`. The `auth_token` (hub's own
call-protocol token) is also populated identically. No new payload-construction
code; reuse the existing `build_forwarded_payload`.
### Abort cascade (ADR-016 §6)
The streaming forwarding handler's `parent_request_id` participates in the
abort cascade: if the parent is aborted, the cascade reaches this handler,
which sends `call.aborted` to the remote node; the remote node cascades to its
own descendants. Cross-node abort is transparent. The `subscribe_with_payload`
path registers the request in `PendingRequestMap` (the existing `subscribe()`
does this); abort handling is already wired. Verify the streaming forwarding
handler's stream is dropped on parent abort (the `SubscriptionStream`'s `Drop`
or the pending entry's removal handles it).
### What this task does NOT do
- **No `OperationEnv::invoke_streaming()`.** Composition is request/response-only.
- **No server-side dispatch changes.** The server-side streaming branch is
`call/protocol/dispatch-streaming-branch`.
- **No gateway changes.** The gateway streaming path is `http/gateway/invoke-streaming`.
## Acceptance Criteria
- [ ] `build_bundles` branches on `spec.op_type`: `Subscription` → streaming
forwarding handler (`HandlerKind::Stream`), `Query`/`Mutation` → existing
`HandlerKind::Once`
- [ ] `make_streaming_forwarding_handler()` constructs a `StreamingHandler`
- [ ] Streaming forwarding handler calls `CallConnection::subscribe_with_payload()`
(or `subscribe()` with the forwarded payload) and forwards the remote stream
- [ ] `CallConnection::subscribe_with_payload(payload)` exists (mirrors
`call_with_payload`) OR `subscribe()` accepts the forwarded payload
- [ ] `forwarded_for` populated from `context.identity` (ADR-032) on the
streaming payload (reuse `build_forwarded_payload`)
- [ ] `auth_token` populated when present (reuse `build_forwarded_payload`)
- [ ] Remote stream forwarded end-to-end: each `call.responded` → stream item,
`call.completed` → stream end, `call.aborted` → stream dropped
- [ ] No truncation, no first-value fallback
- [ ] `composition_authority: None`, `scoped_env: None` for FromCall streaming
leaves (same as Query/Mutation FromCall leaves)
- [ ] Unit test: `build_bundles` with a `Subscription` op produces a
`HandlerKind::Stream` registration
- [ ] Unit test: `build_bundles` with a `Query` op produces `HandlerKind::Once`
(unchanged)
- [ ] Unit test: `make_streaming_forwarding_handler` produces a `StreamingHandler`
that calls `subscribe_with_payload` (verify via payload capture, mirroring
the existing `forwarding_handler_populates_forwarded_for` test)
- [ ] Integration test: subscription forwarding streams remote events (if
feasible with mock connection; otherwise document the integration test as
deferred to a connection-level integration test)
- [ ] `cargo test -p alknet-call` succeeds
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
- [ ] `cargo fmt --check -p alknet-call` passes
## References
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §8 (from_call stream forwarding)
- docs/architecture/crates/call/client-and-adapters.md — §from_call (handler branched on op_type; Subscription → StreamingHandler via subscribe())
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §3 (from_call flow), §6 (cross-node abort)
- docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032 §3 (forwarded_for population)
## Notes
> The client-side `CallConnection::subscribe()` already works — this task wires
> it into the forwarding handler. The main new piece is
> `subscribe_with_payload()` (mirroring `call_with_payload`) so the forwarding
> handler can populate `forwarded_for` + `auth_token`. Reuse
> `build_forwarded_payload` — no new payload-construction code. The abort
> cascade is already wired via `PendingRequestMap`; verify the stream drops on
> parent abort. The `OpSummary` / `build_bundles` needs the `op_type` to
> branch — read it from the constructed `spec.op_type` (already parsed by
> `rebuild_spec_for`). Cross-node abort is transparent via
> `parent_request_id` (ADR-016 §6).
## Summary
> To be filled on completion

View File

@@ -0,0 +1,174 @@
---
id: call/protocol/dispatch-streaming-branch
name: Wire Dispatcher::handle_stream streaming branch (Subscription → invoke_streaming → write each → call.completed)
status: pending
depends_on: [call/registry/invoke-streaming]
scope: narrow
risk: medium
impact: component
level: implementation
---
## Description
Wire the server-side streaming dispatch branch in
`Dispatcher::handle_stream` / `dispatch_requested`. When a `call.requested`
arrives for a `Subscription` op, the dispatcher must call
`OperationRegistry::invoke_streaming()` and pump the resulting
`ResponseStream` to the wire: each `Ok(value)``call.responded` frame,
`Err``call.error` frame (terminal), natural stream end → `call.completed`
frame. This is the server-side path that makes `Subscription` operations work
end-to-end — without it, a `StreamingHandler`-registered op had no server-side
dispatch path.
This task depends on `call/registry/invoke-streaming` (which provides
`invoke_streaming()`). It adds the `op_type` branch to the dispatch path and
the stream-to-wire pump.
### The branch
`dispatch_requested` currently unconditionally calls `registry.invoke()` and
returns one `ResponseEnvelope`. It needs to know the `op_type` to branch. Two
options:
1. **Look up the registration to get `op_type` before dispatching.** The
`build_root_context` already looks up the registration; expose `op_type`
from it. Then branch: `Subscription` → streaming path, `Query`/`Mutation`
existing `invoke()` path.
2. **Return an enum from `dispatch_requested`** (`DispatchResult::Once(ResponseEnvelope)`
| `DispatchResult::Stream(ResponseStream)`) and let `handle_stream` match on
it for the wire-writing loop.
Pick the cleaner option. Option 1 keeps `dispatch_requested` returning a
`ResponseEnvelope` for the Once path but needs a separate streaming entry point
(e.g., `dispatch_requested_streaming` returning `ResponseStream`). Option 2
unifies the dispatch entry but changes the return type. The spec frames it as
"branches on `op_type`" in `handle_stream`, suggesting the branch lives in the
dispatch layer. Document the choice.
### Streaming dispatch path
For a `Subscription` op:
```rust
// In dispatch_requested (or a new dispatch_requested_streaming):
let context = self.build_root_context(...);
// deadline: None for subscriptions (unbounded — ADR-049 §6, call-protocol Timeouts)
// The build_root_context sets a 30s deadline; for the streaming path, set
// deadline to None AFTER construction (or pass a flag). The spec says
// "deadline: None for subscriptions (unbounded)".
let stream = self.registry.invoke_streaming(&operation_name, input, context);
stream // ResponseStream — pumped by handle_stream
```
### handle_stream streaming pump
In `handle_stream`, after dispatching, if the result is a stream:
```rust
// Read the ResponseStream, write each envelope as an EventEnvelope frame
let mut stream = tokio_stream_into_response_stream(...); // or use StreamExt
while let Some(envelope) = stream.next().await {
let event: EventEnvelope = envelope.into();
if let Err(err) = writer.write_frame(&event).await {
warn!(error = %err, "failed to write streaming frame; closing stream");
break;
}
// If the envelope was an error (Err result), the stream ends after it
// (the StreamingHandler's contract: Err is terminal). The stream's own
// end (None) triggers call.completed below.
}
// Natural stream end → write call.completed
let completed = EventEnvelope::completed(&request_id);
if let Err(err) = writer.write_frame(&completed).await {
warn!(error = %err, "failed to write call.completed");
}
```
The `ResponseEnvelope → EventEnvelope` conversion (`into()`) already exists and
produces `call.responded` for `Ok` and `call.error` for `Err`. The
`call.completed` frame is written once when the stream ends naturally (not on
error — an `Err` envelope is terminal, the stream ends after it, and we do NOT
write `call.completed` after a `call.error`; the stream's `None` after an error
is not a "natural end"). Track whether the last envelope was an error to decide
whether to write `call.completed`. Alternatively, the `StreamingHandler`'s
contract is: `Err` ends the stream (the handler's stream yields the error then
`None`), so after the loop, only write `call.completed` if the stream did not
end on an error. Simplest correct approach: write `call.completed` only on
natural end (the stream returned `None` without the last item being an `Err`).
Track a `last_was_error` flag.
### deadline: None for subscriptions
`build_root_context` sets `deadline: Some(now + 30s)`. For the streaming path,
the spec says `deadline: None` (unbounded — subscriptions are long-running). Set
`context.deadline = None` after `build_root_context` for the streaming branch,
or add a parameter to `build_root_context`. The deadline bounds the
request/response call tree; a subscription has no such bound. Document this.
### Abort cascade (ADR-016)
If `call.aborted` arrives for a streaming request ID, the stream is dropped
(Rust `Drop` releases the handler's resources). The existing `handle_abort`
path already removes the pending entry and cascades. For the streaming branch,
the stream future being dropped (when the `handle_stream` task is cancelled or
the `call.aborted` is processed) releases the handler's resources via `Drop`.
No new abort code is needed — the existing `handle_abort` + the stream's `Drop`
handle it. Verify the streaming pump task is cancellable (it's a `tokio::spawn`
task; aborting the connection cancels it).
### What this task does NOT do
- **No client-side changes.** The client `CallConnection::subscribe()` already
works (it reads `call.responded` events until `call.completed`). This task is
server-side only.
- **No gateway changes.** `GatewayDispatch::invoke_streaming` is
`http/gateway/invoke-streaming`.
## Acceptance Criteria
- [ ] `dispatch_requested` (or a new `dispatch_requested_streaming`) branches on
`op_type`: `Subscription``invoke_streaming()`, `Query`/`Mutation`
`invoke()` (existing)
- [ ] `handle_stream` pumps the `ResponseStream` for the streaming branch:
each `ResponseEnvelope``EventEnvelope` frame
- [ ] Natural stream end → `call.completed` frame written
- [ ] `Err` envelope → `call.error` frame written, stream ends after it (no
`call.completed` after an error)
- [ ] `deadline: None` for the streaming branch (unbounded subscriptions)
- [ ] Abort: `call.aborted` for a streaming request drops the stream (Drop
releases resources; existing `handle_abort` handles the pending entry)
- [ ] Existing `Query`/`Mutation` dispatch path unchanged (one
`call.responded`/`call.error` frame, no `call.completed`)
- [ ] Unit test: `Subscription` op dispatch → multiple `call.responded` frames
+ `call.completed` on stream end
- [ ] Unit test: `Subscription` op handler yields `Err` → one `call.error`
frame, no `call.completed` after
- [ ] Unit test: `Query` op dispatch unchanged (one frame, no `call.completed`)
- [ ] Unit test: `call.aborted` for streaming request → stream dropped
- [ ] `cargo test -p alknet-call` succeeds
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
- [ ] `cargo fmt --check -p alknet-call` passes
## References
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §6 (server-side dispatch branches on op_type)
- docs/architecture/crates/call/call-protocol.md — §CallAdapter Stream Handling (streaming branch: invoke_streaming → write each → call.completed; deadline: None; abort cascade)
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (stream drop on abort)
## Notes
> The streaming pump is a straightforward `while let Some(envelope) = stream.next().await`
> loop — not a complex abstraction. The tricky part is the `call.completed`
> semantics: write it on natural stream end, NOT after an `Err` (which is
> terminal). Track whether the last envelope was an error. `deadline: None` for
> subscriptions is a spec requirement — the 30s request/response deadline does
> not bound a long-running subscription. The abort cascade needs no new code:
> dropping the stream future (via task cancellation or `handle_abort`) releases
> the handler's resources through Rust's `Drop`. Pick the dispatch-entry shape
> (separate streaming method vs unified enum return) and document it — the spec
> frames it as a branch in `handle_stream`, so the branch should be visible there.
## Summary
> To be filled on completion

View File

@@ -0,0 +1,170 @@
---
id: call/registry/invoke-streaming
name: Implement OperationRegistry::invoke_streaming() returning ResponseStream
status: pending
depends_on: [call/registry/streaming-handler-handlerkind]
scope: narrow
risk: medium
impact: component
level: implementation
---
## Description
Add `OperationRegistry::invoke_streaming()` — the streaming dispatch path that
`Subscription` operations use. This is the counterpart to `invoke()`: same
visibility + ACL checks, then dispatches to the `StreamingHandler` and returns
the `ResponseStream`. Pre-handler errors (not-found, forbidden,
`INVALID_OPERATION_TYPE` for a non-Subscription op) yield a single error
`ResponseEnvelope` and end the stream.
This task depends on `call/registry/streaming-handler-handlerkind` (which
introduces `HandlerKind::Stream` and the `ResponseStream` alias). It adds only
the `invoke_streaming()` method — no other changes.
### invoke_streaming()
```rust
use futures::stream::{self, StreamExt};
impl OperationRegistry {
/// Dispatch a Subscription operation. Returns a stream of
/// ResponseEnvelopes. Pre-handler errors (not-found, forbidden,
/// INVALID_OPERATION_TYPE for a non-Subscription op) yield a single
/// error ResponseEnvelope and end the stream.
pub fn invoke_streaming(
&self,
name: &str,
input: Value,
context: OperationContext,
) -> ResponseStream {
let request_id = context.request_id.clone();
// 1. Look up registration
let registration = match self.operations.get(name) {
Some(r) => r,
None => {
return Box::pin(stream::once(async move {
ResponseEnvelope::not_found(request_id, name)
}));
}
};
// 2. Visibility check (same as invoke)
if registration.spec.visibility == Visibility::Internal && !context.internal {
return Box::pin(stream::once(async move {
ResponseEnvelope::not_found(request_id, name)
}));
}
// 3. ACL check (same as invoke)
let acl = &registration.spec.access_control;
let identity = if context.internal {
context.handler_identity.as_ref().and_then(|ca| ca.as_identity())
} else {
context.identity.clone()
};
if let AccessResult::Forbidden(message) = acl.check(identity.as_ref()) {
return Box::pin(stream::once(async move {
ResponseEnvelope::forbidden(request_id, message)
}));
}
// 4. HandlerKind check — must be Stream for invoke_streaming
let streaming_handler = match &registration.handler {
HandlerKind::Stream(h) => Arc::clone(h),
HandlerKind::Once(_) => {
return Box::pin(stream::once(async move {
ResponseEnvelope::error(
request_id,
CallError::invalid_operation_type(
"invoke_streaming() called on a Query/Mutation op; use invoke()"
),
)
}));
}
};
// 5. Dispatch — the handler returns the stream
streaming_handler(input, context)
}
}
```
The visibility + ACL checks are **identical** to `invoke()` — extract them into
a private helper if it reduces duplication, but the spec requires the security
axis to be provably identical between `invoke()` and `invoke_streaming()`. The
two methods diverge only on the return shape (single envelope vs stream) and
the handler-kind guard (Once vs Stream).
### Pre-handler errors as single-item streams
A pre-handler error (not-found, forbidden, wrong kind) produces a
`ResponseStream` that yields exactly one error `ResponseEnvelope` and then ends.
This matches the single-response path's behavior, just on a stream — the caller
(`Dispatcher::handle_stream` streaming branch, `GatewayDispatch::invoke_streaming`)
drains the stream and writes frames; a one-item error stream produces one
`call.error` frame and closes.
Use `futures::stream::once(async move { ... })` to build these single-item
streams. The error envelope carries the `request_id` from the context.
### What this task does NOT do
- **No `OperationEnv::invoke_streaming()`.** Composition stays
request/response-only (ADR-049). `OperationEnv::invoke()` errors on
`Subscription` (handled in `streaming-handler-handlerkind` via the
`HandlerKind::Stream` match in `LocalOperationEnv``registry.invoke()` and
`OverlayOperationEnv` direct match). No streaming variant is added to the
trait.
- **No dispatch-loop wiring.** `Dispatcher::handle_stream` streaming branch is
`call/protocol/dispatch-streaming-branch`.
- **No gateway wiring.** `GatewayDispatch::invoke_streaming` is
`http/gateway/invoke-streaming`.
## Acceptance Criteria
- [ ] `OperationRegistry::invoke_streaming()` method exists
- [ ] Returns `ResponseStream` (`Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>`)
- [ ] Not-found op → single-item stream with `NOT_FOUND` error envelope, then ends
- [ ] Internal op from external call → single-item stream with `NOT_FOUND`, then ends
- [ ] ACL denied → single-item stream with `FORBIDDEN`, then ends
- [ ] `HandlerKind::Once` op (Query/Mutation) → single-item stream with
`INVALID_OPERATION_TYPE`, then ends
- [ ] `HandlerKind::Stream` op (Subscription) → dispatches the `StreamingHandler`,
returns its stream
- [ ] Visibility + ACL checks identical to `invoke()` (same authority switch:
internal → handler_identity, external → identity)
- [ ] Unit test: `invoke_streaming()` on a registered `Subscription` op yields
the handler's stream items
- [ ] Unit test: `invoke_streaming()` on unknown op yields one `NOT_FOUND` then ends
- [ ] Unit test: `invoke_streaming()` on a `Query` op yields one
`INVALID_OPERATION_TYPE` then ends
- [ ] Unit test: `invoke_streaming()` on Internal op from external context yields
one `NOT_FOUND` then ends
- [ ] Unit test: `invoke_streaming()` ACL denied yields one `FORBIDDEN` then ends
- [ ] Unit test: `invoke_streaming()` internal call uses handler_identity for ACL
- [ ] `cargo test -p alknet-call` succeeds
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
- [ ] `cargo fmt --check -p alknet-call` passes
## References
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §3 (invoke_streaming), §4 (invoke errors on Subscription), §5 (OperationEnv request/response-only)
- docs/architecture/crates/call/operation-registry.md — §OperationRegistry (invoke_streaming signature, pre-handler errors as single-item streams)
## Notes
> The visibility + ACL checks MUST be identical to `invoke()` — the spec calls
> this out explicitly: "invoke_streaming() performs the same visibility + ACL
> checks as invoke()". Extract a shared helper if it helps, but the security
> axis must be provably identical. Pre-handler errors become single-item streams
> (one error envelope, then end) — this matches the single-response path's
> behavior, just on a stream. Do NOT add `OperationEnv::invoke_streaming()` —
> composition is request/response-only by design (ADR-049 §5); stream
> composition is a handler-level concern. The `futures` crate's `stream::once`
> and `StreamExt` are the tools for building single-item streams.
## Summary
> To be filled on completion

View File

@@ -0,0 +1,256 @@
---
id: call/registry/streaming-handler-handlerkind
name: Introduce StreamingHandler, HandlerKind, ResponseStream types and migrate HandlerRegistration to HandlerKind
status: completed
depends_on: []
scope: broad
risk: medium
impact: component
level: implementation
---
## Description
ADR-049 restores the streaming handler path that the Rust port dropped when it
collapsed the TS `OperationHandler` / `SubscriptionHandler` union into a single
`Handler`. This task introduces the new types (`StreamingHandler`, `HandlerKind`,
`ResponseStream`, `make_streaming_handler`), adds the `INVALID_OPERATION_TYPE`
protocol error code, changes `HandlerRegistration.handler` from `Handler` to
`HandlerKind`, updates the builder to absorb the wrapping, adds registration-time
validation, updates `invoke()` to error on `Stream`, updates the overlay env to
match on `HandlerKind`, and migrates **every existing construction site** to wrap
in `HandlerKind::Once`.
This is the foundational breaking change — all downstream streaming tasks depend
on it. It is broad in surface area (touches `registration.rs`, `wire.rs`,
`connection.rs`, and every test/adapter that constructs a `HandlerRegistration`)
but each individual change is mechanical. The goal: after this task, the codebase
compiles with two handler kinds, `Query`/`Mutation` ops work exactly as before
(wrapped in `HandlerKind::Once`), and `Subscription` ops are rejected by `invoke()`
with `INVALID_OPERATION_TYPE` (the streaming dispatch path `invoke_streaming()`
is added in `call/registry/invoke-streaming`).
### New types (registration.rs)
```rust
use futures::stream::Stream;
/// Streaming handler — Subscription operations. Returns a stream of
/// ResponseEnvelopes: each Ok(value) → call.responded, an Err → call.error
/// (terminal — stream ends), natural stream end → call.completed.
pub type StreamingHandler = Arc<
dyn Fn(Value, OperationContext)
-> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
+ Send + Sync,
>;
/// Type alias for the boxed stream shape used by `invoke_streaming()` and
/// `StreamingHandler` return values. `futures::stream::BoxStream<'static, T>`
/// = `Pin<Box<dyn Stream<Item = T> + Send>>` — the concrete library is a
/// two-way-door implementation detail (ADR-049); the alias exists so the two
/// spellings refer to the same type.
pub type ResponseStream = Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>;
/// Which dispatch path a handler uses — locked by ADR-049. Validated against
/// `spec.op_type` at registration: Query/Mutation → Once; Subscription → Stream.
/// Mismatch is a startup error.
pub enum HandlerKind {
Once(Handler),
Stream(StreamingHandler),
}
```
`make_streaming_handler()` helper (analogue of `make_handler()`):
```rust
pub fn make_streaming_handler<S, St>(f: S) -> StreamingHandler
where
S: Fn(Value, OperationContext) -> St + Send + Sync + 'static,
St: Stream<Item = ResponseEnvelope> + Send + 'static,
{
Arc::new(move |input, context| Box::pin(f(input, context)))
}
```
### INVALID_OPERATION_TYPE error code (wire.rs)
Add the sixth protocol-level error code to `CallError`:
```rust
pub fn invalid_operation_type(message: impl Into<String>) -> Self {
Self::new("INVALID_OPERATION_TYPE", message, false)
}
```
`retryable: false`, `details: None`. This is a permanent client-side programming
error (wrong dispatch path for the operation's type), not a transient failure.
Clients should treat unknown codes as `INTERNAL` with `retryable: false` (the
existing rule); `INVALID_OPERATION_TYPE` is distinct from `INVALID_INPUT` (schema
mismatch) and `INTERNAL` (handler failure).
### HandlerRegistration.handler → HandlerKind
```rust
pub struct HandlerRegistration {
pub spec: OperationSpec,
pub handler: HandlerKind, // was: Handler
pub provenance: OperationProvenance,
pub composition_authority: Option<CompositionAuthority>,
pub scoped_env: Option<ScopedPeerEnv>,
pub capabilities: Capabilities,
}
```
`HandlerRegistration::new()` takes `handler: HandlerKind` (callers wrap in
`HandlerKind::Once(...)` or `HandlerKind::Stream(...)`).
### Builder absorbs HandlerKind wrapping
The builder inspects `spec.op_type` and wraps automatically — `.with_local()`
and `.with_leaf()` / `.with_leaf_provenance()` take the raw `Handler` (for
Query/Mutation) and wrap it in `HandlerKind::Once`. For Subscription ops, add a
parallel method pair (`.with_local_streaming()` / `.with_leaf_streaming()`) that
takes a `StreamingHandler` and wraps in `HandlerKind::Stream`. The builder
validates `handler` kind matches `spec.op_type` and reports mismatch as a
startup error.
The two-method-pair approach is preferred over a typed enum input because it
keeps the common case (Query/Mutation, `Handler`) on the existing signatures
and makes the streaming case explicit at the call site. Document this choice.
### register() validation
`OperationRegistry::register()` validates that `handler` is the right
`HandlerKind` for `spec.op_type`:
- `Query` / `Mutation``HandlerKind::Once`
- `Subscription``HandlerKind::Stream`
Mismatch is a startup error. Change `register()` to return `Result<(), String>`
(preferred — startup errors should be explicit, not panics) with a clear message
(`"handler kind mismatch: {op_type} requires HandlerKind::{Once|Stream}"`).
Update all `register()` call sites to handle the Result (the builder's `store()`
and tests). Alternatively panic with a clear message — but `Result` is cleaner
for a startup error and matches the `AdapterError` pattern used elsewhere.
### invoke() errors on Stream
`OperationRegistry::invoke()` matches on `registration.handler`:
- `HandlerKind::Once(handler)` → existing dispatch path (unchanged)
- `HandlerKind::Stream(_)` → return `ResponseEnvelope::error(request_id,
CallError::invalid_operation_type("invoke() called on a Subscription op;
use invoke_streaming()"))`
This is the guard that prevents a streaming op from being silently truncated
through the request/response path. The `invoke_streaming()` method itself is
added in `call/registry/invoke-streaming`.
### OverlayOperationEnv (connection.rs)
`OverlayOperationEnv::invoke_with_policy` dispatches directly (it does NOT call
`registry.invoke()` — it reads the handler from the overlay and calls it). After
the type change, `registration.handler` is `HandlerKind`, so the env must match:
- `HandlerKind::Once(handler)` → `handler(input, context).await` (existing path)
- `HandlerKind::Stream(_)` → return `ResponseEnvelope::error(parent.request_id,
CallError::invalid_operation_type("OperationEnv::invoke() called on a
Subscription op; composition is request/response-only"))`
`LocalOperationEnv` calls `self.registry.invoke()` which already errors on
`Stream` — no change needed there. `PeerCompositeEnv` delegates to
session/connection/base envs — no change needed there either.
### Migration of existing construction sites
Every site that constructs `HandlerRegistration::new(spec, handler, ...)` must
wrap `handler` in `HandlerKind::Once(handler)`. This is mechanical. Sites
include (non-exhaustive — find them all with a grep for `HandlerRegistration::new`):
- `crates/alknet-call/src/registry/registration.rs` (tests)
- `crates/alknet-call/src/registry/env.rs` (tests)
- `crates/alknet-call/src/registry/discovery.rs` (`services_list_handler`,
`services_schema_handler` construction — these are `Query` ops)
- `crates/alknet-call/src/protocol/dispatch.rs` (tests)
- `crates/alknet-call/src/protocol/connection.rs` (tests,
`imported_registration` helper)
- `crates/alknet-call/src/client/from_call.rs` (`build_bundles`,
`make_forwarding_handler`, tests)
- `crates/alknet-http/src/gateway/dispatch.rs` (tests)
- `crates/alknet-http/src/server/gateway_routes.rs` (tests)
- `crates/alknet-http/src/adapters/from_openapi.rs` (`build_registration`)
- `crates/alknet-http/src/adapters/from_mcp/mod.rs` (`build_registration`)
The builder sites (`with_local`, `with_leaf`, `with_leaf_provenance`, `with`)
are updated by the builder-absorbs-wrapping change above — their callers pass
raw `Handler` and the builder wraps. Direct `HandlerRegistration::new()` calls
need the explicit `HandlerKind::Once(...)` wrap.
## Acceptance Criteria
- [ ] `StreamingHandler` type alias in `registration.rs`
- [ ] `ResponseStream` type alias (`Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>`)
- [ ] `HandlerKind` enum with `Once(Handler)` and `Stream(StreamingHandler)` variants
- [ ] `make_streaming_handler()` helper compiles and works
- [ ] `CallError::invalid_operation_type()` constructor in `wire.rs`
- [ ] `HandlerRegistration.handler` field is `HandlerKind` (not `Handler`)
- [ ] `HandlerRegistration::new()` takes `HandlerKind`
- [ ] Builder `with_local` / `with_leaf` / `with_leaf_provenance` wrap `Handler` in
`HandlerKind::Once` for Query/Mutation
- [ ] Builder `with_local_streaming` / `with_leaf_streaming` wrap `StreamingHandler`
in `HandlerKind::Stream` for Subscription
- [ ] Builder validates `handler` kind matches `spec.op_type` — mismatch is a
startup error
- [ ] `OperationRegistry::register()` validates `HandlerKind` matches `op_type`
(returns `Result<(), String>` or panics with clear message)
- [ ] `OperationRegistry::invoke()` dispatches `HandlerKind::Once` (existing path)
- [ ] `OperationRegistry::invoke()` returns `INVALID_OPERATION_TYPE` for
`HandlerKind::Stream`
- [ ] `OverlayOperationEnv::invoke_with_policy` matches on `HandlerKind`:
`Once` → dispatch, `Stream` → `INVALID_OPERATION_TYPE`
- [ ] `LocalOperationEnv` propagates `INVALID_OPERATION_TYPE` via `registry.invoke()`
(no code change needed — verify)
- [ ] All existing `HandlerRegistration::new()` call sites wrap in
`HandlerKind::Once(...)`
- [ ] All existing builder call sites compile (builder absorbs wrapping)
- [ ] Unit test: `invoke()` on a `Subscription` op (registered with
`HandlerKind::Stream`) returns `INVALID_OPERATION_TYPE`
- [ ] Unit test: `invoke()` on a `Query` op (registered with `HandlerKind::Once`)
dispatches normally
- [ ] Unit test: `register()` rejects `HandlerKind::Once` for a `Subscription` spec
- [ ] Unit test: `register()` rejects `HandlerKind::Stream` for a `Query` spec
- [ ] Unit test: `OverlayOperationEnv::invoke()` on a `Stream`-kind overlay op
returns `INVALID_OPERATION_TYPE`
- [ ] Unit test: `make_streaming_handler` produces a working `StreamingHandler`
- [ ] `cargo test -p alknet-call` succeeds
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
- [ ] `cargo fmt --check -p alknet-call -p alknet-http` passes
## References
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 (the decision)
- docs/architecture/crates/call/operation-registry.md — §Handler (StreamingHandler, HandlerKind, ResponseStream, make_streaming_handler), §OperationRegistry (register validation, invoke errors on Stream), §HandlerRegistration
- docs/architecture/crates/call/call-protocol.md — §call.error Payload (INVALID_OPERATION_TYPE protocol code)
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (amended: six protocol codes)
## Notes
> This is the foundational breaking change. The `HandlerRegistration.handler`
> type flip from `Handler` to `HandlerKind` ripples to every construction site,
> but each change is mechanical (`Handler` → `HandlerKind::Once(handler)`). The
> builder absorbs the wrapping for the common case. The load-bearing parts are:
> (1) `register()` validation catches kind/op_type mismatch at startup, (2)
> `invoke()` errors on `Stream` (the guard that prevents silent truncation), (3)
> `OverlayOperationEnv` matches on `HandlerKind` (it dispatches directly, not via
> `registry.invoke()`). `LocalOperationEnv` needs no change — it delegates to
> `registry.invoke()` which handles it. Do NOT add `invoke_streaming()` in this
> task — that's `call/registry/invoke-streaming`. The `futures` crate is already
> a dependency of `alknet-call`. The two-method-pair builder API
> (`with_local`/`with_local_streaming`) is preferred over a typed enum input —
> it keeps the common case on existing signatures and makes streaming explicit.
## Summary
> Introduced StreamingHandler/ResponseStream type aliases and HandlerKind enum (Once|Stream) + make_streaming_handler() helper in registration.rs; added CallError::invalid_operation_type() (sixth protocol code, retryable: false) in wire.rs; flipped HandlerRegistration.handler to HandlerKind and changed new() signature; builder absorbs wrapping (with_local/with_leaf wrap Handler in Once for Query/Mutation, new with_local_streaming/with_leaf_streaming take StreamingHandler and wrap in Stream for Subscription) with kind/op_type mismatch validation; OperationRegistry::register() now returns Result<(), String> with clear mismatch message; invoke() errors on HandlerKind::Stream with INVALID_OPERATION_TYPE; OverlayOperationEnv::invoke_with_policy matches on HandlerKind (Stream -> INVALID_OPERATION_TYPE); migrated all ~95 HandlerRegistration::new() call sites to wrap in HandlerKind::Once(handler); updated two websocket subscription tests to expect INVALID_OPERATION_TYPE; added unit tests for invoke/register validation, make_streaming_handler, and overlay Stream-kind rejection. All verification passes (build, clippy -D warnings, test, fmt --check) for alknet-call + alknet-http.

View File

@@ -0,0 +1,243 @@
---
id: http/adapters/from-openapi-sse-streaming
name: Implement from_openapi Subscription forwarding as StreamingHandler (SSE response → BoxStream<ResponseEnvelope>)
status: pending
depends_on: [call/registry/streaming-handler-handlerkind]
scope: narrow
risk: medium
impact: component
level: implementation
---
## Description
Branch `from_openapi`'s forwarding handler construction on `op_type` so that a
`Subscription` op (detected via `text/event-stream` response content type)
registers a `StreamingHandler` (`HandlerKind::Stream`) that streams the SSE
response chunks as `ResponseEnvelope::ok()` items. `Query`/`Mutation` ops keep
the existing `Handler` (`HandlerKind::Once`) that returns a single
`ResponseEnvelope`. This closes the gap where a `from_openapi`-imported
`Subscription` returned only the last SSE event.
This task depends on `call/registry/streaming-handler-handlerkind` (which
introduces `HandlerKind::Stream` and `make_streaming_handler`). The existing
`from_openapi` code already detects `Subscription` (`detect_op_type` checks for
`text/event-stream`) and has an SSE parser (`parse_sse_frames`); this task
rewires the subscription path from "collect all events, return last" to "stream
events as they arrive".
### The branch in build_registration
`build_registration` currently always builds a `Handler` (via `make_handler`) and
wraps in `HandlerKind::Once` (after `streaming-handler-handlerkind`). Branch on
`op_type`:
- `Query` / `Mutation` → existing `make_handler` + `forward()` (single response),
`HandlerKind::Once`
- `Subscription` → new `make_streaming_handler` + `forward_stream()` (SSE
streaming), `HandlerKind::Stream`
The `op_type` is already computed by `detect_op_type` and available in
`build_registration`. The `HandlerRegistration::new()` call at the end wraps in
the right `HandlerKind` based on `op_type`.
### forward_stream() — the streaming forward function
```rust
async fn forward_stream(
http_client: &Arc<SharedHttpClient>,
base_url: &str,
path_template: &str,
method: &str,
auth_scheme: &Option<HttpAuthScheme>,
default_headers: &HashMap<String, String>,
namespace: &str,
error_status_codes: &[(u16, String)],
input: Value,
context: OperationContext,
) -> ResponseStream {
let request_id = context.request_id.clone();
// 1. Build the request (same as forward())
let (http_method, url, body, headers) = match build_request(...) {
Ok(parts) => parts,
Err(err) => {
return Box::pin(stream::once(async move {
ResponseEnvelope::error(request_id, err)
}));
}
};
// 2. Send with Accept: text/event-stream
let request_builder = http_client.client()
.request(http_method, url.as_str())
.headers(headers)
.header(ACCEPT, "text/event-stream");
let request_builder = match body.as_ref() {
Some(b) => request_builder.body(serde_json::to_string(b).unwrap_or("null".to_string())),
None => request_builder,
};
let response: reqwest::Response = match request_builder.send().await {
Ok(r) => r,
Err(err) => {
return Box::pin(stream::once(async move {
ResponseEnvelope::error(request_id, CallError::internal(format!("HTTP request failed: {err}")))
}));
}
};
let status = response.status();
if !status.is_success() {
// Non-2xx → single error envelope, stream ends
let code = error_status_codes.iter()
.find(|(s, _)| *s == status.as_u16())
.map(|(_, c)| c.clone())
.unwrap_or_else(|| format!("HTTP_{}", status.as_u16()));
let message = format!("HTTP {}: {}", status.as_u16(), status.canonical_reason().unwrap_or(""));
return Box::pin(stream::once(async move {
ResponseEnvelope::error(request_id, CallError::new(code, message, false))
}));
}
// 3. Stream the SSE chunks → ResponseEnvelope::ok() per data: frame
let request_id_stream = request_id.clone();
let sse_stream = response.bytes_stream()
.scan(String::new(), move |buffer, chunk_result| {
// Parse SSE frames from the chunk, emit each as a ResponseEnvelope::ok()
// This is the streaming analogue of stream_subscription()
let request_id = request_id_stream.clone();
async move {
match chunk_result {
Ok(chunk) => {
buffer.push_str(&String::from_utf8_lossy(&chunk));
let (events, remaining) = parse_sse_frames(buffer);
*buffer = remaining;
// Emit each event as a ResponseEnvelope::ok()
let envelopes: Vec<ResponseEnvelope> = events.into_iter()
.map(|e| {
let parsed = if e.data.trim().is_empty() {
Value::Null
} else {
serde_json::from_str(&e.data).unwrap_or(Value::String(e.data.clone()))
};
ResponseEnvelope::ok(&request_id, parsed)
})
.collect();
Some((envelopes,)) // yield the batch
}
Err(err) => {
let error = CallError::internal(format!("SSE stream error: {err}"));
Some(vec![ResponseEnvelope::error(request_id, error)])
}
}
}
})
.flat_map(|envelopes| stream::iter(envelopes));
Box::pin(sse_stream)
}
```
The exact combinator shape (`scan` + `flat_map`, or a custom `Stream` impl, or
`unfold`) is an implementation detail — the contract is: each SSE `data:` frame
becomes a `ResponseEnvelope::ok()`; an HTTP error (non-2xx) becomes a single
`ResponseEnvelope::error()` and ends the stream; SSE stream end ends the
`ResponseStream` (→ `call.completed` on the wire). Reuse the existing
`parse_sse_frames` parser — it already handles multi-event buffers, partial
trailing lines, comments, multi-line data, BOM.
### Remove stream_subscription() (the collect-all placeholder)
The existing `stream_subscription()` collects all SSE events and returns the
last one as a single `ResponseEnvelope`. This is the placeholder that
truncates. Remove it (or repurpose its SSE-parsing logic into the streaming
`forward_stream`). The `parse_sse_frames` function stays (it's reused by
`forward_stream`); only the collect-all `stream_subscription` wrapper goes.
### build_registration wiring
```rust
let handler = if op_type == OperationType::Subscription {
// Streaming handler — HandlerKind::Stream
let stream_handler = make_streaming_handler(move |input, context| {
// clone captured vars
async move {
forward_stream(&http_client, &base_url, &path_template, &method_upper,
&auth_scheme, &default_headers, &namespace, &error_status_codes,
input, context).await
}
});
HandlerKind::Stream(stream_handler)
} else {
// Request/response handler — HandlerKind::Once (existing)
let once_handler = make_handler(move |input, context| {
// clone captured vars
async move {
forward(&http_client, &base_url, &path_template, &method_upper,
&auth_scheme, &default_headers, &namespace, &error_status_codes,
op_type, input, context).await
}
});
HandlerKind::Once(once_handler)
};
HandlerRegistration::new(spec, handler, OperationProvenance::FromOpenAPI, None, None, capabilities)
```
### What this task does NOT do
- **No `from_mcp` changes.** `from_mcp` handlers are always `HandlerKind::Once`
(MCP tools are request/response — ADR-041; ADR-049 confirms this is unchanged).
- **No gateway changes.** The gateway `/subscribe` SSE path is
`http/server/subscribe-sse-streaming`.
- **No `OperationRegistry` changes.** `invoke_streaming()` is provided by
`call/registry/invoke-streaming`.
## Acceptance Criteria
- [ ] `build_registration` branches on `op_type`: `Subscription`
`HandlerKind::Stream` (streaming forward), `Query`/`Mutation`
`HandlerKind::Once` (existing forward)
- [ ] `forward_stream()` streams SSE chunks as `ResponseEnvelope::ok()` items
- [ ] Each SSE `data:` frame → one `ResponseEnvelope::ok()`
- [ ] HTTP error (non-2xx) → single `ResponseEnvelope::error()`, stream ends
- [ ] SSE stream end → `ResponseStream` ends (→ `call.completed` on wire)
- [ ] `parse_sse_frames` reused (multi-event, partial trailing, comments,
multi-line data, BOM — all handled)
- [ ] `stream_subscription()` (collect-all placeholder) removed or repurposed
- [ ] `Query`/`Mutation` forwarding unchanged (existing `forward()` path)
- [ ] `Accept: text/event-stream` header sent for Subscription requests
- [ ] Unit test: `Subscription` op registration is `HandlerKind::Stream`
- [ ] Unit test: `Query` op registration is `HandlerKind::Once` (unchanged)
- [ ] Integration test: `Subscription` forwarding streams multiple
`ResponseEnvelope::ok()` items from an SSE server (one per `data:` frame)
- [ ] Integration test: `Subscription` forwarding on HTTP error → one
`ResponseEnvelope::error()`, stream ends
- [ ] Integration test: `Query` forwarding unchanged (single response)
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
- [ ] `cargo fmt --check -p alknet-http` passes
## References
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §9 (from_openapi SSE forwarding)
- docs/architecture/crates/http/http-adapters.md — §Forwarding handler (Subscription → HandlerKind::Stream, SSE → BoxStream)
- docs/architecture/crates/http/http-mcp.md — from_mcp handlers always HandlerKind::Once (unchanged)
## Notes
> The existing `stream_subscription()` is the placeholder that truncates — it
> collects all SSE events and returns the last. Replace it with `forward_stream()`
> that yields each SSE event as a stream item. Reuse `parse_sse_frames` (it's
> already correct for multi-event buffers, partial lines, comments, BOM). The
> combinator shape (`scan` + `flat_map`, `unfold`, or custom `Stream`) is an
> implementation detail — the contract is one `ResponseEnvelope::ok()` per
> `data:` frame, error on HTTP failure, end on SSE close. `from_mcp` is
> unchanged — MCP tools are request/response (ADR-041), always
> `HandlerKind::Once`. The `futures` crate's `StreamExt::scan` / `flat_map` /
> `unfold` are the likely tools.
## Summary
> To be filled on completion

View File

@@ -1,7 +1,7 @@
---
id: http/adapters/to-mcp
name: Implement to_mcp gateway projection (4-tool gateway, rmcp StreamableHttpService, ADR-041)
status: pending
status: completed
depends_on: [http/gateway/gateway-dispatch-spine, http/server/bearer-auth-middleware]
scope: broad
risk: medium
@@ -205,4 +205,12 @@ The `ResponseEnvelope` → `CallToolResult` mapping uses rmcp's
## Summary
> To be filled on completion
> Implemented src/adapters/to_mcp.rs: ToMcpGateway rmcp ServerHandler with 4 fixed
> gateway tools (search/schema/call/batch). search dispatches services/list (ACL-
> filtered, excludes Subscriptions ADR-041 §2), schema dispatches services/schema,
> call/batch dispatch via GatewayDispatch::invoke with ResponseEnvelope→CallToolResult
> mapping (structured for Ok, structured_error for Err). Bearer auth via shared
> middleware around nest_service. Identity survives rmcp framing (research §6 #2
> confirmed via test). Feature-gated behind mcp; stdio NOT built (ADR-037). Pure
> projection. StreamableHttpService nested at /mcp. 16 unit tests. 223 mcp-feature
> tests + 5 integration tests pass. Clippy clean on both feature configs.

View File

@@ -1,7 +1,7 @@
---
id: http/adapters/to-openapi
name: Implement to_openapi gateway projection (5-endpoint OpenAPI doc, info.version semver, ADR-042/045)
status: pending
status: completed
depends_on: [http/server/gateway-endpoints, http/gateway/gateway-dispatch-spine]
scope: moderate
risk: medium
@@ -185,4 +185,11 @@ out of scope.
## Summary
> To be filled on completion
> Implemented to_openapi(registry: &OperationRegistry) -> OpenAPISpec in src/adapters/
> to_openapi.rs — pure projection generating fixed 5-endpoint gateway doc (/search,
> /schema, /call, /batch, /subscribe) with info.version = 1.0.0 (ADR-045). /call responses
> carry protocol-level errors (400/401/403/404/500/504) + operation-level errors from
> registry error_schemas mapped by http_status (ADR-023). Per-caller operation surface
> NOT preloaded (discovered via /search, ADR-042). /subscribe response is text/event-stream.
> Wired GET /openapi.json in adapter.rs replacing placeholder 501. 16 new tests. 230
> total tests pass. Clippy clean. Formatting fixed during merge.

View File

@@ -0,0 +1,130 @@
---
id: http/gateway/invoke-streaming
name: Implement GatewayDispatch::invoke_streaming() returning BoxStream<ResponseEnvelope>
status: pending
depends_on: [call/registry/invoke-streaming]
scope: narrow
risk: medium
impact: component
level: implementation
---
## Description
Add `GatewayDispatch::invoke_streaming()` — the streaming analogue of
`invoke()`, returning a `BoxStream<ResponseEnvelope>`. The security invariants
are identical to `invoke()`: `internal: false`, `forwarded_for: None`, same
capabilities, same `scoped_env`, same ACL check before dispatch. The two methods
diverge only on the return shape (stream vs single envelope). The HTTP
`/subscribe` handler calls this and pipes the stream to SSE.
This task depends on `call/registry/invoke-streaming` (which provides
`OperationRegistry::invoke_streaming()`). It adds the `GatewayDispatch` method
only — the `/subscribe` SSE wiring is `http/server/subscribe-sse-streaming`.
### invoke_streaming()
```rust
use futures::stream::BoxStream;
impl GatewayDispatch {
/// Invoke a Subscription operation as a wire-ingress caller. The streaming
/// analogue of `invoke()`. Security invariants identical to `invoke()`:
/// `internal: false`, `forwarded_for: None`, same capabilities, same
/// scoped_env, same ACL. Diverges only on return shape (stream vs single
/// envelope). Returns a `BoxStream<ResponseEnvelope>`; the `/subscribe`
/// handler pipes it to SSE.
pub async fn invoke_streaming(
&self,
identity: Option<Identity>,
op: &str,
input: Value,
) -> BoxStream<'static, ResponseEnvelope> {
let operation_name = strip_leading_slash(op).to_string();
let request_id = uuid::Uuid::new_v4().to_string();
let context = self.build_root_context(&request_id, &operation_name, identity);
// The registry's invoke_streaming returns ResponseStream
// (Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>), which IS
// BoxStream<'static, ResponseEnvelope>. Box::pin / BoxStream are the
// same type — the alias just spells it out.
self.registry.invoke_streaming(&operation_name, input, context)
}
}
```
`build_root_context` is reused unchanged — it constructs the root
`OperationContext` with `internal: false`, `forwarded_for: None`, fresh
`request_id`, deadline, registration bundle's `composition_authority` /
`capabilities` / `scoped_env`. The security axis is provably identical between
`invoke()` and `invoke_streaming()` because they share `build_root_context`.
### deadline: None for streaming
The spec says `deadline: None` for subscriptions (unbounded). `build_root_context`
sets `deadline: Some(now + 30s)`. For the streaming path, set
`context.deadline = None` after `build_root_context`, OR add a streaming flag to
`build_root_context`. Coordinate with `call/protocol/dispatch-streaming-branch`
which has the same concern — extract a shared approach (e.g., a
`build_root_context_streaming` variant or a `deadline: None` override). The
gateway and the call dispatcher both need `deadline: None` for subscriptions;
don't duplicate the logic.
### What this task does NOT do
- **No `/subscribe` SSE wiring.** That's `http/server/subscribe-sse-streaming`.
- **No `OperationRegistry` changes.** `invoke_streaming()` is provided by
`call/registry/invoke-streaming`.
- **No `to_mcp` changes.** MCP excludes `Subscription` (ADR-041); `to_mcp` never
calls `invoke_streaming()`.
## Acceptance Criteria
- [ ] `GatewayDispatch::invoke_streaming()` method exists
- [ ] Returns `BoxStream<'static, ResponseEnvelope>` (or `ResponseStream`
same type)
- [ ] Builds root `OperationContext` via `build_root_context` (same as `invoke()`)
- [ ] Root context has `internal: false`, `forwarded_for: None`, fresh
`request_id`
- [ ] `handler_identity`, `capabilities`, `scoped_env` from registration bundle
(same as `invoke()`)
- [ ] `deadline: None` for the streaming path (unbounded subscriptions)
- [ ] Calls `OperationRegistry::invoke_streaming()`
- [ ] Security invariants identical to `invoke()` (shared `build_root_context`)
- [ ] Unit test: `invoke_streaming()` on a registered `Subscription` op returns
the handler's stream
- [ ] Unit test: `invoke_streaming()` on unknown op returns a stream yielding
one `NOT_FOUND`
- [ ] Unit test: `invoke_streaming()` on Internal op from external returns
stream yielding one `NOT_FOUND` (not leaked)
- [ ] Unit test: `invoke_streaming()` with `None` identity + restricted op
returns stream yielding one `FORBIDDEN`
- [ ] Unit test: `invoke_streaming()` on a `Query` op returns stream yielding
one `INVALID_OPERATION_TYPE`
- [ ] Unit test: `invoke()` (existing) on a `Subscription` op returns
`INVALID_OPERATION_TYPE` (verifies the guard from
`streaming-handler-handlerkind` holds through the gateway)
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
- [ ] `cargo fmt --check -p alknet-http` passes
## References
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §7 (GatewayDispatch::invoke_streaming)
- docs/architecture/crates/http/http-server.md — §Streaming projection (invoke_streaming security invariants identical to invoke)
- docs/architecture/crates/call/operation-registry.md — §OperationRegistry (invoke_streaming)
## Notes
> The security axis MUST be provably identical between `invoke()` and
> `invoke_streaming()` — they share `build_root_context`. The two methods
> diverge only on the return shape. `deadline: None` for subscriptions is a
> shared concern with `call/protocol/dispatch-streaming-branch` — extract a
> shared approach (a streaming-variant of `build_root_context` or a
> `deadline: None` override) rather than duplicating. `to_mcp` never calls
> `invoke_streaming()` (MCP excludes `Subscription` — ADR-041); do not add
> streaming to the MCP gateway. The `futures` crate is already a dependency of
> `alknet-http`.
## Summary
> To be filled on completion

View File

@@ -1,7 +1,7 @@
---
id: http/review-http-final
name: Final review of alknet-http crate — all components, feature gates, pattern consistency
status: pending
status: completed
depends_on: [http/review-http, http/review-websocket, http/review-mcp]
scope: broad
risk: low
@@ -176,4 +176,24 @@ WebSocket, MCP) and verifies the crate as a whole.
## Summary
> To be filled on completion
> Final crate-wide review complete. All 9 checklist areas pass:
> 1. Crate structure: Cargo.toml, lib.rs, 5 modules (server/gateway/client/adapters/websocket),
> workspace member, workspace path deps for alknet-core + alknet-call.
> 2. Feature gate isolation: default = [h2, http1], mcp = [dep:rmcp], h3 ABSENT (ADR-044),
> cargo check (default/mcp/no-default) all succeed, MCP code not compiled without mcp.
> 3. Dependencies correct: alknet-core, alknet-call, axum, reqwest stack, hyper, rmcp (mcp-gated).
> No wtransport/h3. No env-var config.
> 4. Cross-cutting: no-env-vars (no std::env::var in any handler), no secret material in responses,
> AccessControl sole gate, Internal → NOT_FOUND, error fidelity (HTTP_<status> prefix),
> browsers/MCP clients not peers.
> 5. Pattern consistency: GatewayDispatch concrete struct (not trait), auth middleware shared,
> SharedHttpClient ArcSwap-wrapped, error mapping free function, from_* are OperationAdapter
> impls, to_* are pure projections.
> 6. ADR conformance: all ADRs (003-048) verified.
> 7. Absence verified: no h3/WebTransport, no from_wss, no stdio MCP, no direct-call surface,
> no traditional per-operation-paths OpenAPI, no env-var config.
> 8. Test coverage: alknet-call 277+2, alknet-http default 230, alknet-http mcp 265+5. All pass.
> 9. Build cleanliness: fmt clean, clippy clean (default + mcp + all-targets), build clean.
>
> One known limitation: /subscribe SSE completes after single event (registry invoke returns
> single ResponseEnvelope, no streaming subscription handler yet — research §6 OQ#5).

View File

@@ -1,7 +1,7 @@
---
id: http/review-http
name: Review alknet-http server surface + OpenAPI adapters for spec conformance
status: pending
status: completed
depends_on: [http/server/gateway-endpoints, http/adapters/to-openapi, http/adapters/from-openapi, http/server/healthz-decoy]
scope: broad
risk: low
@@ -163,4 +163,15 @@ core of the crate.
## Summary
> To be filled on completion
> HTTP server surface + OpenAPI adapters reviewed against all 12 checklist items. All
> conformance criteria pass: HttpAdapter (struct, DecoyConfig, ProtocolHandler, axum
> over QUIC, h3 not registered), gateway endpoints (5 fixed, no direct-call ADR-047,
> flat JSON /call, GatewayDispatch::invoke, Internal → 404, 401/403 split, SSE /subscribe),
> error mapping (all codes, HTTP_<status> prefix, 401/403 split, Retry-After), auth
> (Bearer-only, shared middleware, ResolvedIdentity, no env vars), /healthz raw + decoy
> fallback, to_openapi (5 endpoints, info.version 1.0.0, per-caller via /search, error
> fidelity ADR-023), from_openapi (OperationAdapter, no-env-vars, HTTP_<status>, SSE),
> SharedHttpClient (ClientWithMiddleware, retry, RetryAfter, ArcSwap), GatewayDispatch
> (concrete struct, not trait), security constraints (no secrets in responses, no env
> vars, Internal → 404, AccessControl sole gate). 230 default + 265 mcp tests pass.
> Clippy clean on both feature configs. Fmt clean.

View File

@@ -1,7 +1,7 @@
---
id: http/review-mcp
name: Review MCP adapters for ADR-037/041 conformance (streamable HTTP, 4-tool gateway, output handling)
status: pending
status: completed
depends_on: [http/adapters/from-mcp, http/adapters/to-mcp]
scope: moderate
risk: low
@@ -158,4 +158,13 @@ for the feature-gated MCP work.
## Summary
> To be filled on completion
> MCP adapters reviewed against all 12 checklist items. All conformance criteria
> pass: from_mcp (OperationAdapter, tools/list, structuredContent-preferred output
> handling, no JSON.parse, isError→CallError, no-env-vars ADR-014), to_mcp (4-tool
> gateway ADR-041, ServerHandler, Subscription excluded, GatewayDispatch::invoke
> shared spine, CallToolResult mapping, Identity survives rmcp framing research §6
> #2, no PeerId ADR-034 §4), streamable HTTP only (ADR-037), feature gate isolation,
> GatewayDispatch concrete struct (not trait), error fidelity (ADR-023), test coverage
> (223 mcp tests + 5 integration tests). One formatting fix applied to from_mcp/mod.rs.
> cargo fmt --check, clippy --features mcp --all-targets, test --features mcp, and
> cargo check (no mcp) all pass.

View File

@@ -1,7 +1,7 @@
---
id: http/review-websocket
name: Review WebSocket path for ADR-044/048 conformance (native session, no length prefix, browsers-not-peers)
status: pending
status: completed
depends_on: [http/websocket/connection-overlay]
scope: moderate
risk: low
@@ -151,4 +151,14 @@ gateway shape, no length prefix).
## Summary
> To be filled on completion
> WebSocket path reviewed against all 11 checklist items. All conformance criteria
> pass: dispatcher transport abstraction (pub API, non-QUIC CallConnection, no
> regressions — 277+2 alknet-call tests), WS upgrade (/alknet/call, Bearer auth, 401,
> HTTP/1.1+HTTP/2), framing (binary WS = EventEnvelope, no length prefix, text → close
> 1002), dispatch (dispatch_requested, AccessControl gates, FORBIDDEN → call.error,
> correlation by id, handle_abort), bidirectionality (hub calls browser ops via overlay),
> connection-local overlay (no PeerId, no PeerEntry, overlay_env() routing,
> PeerRef::Specific → NOT_FOUND, overlay dies on close), browsers-not-peers rationale,
> streaming (native call.responded, no SSE, abort cascade on disconnect), all ADRs
> (012/016/024/029/034/044/048), security constraints. 230 tests pass, clippy clean,
> fmt clean.

View File

@@ -1,7 +1,7 @@
---
id: http/server/gateway-endpoints
name: Implement 5 gateway endpoints (search/schema/call/batch/subscribe) — axum route handlers
status: pending
status: completed
depends_on: [http/server/http-adapter, http/gateway/gateway-dispatch-spine, http/gateway/error-mapping, http/server/bearer-auth-middleware]
scope: broad
risk: medium
@@ -191,4 +191,13 @@ browsers (the `websocket/` tasks).
## Summary
> To be filled on completion
> Implemented 5 fixed gateway endpoints in src/server/gateway_routes.rs: POST /call,
> GET /search, GET /schema, POST /batch, POST /subscribe (SSE). All delegate to
> GatewayDispatch::invoke; auth via ResolvedIdentity extractor; errors mapped via
> call_error_to_http_response (identity-aware 401/403 split). Internal ops → 404.
> /schema adds ACL pre-check. /subscribe projects ResponseEnvelope as SSE. /batch
> loops over invoke returning array. Wired into adapter.rs replacing placeholder 501s.
> 188 tests pass. Clippy clean.
>
> Note: /subscribe SSE completes after single event (registry invoke returns single
> ResponseEnvelope, no streaming subscription handler yet — research §6 OQ#5).

View File

@@ -0,0 +1,156 @@
---
id: http/server/subscribe-sse-streaming
name: Wire /subscribe handler to GatewayDispatch::invoke_streaming() and pipe BoxStream to SSE
status: pending
depends_on: [http/gateway/invoke-streaming]
scope: narrow
risk: medium
impact: component
level: implementation
---
## Description
Replace the `/subscribe` handler's one-event placeholder
(`subscribe_stream_from_envelope`, which calls `GatewayDispatch::invoke()` and
wraps the single `ResponseEnvelope` in a one-event SSE stream) with the real
streaming path: call `GatewayDispatch::invoke_streaming()` and pipe the
`BoxStream<ResponseEnvelope>` to SSE. Each `Ok(value)` → SSE `data:` frame;
`Err` → SSE error event + close (terminal); natural stream end → close (normal
end, corresponds to `call.completed` on the wire). On `call.aborted` or HTTP
client disconnect, drop the stream (Drop releases handler resources, abort
cascade runs per ADR-016).
This task depends on `http/gateway/invoke-streaming` (which provides
`GatewayDispatch::invoke_streaming()`). It rewrites `subscribe_handler` and
removes the placeholder helpers.
### subscribe_handler rewrite
```rust
pub(crate) async fn subscribe_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
Json(request): Json<CallRequest>,
) -> Sse<SubscribeStream> {
let stream = if is_internal_op(&state.registry, &request.operation) {
// Internal ops return NOT_FOUND (don't leak existence) — single error event
subscribe_stream_internal_error(request.operation)
} else {
let dispatch = state.dispatch();
let envelope_stream = dispatch
.invoke_streaming(identity, &request.operation, request.input)
.await;
// Pipe the BoxStream<ResponseEnvelope> to SSE frames
subscribe_stream_from_envelope_stream(envelope_stream)
};
Sse::new(stream)
}
```
### subscribe_stream_from_envelope_stream
Map each `ResponseEnvelope` in the `BoxStream` to an SSE `Event`:
```rust
fn subscribe_stream_from_envelope_stream(
stream: BoxStream<'static, ResponseEnvelope>,
) -> SubscribeStream {
Box::pin(stream.map(|envelope| {
match envelope.result {
Ok(output) => {
let data = serde_json::to_string(&output)
.unwrap_or_else(|_| "null".to_string());
Ok(Event::default().data(data))
}
Err(error) => {
let payload = serde_json::to_value(&error).unwrap_or(Value::Null);
let data = serde_json::to_string(&payload)
.unwrap_or_else(|_| "null".to_string());
Ok(Event::default().event("error").data(data))
}
}
}))
}
```
The `Err` case produces an SSE error event — the stream ends after it (the
`StreamingHandler`'s contract: `Err` is terminal). The natural stream end
(stream yields `None`) closes the SSE stream (axum's `Sse` wrapper handles the
close when the underlying stream ends).
### Remove the placeholder
Delete `subscribe_stream_from_envelope` (the one-event placeholder) and
`envelope_to_sse_stream` (the single-envelope-to-stream helper). The new
`subscribe_stream_from_envelope_stream` replaces them. Keep
`subscribe_stream_internal_error` (Internal ops still return a single
`NOT_FOUND` error event — they don't reach `invoke_streaming()`).
### Client disconnect / abort
axum's `Sse` response detects when the HTTP client disconnects (the response
writer closes) and drops the stream future. `Drop` releases the handler's
resources, and the abort cascade runs per ADR-016. No explicit disconnect
handling is needed — Rust's `Drop` + axum's response-drop handle it. Verify the
stream is dropped (not leaked) on disconnect.
### What this task does NOT do
- **No `GatewayDispatch` changes.** `invoke_streaming()` is provided by
`http/gateway/invoke-streaming`.
- **No `to_mcp` changes.** MCP has no `/subscribe` equivalent (ADR-041).
- **No `from_openapi` changes.** `from_openapi` SSE forwarding is
`http/adapters/from-openapi-sse-streaming`.
## Acceptance Criteria
- [ ] `subscribe_handler` calls `GatewayDispatch::invoke_streaming()` (not
`invoke()`)
- [ ] `subscribe_stream_from_envelope_stream` maps `BoxStream<ResponseEnvelope>`
to SSE `Event`s
- [ ] `Ok(value)` → SSE `data:` frame with output serialized as JSON
- [ ] `Err` → SSE error event (`event: error`) with `CallError` serialized, then
stream ends (terminal)
- [ ] Natural stream end → SSE stream closes (normal end)
- [ ] Internal op → single `NOT_FOUND` error event (unchanged —
`subscribe_stream_internal_error` kept)
- [ ] Client disconnect → stream dropped (Drop releases resources; abort cascade)
- [ ] Placeholder helpers (`subscribe_stream_from_envelope`,
`envelope_to_sse_stream`) removed
- [ ] `SubscribeStream` type alias still `BoxStream<'static, Result<Event, Infallible>>`
- [ ] Unit test: `/subscribe` on a `Subscription` op streams multiple `data:`
frames (one per `call.responded`)
- [ ] Unit test: `/subscribe` on a `Subscription` op that yields `Err` → one
`event:error` frame, then stream closes
- [ ] Unit test: `/subscribe` on Internal op → `event:error` with `NOT_FOUND`
(unchanged)
- [ ] Unit test: `/subscribe` on unknown op → `event:error` with `NOT_FOUND`
- [ ] Unit test: `/subscribe` on `Query` op → `event:error` with
`INVALID_OPERATION_TYPE` (the guard holds through the gateway)
- [ ] Unit test: response `Content-Type` is `text/event-stream`
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
- [ ] `cargo fmt --check -p alknet-http` passes
## References
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §7 (HTTP /subscribe pipes BoxStream to SSE)
- docs/architecture/crates/http/http-server.md — §Streaming projection (SSE — the gateway's /subscribe)
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (stream drop on disconnect/abort)
## Notes
> This replaces the one-event placeholder with the real streaming path. The
> `Err` envelope is terminal — the SSE stream ends after the error event (no
> `data:` frame after an `event:error`). Natural stream end closes the SSE
> stream (axum handles the close when the underlying stream ends). Client
> disconnect drops the stream future via Rust's `Drop` — no explicit handling
> needed. Keep `subscribe_stream_internal_error` (Internal ops return
> `NOT_FOUND` without reaching `invoke_streaming()` — they don't leak
> existence). The `futures::StreamExt::map` combinator is the tool for mapping
> the envelope stream to SSE events.
## Summary
> To be filled on completion

View File

@@ -1,7 +1,7 @@
---
id: http/websocket/connection-overlay
name: Implement connection-local Layer 2 overlay for browser-registered ops (no PeerId, ADR-024/034/044)
status: pending
status: completed
depends_on: [http/websocket/upgrade-handler]
scope: moderate
risk: medium
@@ -179,4 +179,12 @@ This task ensures:
## Summary
> To be filled on completion
> Added AccessControl::check to OverlayOperationEnv::invoke_with_policy in alknet-call
> so hub's calls to browser-registered ops are gated by the browser's AccessControl
> (caller identity = parent handler_identity.as_identity(), matching OperationRegistry
> semantics). Created src/websocket/overlay.rs with 19 integration tests: overlay
> scoping (not PeerCompositeEnv), no PeerId for browser, register_imported/all,
> overlay_env() routing, PeerRef::Specific('browser-X')→NOT_FOUND, AccessControl gating
> (allowed/forbidden/default), overlay drop on WS close + isolation, ADR-016 abort
> cascade on disconnect, bidirectionality, no-ops use-case scoping. Zero regressions:
> alknet-call 277+2 tests pass, alknet-http 207 tests pass, clippy clean on both.

View File

@@ -1,7 +1,7 @@
---
id: http/websocket/upgrade-handler
name: Implement WebSocket upgrade handler (native EventEnvelope session, no length prefix, bearer auth)
status: pending
status: completed
depends_on: [http/server/http-adapter, http/websocket/dispatcher-transport-abstraction, http/server/bearer-auth-middleware]
scope: broad
risk: high
@@ -227,4 +227,13 @@ the in-flight subscription, which cascades to descendants per ADR-016.
## Summary
> To be filled on completion
> Implemented src/websocket/upgrade.rs: WS upgrade handler at /alknet/call using axum
> WebSocketUpgrade, bearer auth via shared bearer_auth_middleware (no token → 401),
> resolved identity stored on CallConnection::new_overlay_only, native EventEnvelope
> over binary WS messages (no length prefix, text → protocol close 1002), shared
> Dispatcher::dispatch_requested for call.requested (AccessControl::check gates →
> FORBIDDEN call.error), Dispatcher::handle_abort for call.aborted, responded/completed/
> aborted correlated via PendingRequestMap, fail_all_pending on disconnect (ADR-016
> cascade), bidirectionality via connection-local overlay. Wired /alknet/call route
> into adapter.rs router. 168 tests pass (incl. round-trip, 401, FORBIDDEN, subscription,
> disconnect abort, text-close, bidirectional overlay, no-length-prefix). Clippy clean.

View File

@@ -0,0 +1,210 @@
---
id: review-streaming-impl
name: Review ADR-049 streaming handler implementation for spec conformance and end-to-end correctness
status: pending
depends_on: [call/protocol/dispatch-streaming-branch, call/client/from-call-streaming-forwarding, http/gateway/invoke-streaming, http/server/subscribe-sse-streaming, http/adapters/from-openapi-sse-streaming]
scope: broad
risk: low
impact: phase
level: review
---
## Description
Review the ADR-049 streaming handler implementation across `alknet-call` and
`alknet-http` for spec conformance, end-to-end correctness, and pattern
consistency. This is the quality checkpoint after the streaming handler work —
the most significant cross-cutting change since the initial call/http
implementation. All five implementation tasks must be complete before this
review.
### Review Checklist
1. **Type surface conformance** (operation-registry.md §Handler):
- `StreamingHandler` type alias matches spec (`Arc<dyn Fn(Value, OperationContext) -> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>> + Send + Sync>`)
- `ResponseStream` alias = `Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>` (= `futures::stream::BoxStream<'static, ResponseEnvelope>`)
- `HandlerKind` enum with `Once(Handler)` and `Stream(StreamingHandler)` variants
- `make_streaming_handler()` helper (analogue of `make_handler()`)
- `HandlerRegistration.handler` is `HandlerKind` (not `Handler`)
- `INVALID_OPERATION_TYPE` is the sixth protocol-level code in `CallError`
(`retryable: false`, `details: None`)
2. **Registry conformance** (operation-registry.md §OperationRegistry):
- `register()` validates `HandlerKind` matches `spec.op_type` (Once for
Query/Mutation, Stream for Subscription) — mismatch is a startup error
- `invoke()` dispatches `HandlerKind::Once` (existing path); returns
`INVALID_OPERATION_TYPE` for `HandlerKind::Stream`
- `invoke_streaming()` dispatches `HandlerKind::Stream`; returns
`INVALID_OPERATION_TYPE` for `HandlerKind::Once`
- `invoke_streaming()` pre-handler errors (not-found, forbidden,
INVALID_OPERATION_TYPE) yield a single error `ResponseEnvelope` and end
the stream
- `invoke_streaming()` visibility + ACL checks identical to `invoke()`
(same authority switch: internal → handler_identity, external → identity)
- `OperationEnv` is request/response-only — no `invoke_streaming()` on the
trait; `invoke()` on a `Subscription` returns `INVALID_OPERATION_TYPE`
(via `LocalOperationEnv``registry.invoke()` and `OverlayOperationEnv`
direct match)
3. **Builder conformance** (operation-registry.md §OperationRegistryBuilder):
- `with_local` / `with_leaf` / `with_leaf_provenance` wrap `Handler` in
`HandlerKind::Once` for Query/Mutation
- `with_local_streaming` / `with_leaf_streaming` wrap `StreamingHandler` in
`HandlerKind::Stream` for Subscription
- Builder validates `handler` kind matches `spec.op_type`
4. **Call-protocol dispatch conformance** (call-protocol.md §CallAdapter Stream Handling):
- `Dispatcher::handle_stream` branches on `op_type`: `Subscription`
`invoke_streaming()` → pump stream; `Query`/`Mutation``invoke()` (existing)
- Streaming pump: each `ResponseEnvelope``EventEnvelope` frame
- Natural stream end → `call.completed` frame
- `Err` envelope → `call.error` frame, stream ends after it (NO
`call.completed` after an error)
- `deadline: None` for subscriptions (unbounded)
- Abort: `call.aborted` drops the stream (Drop releases resources; ADR-016)
5. **from_call streaming forwarding** (client-and-adapters.md §from_call):
- `build_bundles` branches on `op_type`: `Subscription`
`make_streaming_forwarding_handler` (`HandlerKind::Stream`),
`Query`/`Mutation``make_forwarding_handler` (`HandlerKind::Once`)
- Streaming forwarding handler calls `CallConnection::subscribe_with_payload()`
(or `subscribe()` with forwarded payload)
- `forwarded_for` populated from `context.identity` (ADR-032)
- Remote stream forwarded end-to-end (no truncation, no first-value fallback)
- `composition_authority: None`, `scoped_env: None` for FromCall streaming leaves
6. **Gateway dispatch conformance** (http-server.md §Streaming projection):
- `GatewayDispatch::invoke_streaming()` exists, returns
`BoxStream<ResponseEnvelope>`
- Security invariants identical to `invoke()` (shared `build_root_context`):
`internal: false`, `forwarded_for: None`, same capabilities, same scoped_env,
same ACL
- `deadline: None` for the streaming path
- `to_mcp` does NOT call `invoke_streaming()` (MCP excludes Subscription)
7. **/subscribe SSE conformance** (http-server.md §Streaming projection):
- `subscribe_handler` calls `GatewayDispatch::invoke_streaming()` (not `invoke()`)
- Each `Ok(value)` → SSE `data:` frame
- `Err` → SSE error event (`event: error`), stream ends after (terminal)
- Natural stream end → SSE stream closes
- Internal op → single `NOT_FOUND` error event (no leak)
- Client disconnect → stream dropped (Drop; abort cascade)
- Placeholder helpers removed (`subscribe_stream_from_envelope`,
`envelope_to_sse_stream`)
8. **from_openapi SSE conformance** (http-adapters.md §Forwarding handler):
- `build_registration` branches on `op_type`: `Subscription`
`HandlerKind::Stream` (streaming forward), `Query`/`Mutation`
`HandlerKind::Once` (existing)
- `forward_stream()` streams SSE chunks as `ResponseEnvelope::ok()` items
- HTTP error → single `ResponseEnvelope::error()`, stream ends
- SSE stream end → `ResponseStream` ends
- `parse_sse_frames` reused (multi-event, partial, comments, BOM)
- `stream_subscription()` collect-all placeholder removed
- `from_mcp` unchanged (always `HandlerKind::Once`)
9. **ADR conformance**:
- ADR-049: all 9 decisions implemented (StreamingHandler, HandlerKind,
invoke_streaming, invoke errors on Subscription, OperationEnv
request/response-only, server-side dispatch branch,
GatewayDispatch::invoke_streaming, from_call stream forwarding,
from_openapi SSE forwarding)
- ADR-023 amended: six protocol codes (INVALID_OPERATION_TYPE added)
- ADR-016: abort cascade on streaming (stream drop)
- ADR-032: forwarded_for on streaming forwarding handlers
10. **End-to-end correctness**:
- A `Subscription` op registered with a `StreamingHandler` streams
`call.responded` events through: server dispatch → wire → HTTP `/subscribe`
SSE (or `from_call` forwarding → remote stream, or `from_openapi` SSE
forwarding)
- `invoke()` on a `Subscription` returns `INVALID_OPERATION_TYPE` (not silent
truncation)
- `invoke_streaming()` on a `Query`/`Mutation` returns
`INVALID_OPERATION_TYPE`
- `OperationEnv::invoke()` on a `Subscription` returns
`INVALID_OPERATION_TYPE` (composition is request/response-only)
11. **Pattern consistency**:
- `invoke()` and `invoke_streaming()` share visibility + ACL logic (security
axis provably identical)
- `GatewayDispatch::invoke()` and `invoke_streaming()` share
`build_root_context` (security axis provably identical)
- `HandlerKind` makes the "one or the other, matching op_type" invariant
type-level (not two `Option`s validated at runtime)
- Existing `Query`/`Mutation` handlers unchanged (wrapped in
`HandlerKind::Once`, dispatch path identical)
12. **Test coverage**:
- Unit tests for `HandlerKind` validation at `register()` (both mismatch
directions)
- Unit tests for `invoke()` / `invoke_streaming()` cross-kind errors
(`INVALID_OPERATION_TYPE` both directions)
- Unit tests for `invoke_streaming()` pre-handler errors (not-found,
forbidden, internal-from-external)
- Unit tests for `invoke_streaming()` ACL authority switch (internal →
handler_identity)
- Unit test for `make_streaming_handler`
- Unit/integration tests for server-side streaming dispatch (multiple
`call.responded` + `call.completed`; `Err``call.error`, no
`call.completed` after)
- Unit/integration tests for `from_call` streaming forwarding
- Unit/integration tests for `from_openapi` SSE streaming forwarding
- Unit tests for `/subscribe` SSE (multiple `data:` frames; `event:error`;
`INVALID_OPERATION_TYPE` for `Query` op via `/subscribe`)
- Unit tests for `GatewayDispatch::invoke_streaming()` (all error paths)
## Acceptance Criteria
- [ ] All type surface matches operation-registry.md §Handler
- [ ] All registry methods match operation-registry.md §OperationRegistry
- [ ] Builder wraps HandlerKind correctly per op_type
- [ ] Call-protocol dispatch branches on op_type correctly
- [ ] from_call streaming forwarding works end-to-end
- [ ] GatewayDispatch::invoke_streaming security invariants identical to invoke
- [ ] /subscribe SSE pipes BoxStream correctly
- [ ] from_openapi SSE streaming works (no truncation)
- [ ] from_mcp unchanged (always HandlerKind::Once)
- [ ] ADR-049 all 9 decisions implemented
- [ ] ADR-023 amended (six protocol codes)
- [ ] invoke() / invoke_streaming() / OperationEnv::invoke() cross-kind errors
all return INVALID_OPERATION_TYPE
- [ ] Existing Query/Mutation handlers unchanged
- [ ] Test coverage adequate for all streaming functionality
- [ ] `cargo fmt --check -p alknet-call -p alknet-http` passes
- [ ] `cargo clippy -p alknet-call -p alknet-http --all-targets` passes with no warnings
- [ ] All tests pass (`cargo test -p alknet-call -p alknet-http`)
## References
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049
- docs/architecture/crates/call/operation-registry.md — Handler, OperationRegistry, HandlerRegistration
- docs/architecture/crates/call/call-protocol.md — CallAdapter Stream Handling, call.error Payload
- docs/architecture/crates/call/client-and-adapters.md — from_call streaming forwarding
- docs/architecture/crates/http/http-server.md — Streaming projection (SSE)
- docs/architecture/crates/http/http-adapters.md — Forwarding handler (from_openapi)
- docs/architecture/crates/http/http-mcp.md — from_mcp always HandlerKind::Once
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (amended: six codes)
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (stream drop)
- docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032 (forwarded_for)
## Notes
> This is the quality checkpoint for the ADR-049 streaming handler work — the
> most significant cross-cutting change since the initial call/http
> implementation. The review should verify the end-to-end streaming path works:
> a `Subscription` op registered with a `StreamingHandler` streams
> `call.responded` events through every projection (server dispatch → wire,
> HTTP `/subscribe` SSE, `from_call` forwarding, `from_openapi` SSE forwarding).
> The load-bearing invariants: (1) `invoke()` / `invoke_streaming()` /
> `OperationEnv::invoke()` cross-kind errors all return
> `INVALID_OPERATION_TYPE` (no silent truncation), (2) the security axis is
> provably identical between `invoke()` and `invoke_streaming()` (shared
> `build_root_context` + shared visibility/ACL logic), (3) `HandlerKind` makes
> the kind/op_type invariant type-level, (4) existing `Query`/`Mutation`
> handlers are unchanged. If deviations are found, document and fix before
> considering the streaming handler work complete.
## Summary
> To be filled on completion