6.4 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| http/gateway/invoke-streaming | Implement GatewayDispatch::invoke_streaming() returning BoxStream<ResponseEnvelope> | completed |
|
narrow | medium | component | 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()
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
/subscribeSSE wiring. That'shttp/server/subscribe-sse-streaming. - No
OperationRegistrychanges.invoke_streaming()is provided bycall/registry/invoke-streaming. - No
to_mcpchanges. MCP excludesSubscription(ADR-041);to_mcpnever callsinvoke_streaming().
Acceptance Criteria
GatewayDispatch::invoke_streaming()method exists- Returns
BoxStream<'static, ResponseEnvelope>(orResponseStream— same type) - Builds root
OperationContextviabuild_root_context(same asinvoke()) - Root context has
internal: false,forwarded_for: None, freshrequest_id handler_identity,capabilities,scoped_envfrom registration bundle (same asinvoke())deadline: Nonefor the streaming path (unbounded subscriptions)- Calls
OperationRegistry::invoke_streaming() - Security invariants identical to
invoke()(sharedbuild_root_context) - Unit test:
invoke_streaming()on a registeredSubscriptionop returns the handler's stream - Unit test:
invoke_streaming()on unknown op returns a stream yielding oneNOT_FOUND - Unit test:
invoke_streaming()on Internal op from external returns stream yielding oneNOT_FOUND(not leaked) - Unit test:
invoke_streaming()withNoneidentity + restricted op returns stream yielding oneFORBIDDEN - Unit test:
invoke_streaming()on aQueryop returns stream yielding oneINVALID_OPERATION_TYPE - Unit test:
invoke()(existing) on aSubscriptionop returnsINVALID_OPERATION_TYPE(verifies the guard fromstreaming-handler-handlerkindholds through the gateway) cargo test -p alknet-httpsucceedscargo clippy -p alknet-http --all-targetssucceeds with no warningscargo fmt --check -p alknet-httppasses
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()andinvoke_streaming()— they sharebuild_root_context. The two methods diverge only on the return shape.deadline: Nonefor subscriptions is a shared concern withcall/protocol/dispatch-streaming-branch— extract a shared approach (a streaming-variant ofbuild_root_contextor adeadline: Noneoverride) rather than duplicating.to_mcpnever callsinvoke_streaming()(MCP excludesSubscription— ADR-041); do not add streaming to the MCP gateway. Thefuturescrate is already a dependency ofalknet-http.
Summary
Added GatewayDispatch::invoke_streaming() returning BoxStream. Security axis provably identical to invoke() via shared build_root_context_inner(bounded: bool); extracted build_root_context_streaming for deadline: None (unbounded subscriptions). Calls OperationRegistry::invoke_streaming(). to_mcp untouched. Added 9 unit tests (all error paths + streaming dispatch + deadline: None verification). 243 tests pass.