Files
alknet/tasks/call/client/from-call-streaming-forwarding.md
glm-5.2 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

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
call/registry/streaming-handler-handlerkind
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 / Mutationmake_forwarding_handler() (existing), wrap in HandlerKind::Once
  • Subscriptionmake_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

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