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.
8.6 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| call/client/from-call-streaming-forwarding | Implement from_call streaming forwarding handler (Subscription → CallConnection::subscribe → StreamingHandler) | pending |
|
narrow | medium | component | 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 inHandlerKind::OnceSubscription→make_streaming_forwarding_handler()(new), wrap inHandlerKind::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
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:
- Add
CallConnection::subscribe_with_payload(payload: Value)(mirrorscall_with_payload) that takes a caller-constructed payload. The forwarding handler builds the payload withbuild_forwarded_payloadand callssubscribe_with_payload. - Extend
subscribe()to accept optionalforwarded_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_bundlesbranches onspec.op_type:Subscription→ streaming forwarding handler (HandlerKind::Stream),Query/Mutation→ existingHandlerKind::Oncemake_streaming_forwarding_handler()constructs aStreamingHandler- Streaming forwarding handler calls
CallConnection::subscribe_with_payload()(orsubscribe()with the forwarded payload) and forwards the remote stream CallConnection::subscribe_with_payload(payload)exists (mirrorscall_with_payload) ORsubscribe()accepts the forwarded payloadforwarded_forpopulated fromcontext.identity(ADR-032) on the streaming payload (reusebuild_forwarded_payload)auth_tokenpopulated when present (reusebuild_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: Nonefor FromCall streaming leaves (same as Query/Mutation FromCall leaves)- Unit test:
build_bundleswith aSubscriptionop produces aHandlerKind::Streamregistration - Unit test:
build_bundleswith aQueryop producesHandlerKind::Once(unchanged) - Unit test:
make_streaming_forwarding_handlerproduces aStreamingHandlerthat callssubscribe_with_payload(verify via payload capture, mirroring the existingforwarding_handler_populates_forwarded_fortest) - 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-callsucceedscargo clippy -p alknet-call --all-targetssucceeds with no warningscargo fmt --check -p alknet-callpasses
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 issubscribe_with_payload()(mirroringcall_with_payload) so the forwarding handler can populateforwarded_for+auth_token. Reusebuild_forwarded_payload— no new payload-construction code. The abort cascade is already wired viaPendingRequestMap; verify the stream drops on parent abort. TheOpSummary/build_bundlesneeds theop_typeto branch — read it from the constructedspec.op_type(already parsed byrebuild_spec_for). Cross-node abort is transparent viaparent_request_id(ADR-016 §6).
Summary
To be filled on completion