docs(adr): amendments pinning the single-publish fix — marked-pub contract (047 am.4), control surface (052), resource enumerators (053), op/register forward-through + marked refusal (022 am.)

Per review 011 §6 sequencing: the four amendment documents drafted
before code. All pins, no options — the hedged alternatives are stated
as rejected with rationale:
- ADR-047 amendment 4: the marked-pub open contract (review 011 §1/§2's
  seven pins — marker semantics, publish_schema refusal, reserved input
  key + dispatch strip, wrapper-as-responder-of-record, ack-timing
  sentence, relay translation, client put-open capability) plus the
  registration invariants that make stubs unregistrable (§4.8).
- ADR-052 (new): channel/control control-handle surface — establisher-
  contributed ChannelControl, wrapper-owned registry, channel:no_control_handle.
- ADR-053 (new): channel/resources/subscribe live resource enumerators —
  snapshot-then-changes, scan-once aggregation, empty seed.
- ADR-022 amendment: announced unmarked Sub/Pub forward through the real
  forwarding shapes; marked announcements refused (MARKED_SPEC_UNSUPPORTED).
This commit is contained in:
glm-5.3-flash committed 2026-10-13 08:27:50 +00:00
1 parent 4eae18f539
commit df4d4d3a9e
4 files changed
+618 -3

No files matched your search

@@ -2,7 +2,7 @@
## Status ## 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 ## 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`. protocol-crate take-over that consumes the dial's `Connection`.
See [client-and-adapters.md](../crates/call/client-and-adapters.md) See [client-and-adapters.md](../crates/call/client-and-adapters.md)
§"CallClient" for the reframed operational spec. §"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).
@@ -14,7 +14,265 @@ per-op `description` on the listing, the dynamic half stays deferred
— see "Amendment (§6 listing enrichment, 2026-09-06)" below; — see "Amendment (§6 listing enrichment, 2026-09-06)" below;
amendment 3 (2026-09-16, review 008 U-1) — the op-name convention amendment 3 (2026-09-16, review 008 U-1) — the op-name convention
extends to the flavor form and discovery carries an explicit 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/<alpn>/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<ChannelPlan>, Connection, AuthContext)
-> BoxFuture<'static, Result<Value, CallError>>
+ 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": <adopted> }`
→ 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) ## Amendment 3 (flavor-form open-op ids + explicit `channel_open_alpn`, 2026-09-16 — review 008 U-1)
@@ -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<Value, CallError>> + 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<u32, ChannelControl>` 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 reply> }` |
| 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)
@@ -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<Box<dyn Stream<Item = Value> + 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<HashMap<String /*alpn*/, Arc<dyn ResourceEnumerator>>>
impl ResourceEnumerators {
pub fn new() -> Self;
pub fn register(&self, alpn: &str, enumerator: Arc<dyn ResourceEnumerator>);
pub fn alpns(&self) -> Vec<String>;
}
```
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<ResourceEnumerators>`
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": <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)