feat: add Pub operation type, HandlerKind::Sink, call.published wire event (ADR-046)

The call protocol had Subscription (server→client streaming) but lacked
the directional complement: client→server streaming, where the initiator
produces a stream and the responder's handler consumes it. This gap was
inherited from the @alkdev/pubsub EventEnvelope prior art, which has
subscribe but no wire-level publish.

ADR-046 adds the Pub primitive:
- OperationType::Pub (client→server streaming)
- OperationType::Subscription renamed to Sub (wire: "subscription" → "sub")
- SinkHandler type + HandlerKind::Sink variant
- PublishStream type alias (Stream<Item = Result<Value, CallError>>)
- OperationRegistry::invoke_sink() dispatch path
- call.published wire event (sixth event type, additive)
- OperationSpec.publish_schema (Option<Value>, validates per-chunk input)
- DispatchResult::Sink + SinkDispatch (handler future + chunk channel)
- Dispatcher::pump_sink (feeds call.published chunks from wire to handler)
- CallConnection::publish() / publish_with_payload() client methods
- from_call sink forwarding handler (make_sink_forwarding_handler)
- make_sink_handler() helper

Fan-out/broker (one producer, N consumers, topic matching) is deferred to
the channels session — the call protocol is point-to-point; the broker is
a routing concern that sits above it. The Pub primitive is the
load-bearing piece the broker will compose on.

- 23 new tests (366 total, up from 343)
- clippy clean, fmt clean

Verification:
  cargo test                                    — 366 passed
  cargo clippy --all-targets -- -D warnings      — clean
  cargo fmt --check                              — clean
This commit is contained in:
2026-08-12 08:06:46 +00:00
parent cc470a363a
commit ea66398c88
12 changed files with 1821 additions and 90 deletions

View File

@@ -53,7 +53,8 @@ pub struct OperationSpec {
pub enum OperationType {
Query, // Read-only, idempotent (e.g., "fs/readFile", "services/list")
Mutation, // Side effects (e.g., "bash/exec", "github/authenticate")
Subscription, // Streaming (e.g., "agent/chat", "events/subscribe")
Sub, // Server→client streaming (e.g., "agent/chat", "events/subscribe") — was Subscription (ADR-046)
Pub, // Client→server streaming (e.g., "fs/upload") — ADR-046
}
pub enum Visibility {
@@ -154,10 +155,11 @@ Operations with empty `AccessControl` (no required scopes, no resource checks) a
### Handler
There are two handler types, one per dispatch shape — mirroring the
There are three 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-021.
`AsyncGenerator`). The split is locked by ADR-021 (Once/Stream) and
ADR-046 (Sink).
```rust
/// Request/response handler — Query and Mutation operations.
@@ -166,7 +168,7 @@ pub type Handler = Arc<
+ Send + Sync,
>;
/// Streaming handler — Subscription operations. Returns a stream of
/// Streaming handler — Sub 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<
@@ -175,6 +177,20 @@ pub type StreamingHandler = Arc<
+ Send + Sync,
>;
/// Sink handler — Pub operations (ADR-046). Receives the initiator's
/// data stream (each `call.published` chunk's `input` as one `Value`
/// item) and returns a single `ResponseEnvelope` (the result of
/// consuming the stream).
pub type SinkHandler = Arc<
dyn Fn(
Value,
OperationContext,
Pin<Box<dyn Stream<Item = Result<Value, CallError>> + Send>>,
) -> Pin<Box<dyn Future<Output = 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>
@@ -182,6 +198,17 @@ pub type StreamingHandler = Arc<
/// 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>>;
/// The publish stream shape consumed by a `SinkHandler` (ADR-046). Each
/// item is one published chunk: `Ok(value)` for a `call.published` event,
/// `Err(call_error)` for an initiator-side error.
pub type PublishStream = Pin<Box<dyn Stream<Item = Result<Value, CallError>> + Send>>;
pub enum HandlerKind {
Once(Handler),
Stream(StreamingHandler),
Sink(SinkHandler),
}
```
Both handlers are async — many operations (file I/O, HTTP service calls,
@@ -933,6 +960,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
| Forwarded-for identity | [ADR-026](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 |
| Streaming handler for subscriptions | [ADR-021](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 |
| Dynamic resource ownership for runtime-spawned resources | [ADR-011](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait, ADR-033 repo/adapter pattern); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-017/022 unchanged |
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (client→server streaming); `SinkHandler` + `HandlerKind::Sink` + `PublishStream`; `invoke_sink()` dispatch path; `call.published` wire event; `OperationSpec.publish_schema`; `Subscription` renamed to `Sub`; fan-out/broker deferred to channels |
## Open Questions