Phase 1 (SDD) — architecture documentation: Ported specs (adapted for alkcall, producer/consumer terms, 6-endpoint gateway, channels-over-WS, Sub/Pub operation types): - overview.md, http-server.md, http-adapters.md, http-mcp.md - README.md index (rewritten for alkhttp) New ADRs: - 067: WebSocket carries the channels protocol (8-byte chunk demux, channel 0 = alk/call, upgrade path /alk/channels) - 068: gateway /publish endpoint for Pub operations (NDJSON body) - 069: WebTransport out of scope in alkhttp (alknet concern) - 070: from_wss consumer adapter (wss feature, tokio-tungstenite) Ported ADRs (25, same numbers, port notes + amendments where the extraction changed facts): 001-004, 010, 014, 015, 017, 022, 023, 027, 034, 036, 037, 039, 041, 042, 044, 045, 046, 047, 048, 049, 051, 066. websocket.md rewritten for the channels session; open-questions.md seeded (OQ-01 WS byte-stream adapter, OQ-02 /publish framing, OQ-03 from_wss reconnect, OQ-04 browser client ownership). Verified: cargo test, clippy -D warnings, fmt, doc --no-deps.
20 KiB
ADR-049: Streaming Handler for Subscription Operations
Ported from alknet ADR-049 (Streaming Handler for Subscription Operations); re-targeted to alkhttp.
Status
Accepted
Context
The call protocol defines Sub as a first-class operation type
(alkcall ADR-015 lists subscribe as one of four top-level protocol
operations; OperationSpec.op_type includes Sub). 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 the alkcall crate's call-protocol.md. The consumer side works — a
consumer can subscribe to a remote stream and consume call.responded events
until call.completed.
The producer side did not (in the original design). The Handler type in
the call crate was:
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 Sub 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:
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
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:
/subscribeHTTP endpoint (GatewayDispatch::invoke()→subscribe_handler) wraps a singleResponseEnvelopein a one-event SSE stream. A realSuboperation (e.g.,agent/chatstreaming LLM tokens) cannot stream through it.from_callforwarding for aSubop callsCallConnection::call_with_payload()(single response), notCallConnection::subscribe()(stream). Afrom_call-imported subscription truncates to the first value.from_openapiforwarding for atext/event-streamresponse returns oneResponseEnvelopeinstead 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 ResponseEnvelopes,
mirroring the TS SubscriptionHandler / OperationHandler split:
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
pub enum HandlerKind {
Once(Handler),
Stream(StreamingHandler),
Sink(SinkHandler),
}
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;
Sub → HandlerKind::Stream; Pub → HandlerKind::Sink. 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 Options validated at
runtime.
Sinkvariant (alkcall ADR-046). The original decision defined two variants,OnceandStream, covering the four operation types then in existence (Query,Mutation,Sub) —Subops →Stream, everything else →Once. alkcall ADR-046 subsequently addedOperationType::Pub(producer→consumer streaming via thecall.publishedwire event) withHandlerKind::Sinkas the third variant. TheSinkvariant shown above is annotated into the enum for accuracy of the current model; the original two-variant decision text below is retained as decision history. In v1,from_openapiproduces noPubops (SSE responses detect asSub), so this ADR's adapter discussion is unaffected bySink.
3. OperationRegistry::invoke_streaming()
impl OperationRegistry {
/// Dispatch a Sub 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 Sub
invoke() is the request/response dispatch path. Calling it on a
Sub 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). Consumers should treat unknown codes
as INTERNAL with retryable: false (the existing rule); INVALID_OPERATION_ TYPE is a permanent caller-side programming error, not a transient failure.
5. OperationEnv::invoke() errors on Sub
OperationEnv::invoke() (composition) stays request/response-only. It returns
a single ResponseEnvelope. Calling it on a Sub 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:
OperationEnvcomposition is "call a child operation, get a result" (theOperationHandlermodel). 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/pubsuboperators.tsis the prior art for this model: 13 operators (filter,map,take,batch,dedupe,window,chain,join, etc.) that operate onAsyncIterable<T>, distinct from the request/response composition. In Rust, the analogues operate onBoxStream<T>. - No
invoke_streaming()is added toOperationEnv. The protocol composition surface is request/response; stream manipulation is handler-internal.
6. Dispatch branches on op_type
Dispatcher::handle_stream / dispatch_requested gains a branch on
op_type:
Sub→registry.invoke_streaming()→ for eachResponseEnvelopein the stream, writeEventEnvelopeto the wire → writecall.completedon stream end.Query/Mutation→registry.invoke()→ write oneEventEnvelope(existing path, unchanged).Pub→registry.invoke_sink()(alkcall ADR-046) → the producer's handler sinks the incomingcall.publishedstream; events flow producer→consumer viacall.published.
The streaming branch sets deadline: None for subscriptions (unbounded —
already specced in the alkcall crate's call-protocol.md Timeouts) and wires
abort cascade (alkcall ADR-020): if call.aborted arrives for a streaming
request, the stream is dropped (Rust Drop releases the handler's resources).
7. GatewayDispatch::invoke_streaming() (alkhttp)
The shared dispatch spine gains a streaming variant:
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→ existingmake_forwarding_handler()(callsCallConnection::call_with_payload(), returns singleResponseEnvelope), registered asHandlerKind::Once.Sub→ newmake_streaming_forwarding_handler()(callsCallConnection::subscribe(), returnsimpl Stream<Item = ResponseEnvelope>, maps toBoxStream<ResponseEnvelope>), registered asHandlerKind::Stream.
A from_call-imported Sub op forwards the remote stream end-to-end:
the consumer-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 →
Sub):
Query/Mutation→ existing forwarding handler (single HTTP request → singleResponseEnvelope),HandlerKind::Once.Sub→ 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:
Suboperations work end-to-end: producer-side handler → producer-side dispatch → wire → HTTP/subscribeSSE →from_callforwarding →from_openapiSSE forwarding. No truncation, no broken paths.- The
Handler/StreamingHandlersplit mirrors the TS prior art exactly, making the Rust port faithful to its source. HandlerKindmakes the "one or the other, matchingop_type" invariant type-level (aOncevariant forQuery/Mutation, aStreamvariant forSub) rather than a runtime check on twoOptions.- Existing handlers (echo, discovery, from_openapi Query/Mutation, from_mcp,
from_call Query/Mutation) are unchanged — they return a single
ResponseEnvelopeand register asHandlerKind::Once. The streaming path is additive to the existing handler surface. OperationEnvcomposition stays request/response, preserving the composition model's simplicity. Stream composition is a handler-level concern, cleanly separated.- The new
INVALID_OPERATION_TYPEprotocol code catches dispatch-path misuse (callinginvoke()on aSub) at the protocol level instead of silently producing wrong behavior.
Negative:
HandlerRegistration.handlerchanges type fromHandlertoHandlerKind. Existing code constructingHandlerRegistrationbundles must wrap inHandlerKind::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; directHandlerRegistration::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 asINTERNALwithretryable: false(the existing rule) handle it correctly — they just don't distinguish it fromINTERNALuntil updated. The code is distinct from all existing codes and from operation-level domain codes (noHTTP_prefix, no collision with the five existing protocol codes). - The
Dispatcher::handle_streamstreaming branch adds a stream-to-wire pump (read stream → write frames → writecall.completed). This is new code in the hot dispatch path, but it is a straightforwardwhile let Some(envelope) = stream.next().awaitloop, not a complex abstraction.
Door type
One-way. The Handler / StreamingHandler / HandlerKind API surface
is what handlers are written against across crates (alkcall,
alkhttp, 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),
now with the alkcall ADR-046 Sink(SinkHandler) third variant) is
the one-way commitment: one handler variant per dispatch model, 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
- alkcall ADR-015: Call Protocol Stream Model (alknet ADR-012; defines
subscribeas 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
StreamingHandlervariant; the decision record is alkcall ADR-022) - ADR-022:
Handler Registration, Provenance, and Composition Authority
(
HandlerRegistrationgainsHandlerKind; the decision record is alkcall ADR-018) - ADR-015: Privilege Model
and Authority Context (visibility/ACL checks run
identically in
invoke_streaming()as ininvoke(); the decision record is alkcall ADR-017) - alkcall ADR-020: Abort Cascade for Nested Calls (alknet ADR-016; stream drop on abort; cascade through streaming handlers)
- ADR-023: Operation Error Schemas
(
INVALID_OPERATION_TYPEis a protocol-level code, distinct from operation-level domain codes; the decision record is alkcall ADR-016) - alkcall ADR-024: Peer-Graph Routing Model (alknet ADR-029;
from_callforwarding handlers gain the streaming variant) - alkcall ADR-046:
Puboperation type andHandlerKind::Sink— the producer→consumer streaming primitive; the thirdHandlerKindvariant - alknet ADR-009: One-Way Door Decision Framework (now alkcall ADR-032; the
Handler/StreamingHandlersplit is a one-way door — handler API surface) @alkdev/operations/src/types.ts:62-78— TS prior art (OperationHandler/SubscriptionHandlersplit)@alkdev/operations/src/registry.ts:65-75— TS prior art (validateSubscriptionHandler— runtime validation againstop_type)@alkdev/operations/src/call.ts:341-349— TS prior art (buildCallHandlerbranches onop_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 fromOperationEnvrequest/response composition)- Spec documents amended (alknet mono-repo spec tree; the alkhttp equivalents
live in this crate's
docs/architecture/):operation-registry.md,call-protocol.md,http-server.md,http-adapters.md,http-mcp.md,client-and-adapters.md
Port notes
- Renames: "alknet-http" → alkhttp; "alknet-core"/"alknet-call" → alkcall.
OperationType::Subscription→Subthroughout (alkcall rename;OperationSpec.op_typevalue and TS-mapping references). The TS prior-art identifiers (SUBSCRIPTION,SubscriptionHandler) are left verbatim — they are the TypeScript package's names, not the Rust enum's.- Producer/consumer terminology: "the server side does not" → "the
producer side did not (in the original design)"; "The client side
works — a client can subscribe" → "The consumer side works — a
consumer can subscribe"; "server-side handler → server-side dispatch" →
"producer-side handler → producer-side dispatch"; "the client-side
CallConnection::subscribe()" → "the consumer-side"; §4 "client-side programming error" → "caller-side programming error"; §6 heading "Server-side dispatch branches onop_type" → "Dispatch branches onop_type". HTTP/TS inherent directionality untouched (buildCallHandleris TS prior-art code;client.tscitations keep their names). Pub/Sinknote (alkcall ADR-046): the original defined twoHandlerKindvariants (Once,Stream) — annotated. The §2 code block now shows the three-variant enum with a block-quote annotation marking theSinkvariant as the alkcall ADR-046 addition (decision history retained); §2's validation sentence and §6's dispatch branch table add thePub→invoke_sink()row with the alkcall ADR-046 citation; the Door type section's one-way enum-shape sentence updated to acknowledge the third variant.from_openapiproduces noPubops in v1 (SSE detection yieldsSub), so §7–§9 need no change. The original two-variant decision text is otherwise retained verbatim as decision history.- §7 title: "(alknet-http)" → "(alkhttp)". The
/subscribeendpoint andGatewayDispatch::invoke_streaming()are this crate's gateway surface (ADR-042/ADR-047); the Context's first downstream bullet namesGatewayDispatch::invoke()→subscribe_handler, which is the alkhttp gateway dispatch spine. - Cross-reference remappings (verified alknet→alkcall ADR mapping): alknet ADR-012 (stream model) → alkcall ADR-015; alknet ADR-016 (abort cascade) → alkcall ADR-020; alknet ADR-022 (handler registration) → alkcall ADR-018; alknet ADR-017 (adapter contract) → alkcall ADR-022; alknet ADR-015 (privilege model) → alkcall ADR-017; alknet ADR-029 (peer-graph routing) → alkcall ADR-024; alknet ADR-009 (one-way door framework) → alkcall ADR-032. ADR-015/017/022/023 are ported to this crate under the same numbers and linked; their alkcall record numbers are noted alongside.
- The amended-spec list at the end of References is annotated: those spec
documents live in the alknet mono-repo spec tree (and the alkcall crate's
docs for the call-side ones); the alkhttp equivalents are this crate's
docs/architecture/http-server.mdandhttp-adapters.md. - No decision content changed — the
StreamingHandlertype, theHandlerKindenum,invoke_streaming(), theINVALID_OPERATION_TYPEcode, theOperationEnvrequest/response-only boundary, the dispatch branch, and the gateway/from_call/from_openapi streaming paths are verbatim from the alknet ADR modulo the corrections logged above.