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

@@ -37,30 +37,36 @@ status, priority, and (when resolved) a resolution citing the ADR.
| OQ-20 | ~~API key asymmetry~~ | dissolved | medium | PeerEntry supports multiple credential paths |
| OQ-21 | X.509 outgoing-only case | resolved | medium | Three remote roles; PeerEntry asymmetry correct |
## Call Protocol — Pub/Sub (NEW)
## Call Protocol — Pub/Sub
| OQ | Title | Status | Priority | Resolution |
|----|-------|--------|----------|------------|
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | open | high | Not yet resolved. The call protocol has `subscribe` (consumer → producer: "send me a stream") but no `pub` (producer pushes to subscribers it didn't directly receive a call.requested from). Surfaced during channels spec work — `channel/resources/subscribe` (ADR-037) needs fan-out. See §"Pub/Sub Gap" below. |
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | partially resolved | high | ADR-046 resolves the primitive: `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch path. The fan-out/broker mechanism (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. See §"Pub/Sub Gap" below. |
### Pub/Sub Gap
The call protocol's `StreamingHandler` / `invoke_streaming()` path
(ADR-021) is point-to-point: a `call.requested` arrives, the handler
produces a stream of `call.responded` events back to that one caller.
There is no mechanism for a producer to push events to subscribers that
it didn't directly receive a `call.requested` from.
There was no mechanism for a producer to stream data *to* a responder
(client→server streaming), and no fan-out (one producer, N consumers).
The first consumer that needs this is the channels `channel/resources/
subscribe` operation (ADR-037): the hub aggregates worker resources and
needs to push live updates to N browser subscribers when any worker's
resource set changes. Each browser's `subscribe` arrives on the hub's
channel 0, but the worker resource changes arrive on separate worker
connections. The hub needs to fan-out.
**ADR-046** resolves the directional gap: `OperationType::Pub` is the
client→server streaming complement to `Sub` (was `Subscription`,
renamed for symmetry). `HandlerKind::Sink` is the consuming handler
type. `call.published` is the wire event carrying stream chunks.
`invoke_sink()` is the dispatch path. `CallConnection::publish()` is
the client method. The `Subscription` variant is renamed to `Sub`
(wire string `"sub"`).
**Blocked on**: An ADR specifying the pub primitive's shape — topic-based
fan-out vs. producer-side push to existing subscriptions. This is the
next ADR to write (alkcall ADR-046).
**Fan-out deferred.** The broker (topic registry, `Pub``Sub` matching,
N-consumer fan-out) is deferred to the channels session. The call
protocol is point-to-point (one initiator, one responder, a stream
between them); a topic registry that outlives individual calls is a
different lifecycle and a different concern. The broker's first
consumer is the channels `channel/resources/subscribe` operation
(ADR-037) and the hub-as-broker pattern (ADR-042). The `Pub` primitive
is the load-bearing piece the broker composes on.
## Channels