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).
This commit is contained in:
2026-07-02 07:43:01 +00:00
parent 139c651eaa
commit 7ecc11610a
10 changed files with 602 additions and 76 deletions

View File

@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-06-30 last_updated: 2026-07-02
--- ---
# Alknet Architecture # 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 | | [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 | | [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 | | [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 ## 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-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-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-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):** **Deferred (not active):**
- **OQ-09**: WASM target boundaries — design constraint, not deliverable - **OQ-09**: WASM target boundaries — design constraint, not deliverable

View File

@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-06-23 last_updated: 2026-07-02
--- ---
# Call Protocol # 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) - `NOT_FOUND` — operation not in registry (or Internal op called from wire)
- `FORBIDDEN` — access denied (insufficient scopes or unauthenticated) - `FORBIDDEN` — access denied (insufficient scopes or unauthenticated)
- `INVALID_INPUT` — input doesn't match the operation's JSON Schema - `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 - `INTERNAL` — handler error, panic, connection failure
- `TIMEOUT` — request timed out (retryable: true) - `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 } }` | | `Ok(value)` | `{ type: "call.responded", id: request_id, payload: { output: value } }` |
| `Err(call_error)` | `{ type: "call.error", id: request_id, payload: <serialized CallError> }` | | `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 ### Protocol Operations
@@ -405,10 +406,14 @@ The `CallAdapter::handle()` method:
1. Spawns a task that continuously calls `connection.accept_bi()` to receive incoming streams 1. Spawns a task that continuously calls `connection.accept_bi()` to receive incoming streams
2. For each accepted stream, reads `EventEnvelope` frames using `FrameFramedReader` 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` 4. Writes response `EventEnvelope` frames using `FrameFramedWriter`
5. Manages `PendingRequestMap` for outgoing calls initiated by the server 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: For outgoing calls (server → client), the adapter:
1. Opens a bidirectional stream with `connection.open_bi()` 1. Opens a bidirectional stream with `connection.open_bi()`
2. Sends `call.requested` on that stream 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` | | 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 | | 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` | | 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 ## 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-030: PeerEntry and Identity.id decoupling (`PeerId` source)
- ADR-032: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`) - ADR-032: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
- ADR-034: Outgoing-only X.509 and the three peer roles - 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/` - Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`

View File

