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:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-23
|
||||
last_updated: 2026-07-02
|
||||
---
|
||||
|
||||
# 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)
|
||||
- `FORBIDDEN` — access denied (insufficient scopes or unauthenticated)
|
||||
- `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
|
||||
- `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 } }` |
|
||||
| `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
|
||||
|
||||
@@ -405,10 +406,14 @@ The `CallAdapter::handle()` method:
|
||||
|
||||
1. Spawns a task that continuously calls `connection.accept_bi()` to receive incoming streams
|
||||
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`
|
||||
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:
|
||||
1. Opens a bidirectional stream with `connection.open_bi()`
|
||||
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` |
|
||||
| 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` |
|
||||
| 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
|
||||
|
||||
@@ -615,4 +621,5 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
- ADR-030: PeerEntry and Identity.id decoupling (`PeerId` source)
|
||||
- ADR-032: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
|
||||
- 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/`
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-28
|
||||
last_updated: 2026-07-02
|
||||
---
|
||||
|
||||
# alknet-call — Client and Adapters
|
||||
@@ -323,8 +323,21 @@ The flow (ADR-017 §3):
|
||||
3. For each discovered op, construct a `HandlerRegistration`:
|
||||
- `spec` mirrors the remote op's name (with optional prefix), namespace,
|
||||
type, schemas, access control.
|
||||
- `handler` is a forwarding handler: sends `call.requested` through the
|
||||
`CallConnection`, awaits `call.responded` (or streams for subscriptions).
|
||||
- `handler` is a forwarding handler, **branched on `op_type`** (ADR-049):
|
||||
- `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`
|
||||
(leaf — ADR-022).
|
||||
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 |
|
||||
| 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 |
|
||||
| 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` |
|
||||
| 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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-27
|
||||
last_updated: 2026-07-02
|
||||
---
|
||||
|
||||
# Operation Registry
|
||||
@@ -91,19 +91,75 @@ Operations with empty `AccessControl` (no required scopes, no resource checks) a
|
||||
|
||||
### 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
|
||||
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 (always `serde_json::Value`)
|
||||
- `input: Value` — the deserialized `payload` from the `call.requested` event
|
||||
(always `serde_json::Value`)
|
||||
- `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
|
||||
|
||||
@@ -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:
|
||||
|
||||
- `register(registration)`: Add an operation to the curated layer at startup
|
||||
- `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.
|
||||
- `invoke(name, input, context)`: Look up, check ACL, invoke handler, return result
|
||||
- `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 (`HandlerKind`), provenance, composition authority, scoped env, capabilities.
|
||||
- `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)
|
||||
|
||||
### Request ID Generation
|
||||
@@ -229,15 +286,23 @@ The registration bundle carries everything the dispatch path needs to construct
|
||||
```rust
|
||||
pub struct HandlerRegistration {
|
||||
pub spec: OperationSpec,
|
||||
pub handler: Handler,
|
||||
pub handler: HandlerKind, // Once or Stream — validated against spec.op_type (ADR-049)
|
||||
pub provenance: OperationProvenance,
|
||||
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,
|
||||
// NOTE: ADR-028 added `remote_safe: bool` here; ADR-029 supersedes it and
|
||||
// removes the field. Peer authorization is `AccessControl::check(peer_identity)`,
|
||||
// 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
|
||||
@@ -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.
|
||||
- `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
|
||||
// with_local: Local provenance, full bundle — all 5 args required.
|
||||
// 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()
|
||||
// Built-in service discovery (Local, no composition — empty authority, empty env, empty caps)
|
||||
.with_local(services_list_spec(), Arc::new(services_list_handler),
|
||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||
.with_local(services_schema_spec(), Arc::new(schema_handler),
|
||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||
// Agent handler (Local, composes — authority + scoped env + capabilities)
|
||||
.with_local(agent_chat_spec(), Arc::new(agent_chat_handler),
|
||||
// Agent handler (Local, Subscription — streams call.responded as the
|
||||
// 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"]),
|
||||
ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]),
|
||||
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.
|
||||
|
||||
**`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
|
||||
/// The composition dispatch trait. A handler composes child operations
|
||||
/// through its `OperationContext.env` (which implements this trait).
|
||||
@@ -673,10 +743,11 @@ let registry = OperationRegistryBuilder::new()
|
||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||
.with_local(services_schema_spec(), Arc::new(schema_handler),
|
||||
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 {
|
||||
spec: agent_chat_spec(),
|
||||
handler: Arc::new(agent_chat_handler),
|
||||
handler: HandlerKind::Stream(Arc::new(agent_chat_streaming_handler)),
|
||||
provenance: OperationProvenance::Local,
|
||||
composition_authority: Some(CompositionAuthority::new(
|
||||
"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.
|
||||
- **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.
|
||||
- **`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
|
||||
|
||||
@@ -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) |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -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-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-049: Streaming handler for subscriptions (`StreamingHandler`, `HandlerKind`, `invoke_streaming()`, `INVALID_OPERATION_TYPE`)
|
||||
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`
|
||||
Reference in New Issue
Block a user