diff --git a/docs/architecture/decisions/022-call-protocol-client-and-adapter-contract.md b/docs/architecture/decisions/022-call-protocol-client-and-adapter-contract.md index 915a1b7..f9cfdea 100644 --- a/docs/architecture/decisions/022-call-protocol-client-and-adapter-contract.md +++ b/docs/architecture/decisions/022-call-protocol-client-and-adapter-contract.md @@ -2,7 +2,7 @@ ## Status -Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`; amendment 2026-09-03 — the bootstrap-op set and the connect-side serving loop, see "Amendment (2026-09-03)" below; amendment 2026-09-04 — the `op/register` collision policy, in that amendment's "Collision policy" paragraph) +Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`; amendment 2026-09-03 — the bootstrap-op set and the connect-side serving loop, see "Amendment (2026-09-03)" below; amendment 2026-09-04 — the `op/register` collision policy, in that amendment's "Collision policy" paragraph; amendment 2026-10-13 — announced Sub/Pub forward-through and marked-spec refusal (review 010 S-04), see "Amendment (2026-10-13)" below) ## Context @@ -622,4 +622,59 @@ shared dial seam is `alknet-client`; `spawn_dispatch` is the protocol-crate take-over that consumes the dial's `Connection`. See [client-and-adapters.md](../crates/call/client-and-adapters.md) -§"CallClient" for the reframed operational spec. \ No newline at end of file +§"CallClient" for the reframed operational spec. + +## Amendment (2026-10-13 — review 010 S-04: announced Sub/Pub forward-through, marked-spec refusal) + +`op/register`'s announced Sub/Pub handling shipped as invocation-failing +stubs (`INVALID_OPERATION_TYPE` on every invocation of an advertised, +discoverable op — review 010 S-04, nine published versions). The +amendment replaces the stubs with the real forwarding machinery and +closes the marked-spec hole the stubs sat in: + +1. **Unmarked `Sub` announcements** register as real + `HandlerKind::Stream` handlers + (`make_streaming_forwarding_handler`) — a nested composition that + cannot invoke them anyway (`env.invoke` is request/response-only, + ADR-046 §6), but the overlay now carries a genuine working forward + of the remote stream, not an advertised-always-failing body. +2. **Unmarked `Pub` announcements** register as real + `HandlerKind::Sink` handlers (`make_sink_forwarding_handler`) — + same posture; the shapes already exist in-tree (`from_call`'s own + import path). +3. **Marked (`channel_open`) announcements — any type — are refused at + `op/register`** with a typed error (`MARKED_SPEC_UNSUPPORTED`): + marked ops are channel-machinery ops by their own marker contract + (ADR-047 §2 — the data plane is the allocated channel's + `BiStream`). Forwarding a marked op is incoherent by the marker's + own semantics — a `Sub`-side forward would deliver the + `{channel_id}` ack of a channel that exists only on the remote + side; a `Pub`-side forward would stream call-plane chunks into a + surface alkcall's amendment 4 rejects. The announcing peer's + sanctioned compositions are the relay + (`ChannelRelay::register_relay_openable`) for a hub, and direct + consumption (`ChannelClient`) otherwise. The same refusal applies + to overlay imports generally: `CallConnection::register_imported` + and the generic registration path refuse marked specs that did not + come through the channel-machinery seam (ADR-047 amendment 4 + §4.8), so a marked spec can no longer land anywhere as an + advertised-always-failing or advertised-but-unusable body. + +The stub-era rationale ("nested composition is request/response-only; +the streaming/sink forwarding shapes ride on the `from_call` import +path") explained why the *stub body* existed, not why the *announcement +was accepted*; it was reviewed and rejected in review 010 (S-04) — the +rationale may justify not implementing nested streaming composition, +but not accepting and advertising an announcement that cannot serve it. + +Door type: one-way. The refusal code and the marked-spec overlay +invariant are wire-visible (announcing peers observe the rejection) and +join the stable error set per ADR-016. The handler-kind mapping for +unmarked announcements reuses the existing forwarding shapes — no new +surface. + +References: review 010 S-04 (the stub), +`src/registry/op_register.rs` (the handler), ADR-047 amendment 4 §4.8 +(the marked-spec registration invariant this enforces on the wire +side), ADR-046 §6 (the composition boundary), ADR-051 (the relay +composition marked announcements are refused in favor of). \ No newline at end of file diff --git a/docs/architecture/decisions/047-openable-alpns-are-operations.md b/docs/architecture/decisions/047-openable-alpns-are-operations.md index 92e1006..c261963 100644 --- a/docs/architecture/decisions/047-openable-alpns-are-operations.md +++ b/docs/architecture/decisions/047-openable-alpns-are-operations.md @@ -14,7 +14,265 @@ per-op `description` on the listing, the dynamic half stays deferred — see "Amendment (§6 listing enrichment, 2026-09-06)" below; amendment 3 (2026-09-16, review 008 U-1) — the op-name convention extends to the flavor form and discovery carries an explicit -`channel_open_alpn` for non-derivable names — see "Amendment 3" below) +`channel_open_alpn` for non-derivable names — see "Amendment 3" below; +amendment 4 (2026-10-13, reviews 010 S-01 / 011) — the marked-pub open +contract: the initiator-allocated channel, the adopt path, the wrapper +as responder of record, zero `call.published` on marked ops, the relay +translation, and the client-side put-open capability — see "Amendment 4" +below) + +## Amendment 4 (the marked-pub open contract, 2026-10-13 — review 010 S-01, review 011) + +`channels//pub` open ops were registered, advertised, and stubbed +(`channel:pub_open_not_implemented` on every invocation — review 010 +S-01, shipped nine published versions). This amendment pins the +implemented contract in place of the stub; review 011 corrected the +remediation sketch before this amendment was drafted (the serving-loop +`PublishStream` shape is the **unmarked** pub semantics and does not +apply to marked ops). + +The shape, in one sentence: **a marked pub open is an ordinary channel +open whose `channel_id` the initiator allocated on its own side** — the +initiator streams bytes on that channel before the open resolves (the +early-arrival parking covers the race), and the responder adopts and +serves the data plane as the responder of record. + +### 4.1 Marker semantics (pin 1) + +The marked pub op's data plane is the adopted channel's `BiStream` — +one direction, initiator → responder, with the ADR-040 bounded buffer +as the backpressure mechanism. **Zero `call.published` data on the call +plane for marked pub ops**: a nonempty publish-chunk stream on a marked +op is rejected loudly with a new wire error (code +`channel:publish_chunks_rejected`, retryable false, message stating +that the data plane is the adopted channel) written in the initiator's +direction when the first chunk arrives; the chunks are never consumed, +and the marked-pub flow continues to its own single response. Two +competing data paths on a marked op must never exist. Unmarked `Pub` +ops are unaffected — they remain the ADR-046 `call.published` stream +through `publish_schema` validation. + +### 4.2 `publish_schema` refused at registration for marked ops (pin 2) + +Publish chunks never exist on a marked op (§4.1), so `publish_schema` +is dead machinery on a marked spec — the registered-but-incapable-of- +mattering class review 010 outlaws. `OperationRegistry::register` +refuses a marked spec that carries `publish_schema` (registration-time +loudness, per the house pattern). + +### 4.3 Reserved input key + dispatch-layer strip (pin 3) + +The adopted channel id crosses the open invocation as the reserved +input key **`"channel_id"`** — the same key the wrapper owns on the +reply side (`RESERVED_REPLY_KEY`, ADR-049 amendment 3); the wrapper +owns the `channel_id` key on both sides of a marked open op. Placement +rationale (review 011 §1.2): schema validation runs in `invoke_*` +before any handler, so the reserved key must be stripped +**pre-validation** in the sink resolve path (`resolve_sink_handler` — +the single source of truth shared by the wire dispatch and +registry-level `invoke_sink`, per review 001 P-08) before +`input_schema` validation; the stripped id is handed to the wrapper via +`OperationContext.metadata` under the key `channel_open_channel_id`. +The producing crate's `input_schema` never contains the key, and +dispatch rejects a marked pub op whose input **lacks** it — the +initiator-allocated id is mandatory, not optional plumbing +(`INVALID_INPUT`, message naming the key). An op input carrying +`channel_id` on a NON-pub or unmarked op is not stripped (their +schemas own their full input surface; the strip and the requirement are +both scoped to marked pub ops). + +### 4.4 The wrapper as responder of record (pin 4) + +The marked-pub open registration is +`ChannelCore::register_pub_openable(spec, establisher, pub_open_handler, +registry, auth, timeout)`. The entry points refuse wrong shapes at +registration: the `Query`/`Mutation`/`Sub` wrapper entry points +(`register_openable*`) refuse `Pub`-typed specs, and +`register_pub_openable` refuses non-`Pub` or unmarked specs. The +handler type is a separate one from `OpenHandler`: + +```rust +pub type PubOpenHandler = Arc< + dyn Fn(Value, Option, Connection, AuthContext) + -> BoxFuture<'static, Result> + + Send + Sync, +>; +``` + +(`JoinHandle<()>` — the `OpenHandler` return — cannot carry the +handler result the merged response needs; see §4.6 for why this is a +separate type rather than an `OpenHandler` change.) + +The wrapper flow (the responder of record): + +1. `policy.check_open(identity)` — unchanged (cap gate, pre-adopt). +2. **Adopt** the initiator-allocated id: + `manager.adopt_channel(reserved_id, alpn, None)` — the existing + demux/mux install + early-arrival drain; no new machinery. Adoption + failure (collision or cap) tears nothing down (nothing was created) + and replies `channel:open_failed` with a new reason **`adopt_failed`** + in the ADR-049 §3 vocabulary. +3. The establisher (bounded, ADR-049 §1/§2 machinery, unchanged) may + validate the offer and contribute ack fields + (`Establishment.reply_fields`); its plan threads to the handler. +4. Spawn the pub handler on the channel's `Connection` — the wrapper + **awaits the handler inline** (review 011 §1.3), which is what makes + R-02's lifetime contract structural here: the handler cannot return + before its pumps finish, so the teardown-at-birth trap does not + exist on this path, and the channel's `ChannelState.handler_task` + stays `None` (teardown is EOF-driven: a `channel/close` during the + put drops the demux sender, the handler's reads see EOF, the handler + finishes early, and the single response is still written — the + handler decides the semantics of a truncated stream). +5. **One response, merge order pinned:** `{ "channel_id": }` + → extended with the establisher's ack fields (ADR-049 amendment 3, + unchanged, including the reserved-key establisher-bug refusal) → + extended with the handler's result fields (handler result wins on + key collision — its computed result postdates the ack). A handler + returning `Err` responds that error directly and the channel tears + down; racing initiator bytes hit EOF and are discarded. +6. The standard completion teardown runs after the await (demux + sender drop, ledger take, `policy.on_close` — the same atomic gate + every teardown path runs, ADR-047 §7). + +A marked pub op registered outside the wrapper family (a plain +`SinkHandler` on a marked pub spec) can never adopt — the bytes would +park and drop. That class is refused at registration (§4.8). + +### 4.5 The ack-timing sentence (pin 5 — protocol semantics) + +**A sink op gets exactly one `call.responded`, produced after the +handler completes — so a putter stream-waits nothing: the +establishment ack (the adopt + establishment reply fields) rides +informatively in that one result, never as a gating ack before the +byte stream.** The fetch/put asymmetry is direction-inherent: fetch +(`Sub`) acks at establishment — the `{channel_id}` response precedes +any byte (and the `{channel_id}` is the ack); put (`Pub`) acks in the +result. This is protocol semantics, not per-consumer folklore: a +consumer protocol layered on marked pub ops (alkblobs' put) cannot +wait for the ack without deadlocking (the response cannot exist until +the stream completes), and the amendment states it so no consumer +re-derives it. + +### 4.6 Why `PubOpenHandler` is a separate type (pin record) + +The alternative — changing `OpenHandler`'s return type — breaks every +published consumer's open handlers (alktty, alkhttp, alktunnels, +alkty, alksocks all register `Sub`-type openables) for zero benefit: +the `()`-returning shape is correct for the spawn-and-reconcile `Sub` +path (the channel outlives the open call; the wrapper never awaits +it), while the put path needs completion-in-response. The split keeps +S-01 capability-additive (review 011 §5: no shipped consumer registers +a `Pub`-typed openable); the put path's type is new API surface, +one-way like its siblings (ADR-046's handler-type door). + +### 4.7 Relay translation replaces `PubTypedOpen` (pin 6) + +`RelayError::PubTypedOpen` is removed. The hub's pub-openable +translation (the put-forwarding shape): + +- The consumer-leg registration composes + `ChannelCore::register_pub_openable` with a relay establisher: the + hub **allocates** its connect-side id on the producer leg + (`open_channel` — odd, the implemented split), sends the open op + carrying the reserved key to the spoke **in the background** (never + awaited inside the establisher — the spoke's put response cannot + exist before its byte stream completes; awaiting it in the + establisher deadlocks the leg), and the `RelayPlan` carries the + spoke-leg send half, the early-arrival-drained read half, the + producer-leg entry reclaim guard (as before, ADR-051), and the + response receiver. +- The relay pub handler (the consumer-leg pub handler): pumps the + consumer-leg channel bytes → spoke send half (one direction, pub); + on consumer EOF, EOFs the spoke side; awaits the spoke's single + response; strips the producer's reserved reply key from the spoke + output (the hub's reply must carry the hub-allocated consumer-leg + id — a spoke `channel_id` merged through would shadow it, the same + reserved-key class the wrapper refuses) and returns the remainder as + the handler result — which the consumer-leg wrapper merges into the + single response with the pinned order. +- The spoke's open-time establishment failure surfaces in the final + response (put-acks-in-result), mapped through the existing + `map_spoke_error` table; the plan's reclaim guard tears down the + producer-leg entry on every handler path, as ADR-051 §6 already + pins for the sub direction. +- The hub-leg template's pub-typed assembly error becomes unreachable + (registered through the relay like the sub ones). + +### 4.8 The registration invariants (the stub-class guard) + +`OperationRegistry::register` enforces, at registration time: + +- A marked spec with `publish_schema` → refused (§4.2). +- A marked spec registered through the generic path → refused: marked + specs are registered only through the channel-machinery seam + (`register_openable*`, `register_pub_openable`, the relay). The + wrapper-family registrations carry an internal registration flag the + generic paths cannot set; `CallConnection::register_imported` (the + overlay landing) and direct `registry.register` both hit the refusal + — for overlay imports because a marked op is unreachable by + composition (`OperationEnv::invoke` is request/response-only; the + marker's data plane has no overlay path), for direct registrations + because the channel machinery would silently not exist. + +### 4.9 The client-side initiator-allocates capability (pin 7) + +`ChannelClient::open_pub_channel(operation_id, input, alpn)`: allocates +the connect-side odd id locally (`open_channel` on its own manager), +inserts the reserved input key, sends the open op **in a background +task**, and returns `(id, send, recv, reply)` immediately — the caller +writes bytes on `send` without waiting for anything (put acks in +result, §4.5). `reply` is the awaited single merged response +(`Ok(Value)` — the full output; `Err(ChannelOpenError)` — the wire +error passthrough, with the local adopt entry torn down on failure). +The existing `open_channel`/`open_channel_with_reply` await the reply +before returning the halves — unusable for a put; this is the +putter-facing shape the wrapper is untestable end-to-end without. + +### Door type + +**One-way (wire-visible).** The reserved input key and its +strip-before-validation, the missing-key rejection, the +chunk-rejection code, the merge order, the single-response semantics, +and the ack-timing sentence are wire-visible from the first consumer +(alkblobs' put — which pends behind this amendment, so this is the +cheapest moment to pin; no shipped consumer rides any of it, review +011 §5). `PubOpenHandler`, `register_pub_openable`, +`open_pub_channel`, and the `adopt_failed` reason are new API surface, +one-way per the handler-API precedent. The early-arrival parking, the +wrapper internals, and the relay plan internals are two-way-door +details inside the one-way contract. + +### Verification gates (implementation units, review 011 §3) + +- known-length put e2e: offer + adopt + bytes → one response, + `{channel_id, ack fields, handler result}` merge order verified; +- unknown-length put e2e: the handler's digest rides the merged + response; +- publish-chunk rejection: a marked pub op carrying nonempty publish + chunks → loud rejection, no handler consumption; +- establishment failure: `channel:open_failed`, channel torn down, + ledger un-incremented, racing bytes EOF-discarded; +- `publish_schema` registration refusal on a marked op; +- relay translation: a hub translating a pub openable end-to-end. +- plus: missing reserved key rejection; `open_pub_channel` happy + + wire-failure paths (the client capability's gates). + +### References + +- Review 010 S-01 (the four stubs; the implement-or-stop-advertising + ruling), review 011 (the corrected shape this amendment pins) +- alkblobs ADR-014 (`014-marked-pub-put-contract-and-alkcall-gate`) — + the downstream ruling this amendment unblocks +- ADR-046 (the unmarked pub semantics that marked ops do NOT ride), + ADR-049 (establishment machinery + `RESERVED_REPLY_KEY` + merge), + ADR-040 (bounded buffer — the data-plane backpressure), ADR-050 + (pumps), ADR-051 (the relay this amendment extends to pub), ADR-043 + (`ChannelClient` — the `open_pub_channel` home) +- `src/channels/manager.rs` (`next_channel_id` odd/even; + `drain_early_arrivals`), `src/channels/operations.rs` (the wrapper + family), `src/channels/relay.rs`, `src/registry/registration.rs` + (`resolve_sink_handler` — the strip site) ## Amendment 3 (flavor-form open-op ids + explicit `channel_open_alpn`, 2026-09-16 — review 008 U-1) diff --git a/docs/architecture/decisions/052-channel-control-surface.md b/docs/architecture/decisions/052-channel-control-surface.md new file mode 100644 index 0000000..7eb2084 --- /dev/null +++ b/docs/architecture/decisions/052-channel-control-surface.md @@ -0,0 +1,149 @@ +# ADR-052: The `channel/control` Control-Handle Surface + +## Status + +Accepted — amends ADR-037 (`channel/control`'s contract) and ADR-047 +(amended §1: the generic ops stay). Resolves review 010 S-02 (the +`channel/control` stub shipped nine published versions; the stub was +pinned by tests and tracked as OQ-39) by implementing the surface the +ADR-037 text always promised. The stub's tracked design work (OQ-39) +is promoted here verbatim-ish: ALPN-crate-registered per-channel +control callbacks, a channels-layer callback registry keyed by +`channel_id`. + +## Context + +ADR-037 specified `channel/control` as the out-of-band control op on +channel 0 (resize, signal, keepalive — anything that does not need +ordering relative to data), with the channels layer routing the +ALPN-specific `message` to a handler's control handle. The +implementation shipped a registered, discoverable stub that checked +`channel` existence and then returned `channel:control_not_implemented` +on every invocation — which perversely returned *not found* for unknown +channels and *not implemented* for real ones (review 010 S-02). The +implementation was tracked as OQ-39 with the shape recorded; nothing +blocked it except that no ALPN crate had asked yet. alktty's move of +its control surface to the channels path (ADR-049 §5's migration list) +and the general stub ruling (review 010) make that answer invalid. + +The design constraint from ADR-039: `ChannelManager` is auth-blind, +ALPN-blind, transport-blind. The control-handle surface must not +reintroduce ALPN interpretation into the manager. + +## Decision + +### 1. The control callback type and registry + +```rust +pub type ChannelControl = Arc< + dyn Fn(Value) -> BoxFuture<'static, Result> + Send + Sync, +>; +``` + +The callback receives the `message` value (ALPN-specific JSON; the +channels layer does not interpret it) and returns the ALPN-specific +reply (or a `CallError` it wants surfaced verbatim — e.g. +`target_gone`). The live handle: `HashMap` in the +`ChannelManager`'s internals (the same map discipline as `channels`; +`parking_lot` mutex). + +The manager stays blind to what a callback does — it stores opaque +closures, exactly as `ChannelState` already stores opaque `handler_task` +handles. + +### 2. Registration: the establisher contributes the control handle + +The ALPN crate registers a control callback by returning it from its +open-op establisher: `Establishment::with_control(callback)` (builder, +alongside `with_reply_field` — ADR-049 amendment 3). The **wrapper** +installs it under the allocated/adopted id +(`manager.set_control_handle(id, callback)`) and every teardown path +removes it (`teardown_channel` drops the entry; `clear_all` drains +them) — same closure-lifecycle discipline as the opener ledger. + +Why the establisher, not the handler: the establisher already returns +typed payloads the wrapper routes (`Establishment`), it runs where the +ALPN crate has prepared its backend (the resize target is the backend +the establisher dialled/planned), and adding a registration path +through the `OpenHandler` would require handing the handler the +manager and the id — a signature break across every published consumer +for zero benefit. The wrapper-owned installation also makes the +callback's lifetime exactly the channel's lifetime by construction. + +### 3. The op: routing and error surface + +`make_control_handler` (the ADR-037 op, registered by +`ChannelOps::register_on`) becomes: validate `channel_id` (unchanged) +→ `channel` exists? (unchanged; `channel:unknown_channel`) → callback +registered for the id? → + +| case | reply | +|------|-------| +| unknown channel | `channel:unknown_channel` (existing class) | +| no callback registered | `channel:no_control_handle` — typed, retryable false, message: the channel's ALPN registered no control surface (`details: { alpn }`) | +| callback ok | `{ "control": }` | +| callback err | the callback's `CallError` verbatim | + +`channel:no_control_handle` is a real capability answer, not a stub: +a consumer that calls `channel/control` on a channel whose ALPN chose +not to expose control learns exactly that. The `message` body is +ALPN-specific JSON (unchanged; the channels layer does not read it). + +**The callback receives the message only** — no identity, no +`OperationContext`. The authorization home is the op's own +`access_control` (checked by the registry before the handler, per +ADR-010/017 — the same op-level ACL every other surface runs); a +per-owner resize is expressed as ADR-011 `resource_id_path` on the +`channel/control` spec for ALPNs that want it, not by widening the +callback signature. Wire-visibility: `channel:no_control_handle` +joins the stable error set (ADR-016 — consumers branch on it +deliberately); the callback's reply shape is the producing crate's +schema concern. + +### 4. What this does not decide + +Ordering-sensitive control rides the data channel (unchanged — +channel-operations.md's control-message division, DP-4 pinned). The +manager keeps its blindness; the enumerators registry of ADR-053 is +the analogous ALPN-level surface for discovery, not control. + +## Consequences + +**Positive:** the advertised op serves; S-02 dies; ALPN crates get +out-of-band control for free (resize/signal arrive as one routed +call-op instead of per-crate in-band framing); teardown-bound callback +lifetime is structural. + +**Negative:** the manager's internals gain one map (opaque closures — +no blindness regression); a control callback racing teardown can +observe a torn-down backend through its own error (`CallError`) — the +same shape the handler EOF path already produces, not a new class. + +## Door type + +**One-way (wire-visible).** `channel:no_control_handle` and the reply +envelope shape join the stable surface once consumers branch on them. +The callback type, the establisher carrier, and the map are two-way-door +implementation details inside it (the `Establishment` carrier is +`#[non_exhaustive]` — ADR-049 amendment 2's posture). + +## Verification gates + +- `channel/control` routes to the establisher-contributed callback and + the reply is the callback's output (e2e over a real channels + connection); +- `channel:no_control_handle` on a channel whose ALPN registered none; +- `channel:unknown_channel` preserved (the pre-existing error); +- teardown removes the handle (post-teardown control answers + `channel:unknown_channel`, never a stale callback); +- callback `Err` passes through verbatim. + +## References + +- Review 010 S-02 (the stub; the implement ruling), review 011 §4 + (downstream-safe; implement preferred) +- OQ-39 (the promoted design note this ADR resolves) +- ADR-037 (the op's contract), ADR-047 (openable ALPNs; the wrapper + this registration rides), ADR-049 (the establisher carrier this + registers through), ADR-039 (manager blindness), ADR-011 + (`resource_id_path` — the per-owner control-gate lever) \ No newline at end of file diff --git a/docs/architecture/decisions/053-resource-enumerators.md b/docs/architecture/decisions/053-resource-enumerators.md new file mode 100644 index 0000000..f2ee40c --- /dev/null +++ b/docs/architecture/decisions/053-resource-enumerators.md @@ -0,0 +1,153 @@ +# ADR-053: `channel/resources/subscribe` — Live Resource Enumerators + +## Status + +Accepted — implements ADR-037 §6's dynamic half and ADR-047 §6's +discovery split ("what is currently there"). Resolves review 010 S-03 +(the `channel/resources/subscribe` stub: a fake one-element error +stream shipped nine published versions, pinned by tests, tracked as +OQ-40) by implementing the aggregated live subscription. OQ-40's +promotion: each ALPN crate provides a resource enumerator; the +channels layer aggregates them into a live `Stream`. + +## Context + +The static half is covered (ADR-047's 2026-09-06 amendment: +`OperationSpec.description` on `services/list` describes the op — not +the produced resource set). The dynamic half — "which tunnel resources +may I open, live" — was deferred (OQ-40) and then shipped as a stub; a +consumer discovering the op got a capability that does not exist +(review 010 S-03). The load-bearing consumer (alktunnels' discovery UI, +review 006 E-02) uses config-known op names today, but the stub ruling +makes deferral unavailable as a posture: registered means implementable. + +ADR-037 §6 + ADR-047 §6 pinned the shape: each ALPN crate that +registers open ops provides a resource enumerator (which containers +are running; which TTY sessions are active); `channel/resources/subscribe` +aggregates across registered openable ALPNs; the data source lives in +the ALPN crate. What was never pinned: the registration seam, the +enumerator contract, and the aggregation semantics. This ADR pins them. + +## Decision + +### 1. The enumerator contract + +```rust +pub type ResourceStream = Pin + Send>>; + +pub trait ResourceEnumerator: Send + Sync + 'static { + /// Snapshot-then-changes: the first item is the ALPN's complete + /// current resource payload; each subsequent item replaces it + /// (a full snapshot, not a delta). The channels layer does not + /// interpret the payload. + fn subscribe(&self) -> ResourceStream; +} +``` + +Each item is the **ALPN's full current resource payload** — an object +merged with `"alpn"` into the wire entry (`{ "resources": [{ "alpn": +"alk/tty", ...payload }] }`; a non-object payload wraps as +`{ "alpn": …, "data": … }`). The snapshot-then-changes contract lives +in the producing crate (a `watch` channel or similar is its own +machinery) — the channels layer never polls an enumerator mid-life. + +### 2. Registration seam + +```rust +pub struct ResourceEnumerators; // channels-call; RwLock>> + +impl ResourceEnumerators { + pub fn new() -> Self; + pub fn register(&self, alpn: &str, enumerator: Arc); + pub fn alpns(&self) -> Vec; +} +``` + +Registered by the composing crate at assembly/install time (the same +seam that composes the policy and the openables — the composer has the +backend handles). The registry is shared per accepting peer (like +`PerIdentityChannelPolicy`, ADR-041): one `Arc` +across every channels connection that peer accepts. + +**Scan-once:** the merged stream's enumerator set is scanned when the +subscription starts; ALPNs registered later are invisible to in-flight +subscriptions (re-subscribe to see them). Registrations happen at +install time, before any subscription — a late-registration notify +machinery is machinery nobody needs, and this pin prevents a future +half-design of it. + +### 3. The op: aggregation semantics + +`ChannelOperations::with_resource_enumerators(manager, policy, +enumerators)` threads the registry to the aggregate handler; the plain +`ChannelOperations::new(manager, policy)` constructor keeps an empty +registry. `make_resources_subscribe_handler` (the `Sub` op — ADR-021 +machinery, unchanged) becomes a real subscription: + +- **Non-empty registry:** merge every registered enumerator's stream + (`futures::stream::select_all`), each item mapped to a one-entry + `{ "resources": [{ "alpn": , ...payload }] }` envelope. The + first events across the ALPNs are the initial snapshot (each + enumerator's first item); subsequent events are the live updates. +- **Empty registry:** one `{ "resources": [] }` envelope, then the + stream stays open (the poll use case — subscribe, read the first + event, cancel — gets its snapshot; per the scan-once pin, nothing + further arrives until re-subscribe). +- **Zero enumerators ever registered but the op discoverable** is + impossible to distinguish from "no resources right now" — by design: + the static half answers "which ALPNs accept open ops" + (`services/list`), the dynamic half answers "what is currently + there"; a peer registering openables without enumerators reports an + empty live resource set, which is the truthful answer for "the + channels layer does not interpret your resources" (ADR-035 posture). + +The `access` preview stays dropped (ADR-047 §6 — the spec is the +authority). The documented wire shape (channel-operations.md) is +unchanged — this is the implementation of the documented op, not a new +wire surface. + +## Consequences + +**Positive:** the advertised op serves (S-03 dies); ALPN crates carry +their resource truth themselves (the channels layer stays ALPN-blind — +it maps and merges opaque values); the hub/dashboard consumer gets the +live subscription ADR-037 promised. + +**Negative:** `ChannelOperations` gains a second constructor (additive +— `new` keeps the empty default); an ALPN crate that never registers +an enumerator serves an always-empty resource set — truthful, and +`services/list` still discloses the ops (the static half is not +encumbered by the dynamic one). + +## Door type + +**Two-way (implementation shape) inside the documented wire surface.** +The wire shape (`{ "resources": [...] }`, per-ALPN entries, no +`access` preview) is ADR-037/047's pre-existing one-way surface, now +actually served. The enumerator trait, the scan-once rule, the empty +seed, and the registration seam are revisable implementation details — +no consumer observes them beyond the stream of envelopes. + +## Verification gates + +- initial snapshot: N registered enumerators → N first events carrying + each ALPN's payload merged with `"alpn"`; +- live update: an enumerator's second item arrives as the next envelope + without re-subscription; +- empty registry: exactly one `{ "resources": [] }` envelope, stream + stays open, subscriber cancel works (abort cascade, ADR-020); +- late registration invisibility: a post-subscription registration does + not appear in an in-flight subscription (the scan-once pin); +- non-object payload wrapping. + +## References + +- Review 010 S-03 (the stub; the implement ruling), review 011 §4 + (downstream-safe; implement preferred) +- OQ-40 (the promoted design note this ADR resolves; the "load-bearing + for alktunnels' discovery UI" note) +- ADR-037 §6 (the dynamic half's contract), ADR-047 §6 (the + discovery split; the 2026-09-06 amendment's static half) +- ADR-021 (the `StreamingHandler` machinery this op rides), ADR-039 + (manager blindness — the enumerators' channels-call home), ADR-041 + (the shared-per-peer construction pattern the registry follows) \ No newline at end of file