@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-06-28 last_updated: 2026-07-02
--- ---
# alknet-call — Client and Adapters # alknet-call — Client and Adapters
@@ -323,8 +323,21 @@ The flow (ADR-017 §3):
3. For each discovered op, construct a `HandlerRegistration`: 3. For each discovered op, construct a `HandlerRegistration`:
- `spec` mirrors the remote op's name (with optional prefix), namespace, - `spec` mirrors the remote op's name (with optional prefix), namespace,
type, schemas, access control. type, schemas, access control.
- `handler` is a forwarding handler: sends `call.requested` through the - `handler` is a forwarding handler, **branched on `op_type`** (ADR-049):
`CallConnection`, awaits `call.responded` (or streams for subscriptions). - `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` - `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
(leaf — ADR-022). (leaf — ADR-022).
4. The caller registers the bundles via 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 | | 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` | | 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 | | 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` | | 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` | | 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 | | 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 status: draft
last_updated: 2026-06-27 last_updated: 2026-07-02
--- ---
# Operation Registry # Operation Registry
@@ -91,19 +91,75 @@ Operations with empty `AccessControl` (no required scopes, no resource checks) a
### Handler ### 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 ```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
- `input: Value` — the deserialized `payload` from the `call.requested` event (always `serde_json::Value`) (always `serde_json::Value`)
- `context: OperationContext` — request ID, identity, metadata, env - `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 ### 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: 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 - `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, provenance, composition authority, scoped env, capabilities. - `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 result - `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) - `list_operations()`: Return all registered specs (for `/services/list` — returns curated + active overlay ops)
### Request ID Generation ### Request ID Generation
@@ -229,15 +286,23 @@ The registration bundle carries everything the dispatch path needs to construct
```rust ```rust
pub struct HandlerRegistration { pub struct HandlerRegistration {
pub spec: OperationSpec, pub spec: OperationSpec,
pub handler: Handler, pub handler: HandlerKind, // Once or Stream — validated against spec.op_type (ADR-049)
pub provenance: OperationProvenance, pub provenance: OperationProvenance,
pub composition_authority: Option<CompositionAuthority>, // None for leaves 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, pub capabilities: Capabilities,
// NOTE: ADR-028 added `remote_safe: bool` here; ADR-029 supersedes it and // NOTE: ADR-028 added `remote_safe: bool` here; ADR-029 supersedes it and
// removes the field. Peer authorization is `AccessControl::check(peer_identity)`, // removes the field. Peer authorization is `AccessControl::check(peer_identity)`,
// not a per-op boolean. See ADR-029 §3. // 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 #### 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. - `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). - `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 ```rust
// with_local: Local provenance, full bundle — all 5 args required. // with_local: Local provenance, full bundle — all 5 args required.
// with_local(spec, handler, composition_authority, scoped_env, capabilities) // 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() let registry = OperationRegistryBuilder::new()
// Built-in service discovery (Local, no composition — empty authority, empty env, empty caps) // Built-in service discovery (Local, no composition — empty authority, empty env, empty caps)
.with_local(services_list_spec(), Arc::new(services_list_handler), .with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new()) CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler), .with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new()) CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
// Agent handler (Local, composes — authority + scoped env + capabilities) // Agent handler (Local, Subscription — streams call.responded as the
.with_local(agent_chat_spec(), Arc::new(agent_chat_handler), // 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"]), CompositionAuthority::new("agent-chat", ["llm:call", "fs:read", "vastai:query"]),
ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]), ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]),
Capabilities::new().with_api_key("google", google_api_key)) 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. 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 ```rust
/// The composition dispatch trait. A handler composes child operations /// The composition dispatch trait. A handler composes child operations
/// through its `OperationContext.env` (which implements this trait). /// through its `OperationContext.env` (which implements this trait).
@@ -673,10 +743,11 @@ let registry = OperationRegistryBuilder::new()
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new()) CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler), .with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new()) 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 { .with(HandlerRegistration {
spec: agent_chat_spec(), spec: agent_chat_spec(),
handler: Arc::new(agent_chat_handler), handler: HandlerKind::Stream(Arc::new(agent_chat_streaming_handler)),
provenance: OperationProvenance::Local, provenance: OperationProvenance::Local,
composition_authority: Some(CompositionAuthority::new( composition_authority: Some(CompositionAuthority::new(
"agent-chat", ["llm:call", "fs:read", "vastai:query"])), "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. - **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. - **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. - **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 ## 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) | | 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 | | 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 | | ~~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 ## 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-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-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-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/` - Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`

View File

@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-01 last_updated: 2026-07-02
--- ---
# HTTP Adapters — from_openapi and to_openapi # HTTP Adapters — from_openapi and to_openapi
@@ -123,8 +123,8 @@ The adapter:
### Forwarding handler ### Forwarding handler
The forwarding handler is the `Arc<dyn Handler>` stored in the The forwarding handler is stored in the `HandlerRegistration` as a
`HandlerRegistration`. At call time, it: `HandlerKind` (ADR-049). At call time, it:
1. Reads the call input (`serde_json::Value`). 1. Reads the call input (`serde_json::Value`).
2. Builds the outbound HTTP request: 2. Builds the outbound HTTP request:
@@ -138,15 +138,23 @@ The forwarding handler is the `Arc<dyn Handler>` stored in the
below). below).
4. For a `Query`/`Mutation`: parses the response body (JSON, text, or 4. For a `Query`/`Mutation`: parses the response body (JSON, text, or
binary — same content-type branching as the TS `createHTTPOperation`), 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 5. For a `Subscription` (`text/event-stream` response): streams
`call.responded` events as the SSE chunks arrive (same SSE parsing as `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 6. On HTTP error (non-2xx): maps to the declared `ErrorDefinition` by
HTTP status code (see Error Fidelity below), returns a `CallError`. HTTP status code (see Error Fidelity below), returns a `CallError`.
The handler is opaque to the `CallAdapter` — it's an `Arc<dyn Handler>` The handler is opaque to the `CallAdapter` — it's a `HandlerKind` the
the registry dispatches. `alknet-call` never sees `reqwest`. registry dispatches (via `invoke()` for `Once`, `invoke_streaming()` for
`Stream`). `alknet-call` never sees `reqwest`.
### HTTP client (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 `from_openapi` maps OpenAPI non-2xx response status codes to
`ErrorDefinition`s (ADR-023 §5). The normative rule (review #002 W20): `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`, 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: `HTTP_` and the status number:
```rust ```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 | | 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` 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 | | `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 ## Open Questions

View File

@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-01 last_updated: 2026-07-02
--- ---
# HTTP MCP — from_mcp and to_mcp # HTTP MCP — from_mcp and to_mcp
@@ -78,10 +78,12 @@ The adapter:
namespace prefix is configured — same local-naming sugar as namespace prefix is configured — same local-naming sugar as
`from_call`'s `FromCallConfig::namespace_prefix`, ADR-029 §5). `from_call`'s `FromCallConfig::namespace_prefix`, ADR-029 §5).
- `spec.namespace` = the configured `namespace`. - `spec.namespace` = the configured `namespace`.
- `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP - `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP
spec doesn't have a native streaming/tool-subscription distinction spec doesn't have a native streaming/tool-subscription distinction
`tools/call` returns a result. If MCP adds a streaming-tool `tools/call` returns a result. If MCP adds a streaming-tool
extension, a `Subscription` mapping would be added.) 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.visibility` = `Internal` (adapter-registered, ADR-015).
- `spec.input_schema` = the tool's `inputSchema` (JSON Schema). - `spec.input_schema` = the tool's `inputSchema` (JSON Schema).
- `spec.output_schema` = depends on whether the tool declares - `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 registration (the MCP server is a persistent streamable HTTP
endpoint, not a per-call connection). endpoint, not a per-call connection).
The handler is opaque to the `CallAdapter``Arc<dyn Handler>` the The handler is opaque to the `CallAdapter`a `HandlerKind::Once`
registry dispatches. `alknet-call` never sees rmcp. wrapping an `Arc<dyn Handler>` that the registry dispatches. `alknet-call`
never sees rmcp.
### Output handling (structuredContent vs content blocks) ### 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 (request/response). `Subscription` operations (streaming) are filtered
out of `search` results and cannot be invoked via `call` — MCP tool out of `search` results and cannot be invoked via `call` — MCP tool
calls are request/response by protocol design; streaming subscriptions 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 #### `to_mcp` service behavior
@@ -263,19 +271,19 @@ axum route handlers) are genuinely per-gateway and are not shared.
Research findings Research findings
(`docs/research/alknet-http-gateway-factoring/findings.md`) recommend (`docs/research/alknet-http-gateway-factoring/findings.md`) recommend
extracting a **thin shared spine** (a concrete struct holding extracting a **thin shared spine** (the concrete `GatewayDispatch` struct
`Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>` with a holding `Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>` with a
`resolve + build_context + invoke` method returning a `resolve + build_context + invoke` method returning a `ResponseEnvelope`,
`ResponseEnvelope`), **not** a `GatewayDispatch` trait or gateway named in ADR-049 and extended with `invoke_streaming()` for the streaming
abstraction. The spine is small (~1530 lines per endpoint), but it is path), **not** a trait or gateway abstraction. The spine is small (~1530
the one place where a divergence bug (identity resolved differently, lines per endpoint), but it is the one place where a divergence bug
`OperationContext.internal` set inconsistently, `CallError` mapped (identity resolved differently, `OperationContext.internal` set
asymmetrically) would be a security/correctness issue. The inconsistently, `CallError` mapped asymmetrically) would be a
server-integration and wire-framing layers stay per-gateway; a third security/correctness issue. The server-integration and wire-framing layers
gateway (GraphQL, gRPC) is not on the horizon, and if one appears its stay per-gateway; a third gateway (GraphQL, gRPC) is not on the horizon,
server-integration layer needs its own shape anyway. This is an and if one appears its server-integration layer needs its own shape anyway.
implementation factoring note, not an ADR — the decision is internal to This is an implementation factoring note, not an ADR — the decision is
`alknet-http` and does not cross crate boundaries. internal to `alknet-http` and does not cross crate boundaries.
### No-Env-Vars ### 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 | | 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 | | 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` | | 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 ## Open Questions

