findings: expose is specced, not deferred — subscription model as matching mechanism, (op_name, params_hash) pubsub, stream dedup, step-by-step proxy walk-through

This commit is contained in:
deepseek-v4-pro committed 2026-07-19 16:50:02 +00:00
1 parent 0ef277730f
commit 8ca2d0632d
1 file changed
+107 -67
@@ -55,11 +55,11 @@ before the handler runs, like every other op), makes the
is per-op, not per-ALPN-branch-of-a-single-op), and lets the two is per-op, not per-ALPN-branch-of-a-single-op), and lets the two
`direction` values (initiator-to-responder vs responder-to-initiator) `direction` values (initiator-to-responder vs responder-to-initiator)
become two verbs (`channels/<alpn>/open` and become two verbs (`channels/<alpn>/open` and
`channels/<alpn>/expose`) with separate ACLs. `open` is specced; `channels/<alpn>/expose`) with separate ACLs. Both are specced; the
`expose` is reserved in the enum and deferred until a concrete push call protocol's subscription model is the matching mechanism between
use case forces the design. The verb split's real win is enabling them — a worker exposing a resource and a consumer opening it are
`open`-only shipping: the old `direction` field forced both semantics matched by `(op_name, params_hash)` on the hub, which proxies the
into the v1 wire contract. data plane between them.
The ALPN crates served under channels (tty, tunnel, socks5, fs, sftp) The ALPN crates served under channels (tty, tunnel, socks5, fs, sftp)
stop being "just ALPNs" and become **call-consuming apps with a stop being "just ALPNs" and become **call-consuming apps with a
@@ -133,7 +133,7 @@ The old "ALPN-server"/"ALPN-client" vocabulary is retired. Call++ apps
are not TLS-layer ALPNs; "producer"/"consumer" describes the data-plane are not TLS-layer ALPNs; "producer"/"consumer" describes the data-plane
role without implying a TLS ALPN. The `ChannelDirection` enum uses role without implying a TLS ALPN. The `ChannelDirection` enum uses
`Open` (initiator is consumer, responder is producer — the common case) `Open` (initiator is consumer, responder is producer — the common case)
and `Expose` (initiator is producer, responder is consumer — deferred). and `Expose` (initiator is producer, responder is consumer).
**Assembly layer** is the CLI binary that wires crates together at **Assembly layer** is the CLI binary that wires crates together at
startup (ADR-019, ADR-024). It constructs backends, injects startup (ADR-019, ADR-024). It constructs backends, injects
@@ -376,23 +376,31 @@ request/response export.
- `channels/<alpn>/open` — the initiator wants to consume a resource - `channels/<alpn>/open` — the initiator wants to consume a resource
the responder will produce. Responder is the producer. The common the responder will produce. Responder is the producer. The common
case: "open me a TTY on your docker container." Browser → hub → case: "open me a TTY on your docker container." Consumer → hub →
spoke: both legs are `channels/tty/open`. **Specced.** producer: both legs are `channels/tty/open`. **Specced.**
- `channels/<alpn>/expose` — the initiator wants to produce a resource - `channels/<alpn>/expose` — the initiator wants to produce a resource
for the responder to consume. Initiator is the producer. The for the responder to consume. Initiator is the producer. The
worker-expose case: worker → hub, worker is making a TTY available worker-expose case: worker → hub, worker is making a resource
for the hub's clients to consume. **Reserved in the available for the hub to proxy to consumers. **Specced.**
`ChannelDirection` enum; deferred until a concrete push use case
forces the design.**
Separate verbs → separate ACLs. The verb split's real win is enabling Separate verbs → separate ACLs. A peer that may expose a TTY is not
`open`-only shipping: the old `direction` field forced both semantics the same grant as a peer that may open one. The verb split also
into the v1 wire contract. `Expose` is reserved so the shape is enables the hub-as-proxy pattern: a worker exposes a resource to the
available when needed, but the op + hold semantics are deferred hub, the hub owns it, and consumers open it from the hub.
(`deferred(scope)`, blocked on a concrete consumer — the repo's own
pattern from OQ-56, OQ-57). Half-specifying "hold until consumer **Matching: the call protocol's subscription model.** The hub matches
connects" now would produce exactly the kind of hedge this doc expose and open by `(op_name, params_hash)`. When a worker calls
criticizes ADR-073 for. `channels/tty/expose`, the hub records the exposed resource keyed by
the hash of the op name + params. When a consumer calls
`channels/tty/open` with matching params, the hub finds the exposed
resource and proxies the data plane between them. This is a pubsub
model where the topic is `(op_name, params_hash)` — the call protocol's
existing subscription mechanism (`OperationType::Subscription`) is the
natural fit for the expose side: the worker subscribes to consumer
demand for that resource, and the hub delivers matching open requests
as subscription events. Stream deduplication falls out naturally: if
two consumers open the same resource, the hub fans out from one
producer stream rather than opening duplicate channels to the worker.
### The `ChannelCore` seam (wrapper shape — flag for POC) ### The `ChannelCore` seam (wrapper shape — flag for POC)
@@ -609,54 +617,82 @@ data-plane work. It ran `channels/tty/open`'s `access_control`
ADR-079 holds unchanged in shape; only the op name changed (from ADR-079 holds unchanged in shape; only the op name changed (from
generic `channel/open` to per-ALPN `channels/tty/open`). generic `channel/open` to per-ALPN `channels/tty/open`).
### Worker → hub → consumer, "worker exposes a resource" (the proxy case — deferred) ### Worker → hub → consumer, "worker exposes a resource" (the proxy case)
The `expose` verb is reserved in the `ChannelDirection` enum but The `expose` verb is specced alongside `open`. The call protocol's
deferred. The walk-through below is a sketch of the shape, not a spec. subscription model is the matching mechanism between them.
The open case (consumer initiates on demand) is the common case and is
specced; the expose case is for push scenarios where a worker offers
resources to a hub, which then acts as a proxy — re-exposing them to
other consumers.
**Concrete example.** A worker runs on a remote instance (vastai, **Concrete example.** A worker runs on a remote instance (vastai,
runpod, a docker container). It wraps an opencode server's OpenAPI spec runpod, a docker container). It wraps an opencode server's OpenAPI spec
via `from_openapi` (call ops, JSON) and also registers via `from_openapi` (call ops, JSON) and registers `channels/tty/open`
`channels/tty/open` for terminal access. The worker connects to a hub for terminal access. The worker connects to a hub and calls
and exposes both: the call ops and the TTY channel. The hub consumes `channels/tty/expose` as a **Subscription** — "I have this resource;
these — the worker's resources become the hub's resources (ownership notify me when a consumer wants it." The hub records the exposed
model: the hub owns what the worker exposes). Another consumer (a resource keyed by `(op_name, params_hash)`. Later, a consumer (browser,
browser, another worker, another hub) that needs terminal access to another worker, another hub) calls `channels/tty/open` with matching
that remote dev environment calls `channels/tty/open` on the hub. The params. The hub matches by hash, delivers the open request to the
hub is now a producer for that consumer — it proxies the data plane worker's subscription, the worker allocates the channel, and the hub
between the worker (the real producer) and the consumer. proxies the data plane between them.
**Step-by-step:**
1. Worker calls `channels/tty/expose` (Subscription) with
`{params: {backend: docker, container: "abc123", cmd: ["bash"]}}`
on its channel 0. Worker is the initiator / producer.
2. Hub's `CallAdapter` receives `channels/tty/expose`.
`OperationRegistry` checks the op's `access_control` against the
worker's identity. The op's spec has
`channel_open: Some(ChannelOpenSpec { alpn: "alknet/tty", direction: Expose })`.
3. Hub recognizes the `channel_open` marker. The hub records the
exposed resource in its subscription table keyed by
`("channels/tty/expose", hash(params))`. The subscription is held
open — no channel is allocated yet. The worker is waiting for a
consumer.
4. Later, a consumer calls `channels/tty/open` (Query/Mutation) with
matching params `{container: "abc123", cmd: ["bash"]}`.
5. Hub checks `access_control` against the consumer's identity. Hub
computes `params_hash` and looks up the expose subscription table.
Finds the worker's subscription.
6. Hub delivers the open request to the worker's subscription as an
event. The worker's expose handler runs: validates params, consults
ownership (ADR-050 — the hub owns the container), prepares the
`TtyBackend`. Worker's `ChannelCore` allocates `channel_id` on the
worker→hub leg, spawns `TtyAdapter`, records opener (hub) in the
ledger, returns `{channel_id}`.
7. Hub receives the worker's `{channel_id}`, allocates a matching
channel on the consumer→hub leg, records the mapping
`consumer_id ↔ worker_id`, returns `{channel_id: consumer_id}` to
the consumer.
8. Hub byte-forwards between `consumer_id` and `worker_id` with
4-byte `channel_id` rewrite (ADR-079 unchanged).
**Stream deduplication (single-producer, N-consumer).** If a second
consumer calls `channels/tty/open` with the same params, the hub
matches the same worker subscription. The hub does NOT deliver another
event to the worker — it already has the producer channel. Instead, it
allocates a second consumer leg and fans out from the existing producer
channel: `worker_channel → consumer_1_channel` and
`worker_channel → consumer_2_channel`. One producer stream, N consumer
streams, hub fans out. The `(op_name, params_hash)` key naturally
deduplicates — it's a pubsub topic, and the hub is the broker.
**The hub-as-proxy pattern.** The hub is a consumer of the worker's **The hub-as-proxy pattern.** The hub is a consumer of the worker's
resource and a producer for downstream consumers. The relay is resource and a producer for downstream consumers. The relay is
protocol-agnostic: swap channel IDs, tunnel reads/writes between the protocol-agnostic: swap channel IDs, tunnel reads/writes between
two `BiStream`s. From the worker's perspective, the hub is the sole `BiStream`s. From the worker's perspective, the hub is the sole
consumer — the hub owns the resource and re-exposes it under its own consumer — the hub owns the resource and re-exposes it under its own
authority (the `forwarded_for` chain carries attribution, not authority (the `forwarded_for` chain carries attribution, not
authority; ADR-032). From the downstream consumer's perspective, the authority; ADR-032). From the downstream consumer's perspective, the
hub is the producer — it doesn't know or care that the real backend is hub is the producer — it doesn't know or care that the real backend is
on a worker. on a worker.
This is the same shape as the open-case relay (ADR-079) but initiated **Teardown.** If the worker disconnects before a consumer arrives, the
from the producer side. The hub allocates `channel_id` on both legs, subscription is cancelled (connection drop → subscription teardown).
records the mapping, and byte-forwards with `channel_id` rewrite. If a consumer disconnects, the hub closes that consumer leg but keeps
the producer channel open for other consumers. When the last consumer
**Single-producer, N-consumer.** The hub can proxy one producer's disconnects, the hub may close the producer channel or keep it
stream to multiple consumers — e.g., a worker producing a live video (subscription stays open for future consumers — policy decision, not
stream, with the hub re-streaming to N viewers. This is a future use architecture).
case (the channels wire format already supports it — channels are
independent, the hub just opens N consumer legs for one producer leg),
but the ownership and ACL model is the same: the hub owns the
producer's resource and controls which consumers may access it.
**The hard questions** — "hold until consumer connects" vs "reject if
no consumer," and the N-consumer fan-out semantics — are deferred with
the verb. The `direction` field is per-leg, not end-to-end; the relay
passes the verb through (`expose` → `expose`), same as in the open
case (`open` → `open`).
### The `channel_id` allocation symmetry ### The `channel_id` allocation symmetry
@@ -730,15 +766,17 @@ is always by the responder" invariant.
channels-opened sessions (`channels/tty/open` → allocate channel → channels-opened sessions (`channels/tty/open` → allocate channel →
spawn `TtyAdapter` on the channel's `BiStream`). Both paths spawn `TtyAdapter` on the channel's `BiStream`). Both paths
converge at the `BiStream`. converge at the `BiStream`.
- Control plane (new): registers `channels/tty/open` on the call - Control plane (new): registers `channels/tty/open` and
`OperationRegistry` at assembly time, alongside its `channels/tty/expose` on the call `OperationRegistry` at assembly
`ProtocolHandler` on the `HandlerRegistry` for direct connections. time, alongside its `ProtocolHandler` on the `HandlerRegistry` for
The op spec carries the `channel_open` marker, the `access_control` direct connections. The op specs carry the `channel_open` marker,
(e.g., `required_scopes: ["tty:open"]`), the `input_schema` (the the `access_control` (e.g., `required_scopes: ["tty:open"]` and
`required_scopes: ["tty:expose"]`), the `input_schema` (the
`NegotiateRequest`), and the `resource_id_path` (e.g., `NegotiateRequest`), and the `resource_id_path` (e.g.,
`/params/container` for docker-backed TTY). `channels/tty/expose` `/params/container` for docker-backed TTY). `channels/tty/expose`
is reserved in the enum but not registered until a concrete push is registered as a `Subscription` op type — the worker subscribes
use case forces it. to consumer demand; the hub delivers matching open requests as
subscription events.
- The open handler validates params, consults ownership (ADR-050), - The open handler validates params, consults ownership (ADR-050),
prepares the `TtyBackend`, returns a "channel plan" to the prepares the `TtyBackend`, returns a "channel plan" to the
`ChannelCore` wrapper, which spawns the `TtyAdapter`. `ChannelCore` wrapper, which spawns the `TtyAdapter`.
@@ -844,11 +882,13 @@ is always by the responder" invariant.
(`alknet/tty` → `tty`) needs pinning either way, including what (`alknet/tty` → `tty`) needs pinning either way, including what
non-`alknet/*` ALPNs do. non-`alknet/*` ALPNs do.
- **Expose verb.** Deferred. The `Expose` variant is reserved in the - **Expose subscription matching.** The `(op_name, params_hash)` key
`ChannelDirection` enum; the op + hold semantics are deferred until is the proposed matching mechanism between expose and open. The
a concrete push use case forces the design (`deferred(scope)`, exact hash function, collision handling, and the subscription
blocked on a concrete consumer — the repo's own pattern from OQ-56, lifecycle (when does the hub tear down an expose subscription with
OQ-57). no consumers?) are POC-worthy details. The architectural point is
that the call protocol's `Subscription` op type is the natural fit
for the expose side, and the hub is the broker.
- **Does `ProtocolHandler` want channel-context?** Probably not for - **Does `ProtocolHandler` want channel-context?** Probably not for
TTY/tunnel/ssh (they just want a `BiStream`), but maybe for ALPNs TTY/tunnel/ssh (they just want a `BiStream`), but maybe for ALPNs