View File

@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-01 last_updated: 2026-07-02
--- ---
# HTTP Server # 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: `Accept: text/event-stream` on the `POST`). The axum route handler:
- Sets `Content-Type: text/event-stream`. - Sets `Content-Type: text/event-stream`.
- For each `call.responded` event, writes an SSE `data:` frame (the - Calls `GatewayDispatch::invoke_streaming()` (ADR-049) — the streaming
event's `output` serialized as JSON). analogue of `invoke()`, returning a `BoxStream<ResponseEnvelope>`. The
- On `call.completed`, closes the SSE stream (normal end). security invariants are identical to `invoke()`: `internal: false`,
- On `call.aborted`, closes the stream with an SSE error event. `forwarded_for: None`, same capabilities, same `scoped_env`, same ACL
- On HTTP client disconnect (detected as the response writer closing), check before dispatch. The two methods diverge only on the return shape
sends `call.aborted` for the in-flight subscription, which cascades (stream vs single envelope).
to descendants per ADR-016. - 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 This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket
([websocket.md](websocket.md)), the subscription projects directly ([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 onto WebTransport bidirectional streams; see
[webtransport.md](webtransport.md). [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) ### One-directional projection (HTTP request/response)
The HTTP/1.1 + HTTP/2 surface is a **lossy, one-directional projection** 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) | | 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 | | 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 | | 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 ## Open Questions

View File

@@ -2,19 +2,23 @@
## Status ## Status
Accepted Accepted (amended by ADR-049 — protocol-level code list extended to six)
## Context ## Context
The `OperationSpec` in alknet-call has `input_schema` and `output_schema` but The `OperationSpec` in alknet-call has `input_schema` and `output_schema` but
no `error_schemas`. The `call.error` payload (call-protocol.md L128134) no `error_schemas`. The `call.error` payload (call-protocol.md L128134)
carries a `code` and `message`, where `code` is one of five infrastructure carries a `code` and `message`, where `code` is one of six infrastructure
codes: `NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`. 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, 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 reject the wrong dispatch method for the operation type, time out, or hit
machinery (the registry, the adapter), not by operation handlers. 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: 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`, - `code` — the error code. Either a protocol-level code (`NOT_FOUND`,
`FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`) or an `FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`,
operation-level domain code from `error_schemas` (e.g., `TIMEOUT`) or an operation-level domain code from `error_schemas` (e.g.,
`FILE_NOT_FOUND`, `RATE_LIMITED`). `FILE_NOT_FOUND`, `RATE_LIMITED`).
- `message` — human-readable error message. Unstructured — for logging and - `message` — human-readable error message. Unstructured — for logging and
debugging, not for programmatic handling. Clients should switch on debugging, not for programmatic handling. Clients should switch on
@@ -182,7 +186,7 @@ optional-array convention.
### 3. Protocol-level vs operation-level error codes ### 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: machinery, not by handlers:
| Code | Emitted by | Meaning | | Code | Emitted by | Meaning |
@@ -190,6 +194,7 @@ machinery, not by handlers:
| `NOT_FOUND` | Registry | Operation not registered (or Internal op called from wire) | | `NOT_FOUND` | Registry | Operation not registered (or Internal op called from wire) |
| `FORBIDDEN` | Registry / ACL | Caller lacks required scopes, or unauthenticated | | `FORBIDDEN` | Registry / ACL | Caller lacks required scopes, or unauthenticated |
| `INVALID_INPUT` | Registry | Input doesn't match `input_schema` | | `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 | | `INTERNAL` | Registry / Adapter | Handler panic, unhandled error, connection failure |
| `TIMEOUT` | Adapter | Request timed out | | `TIMEOUT` | Adapter | Request timed out |
@@ -242,8 +247,9 @@ accordingly.
``` ```
**Normative rule (review #002 W20)**: `from_openapi` must not produce error **Normative rule (review #002 W20)**: `from_openapi` must not produce error
codes that collide with the five protocol-level codes (`NOT_FOUND`, codes that collide with the six protocol-level codes (`NOT_FOUND`,
`FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`). The adapter prefixes `FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`,
`TIMEOUT`). The adapter prefixes
imported error codes with `HTTP_` and the status number (e.g., `HTTP_404`, 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 `HTTP_429`) to avoid collision. This is a requirement for the adapter, not
a naming convention — the `from_openapi` example above was previously shown 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) for OS-level permission issues)
- docs/reviews/001-pre-implementation-architecture-sanity-check.md - docs/reviews/001-pre-implementation-architecture-sanity-check.md
(finding C5, which this ADR resolves) (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 - docs/sdd_process.md L19, L423 (Safe Exit protocol — the general principle
of making failure typed and declared) of making failure typed and declared)
- TypeScript reference: `/workspace/@alkdev/operations/src/types.ts` - 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 status: draft
last_updated: 2026-06-30 last_updated: 2026-07-02
--- ---
# Open Questions # Open Questions
@@ -316,7 +316,12 @@ These questions are acknowledged but not active. They will be promoted to open w
- **Status**: resolved - **Status**: resolved
- **Door type**: One-way (wire format), two-way (mapping mechanism) - **Door type**: One-way (wire format), two-way (mapping mechanism)
- **Priority**: high - **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) - **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 ## Theme: Call Client and Adapters
@@ -910,3 +915,43 @@ is a feature extension, not an unmade architecture decision.
- **Cross-references**: ADR-014, ADR-017, ADR-035, - **Cross-references**: ADR-014, ADR-017, ADR-035,
[http-adapters.md](crates/http/http-adapters.md), [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)