Files
alknet/docs/research/call-channels-unification/findings.md
T
deepseek-v4-pro 3543c1bb7a findings: known gaps, wire format family, and alknet-typedef unification
- Add Known gaps section (A-G): Pub handler shape, hub broker spec,
  from_call relay wrapper, channel_id allocation in Pub case,
  OperationEnv coupling, channel_open wire format, resource_id_path
  ACL vs handler ownership
- Add Wire format family section: call JSON, call binary, channels,
  TTY all share [discriminant][length][payload] shape; binary call
  frame is 9 bytes vs JSON's ~80+
- Add alknet-typedef section: JSON Schema with TypeDef:* custom
  keywords as the binary struct engine, replacing per-protocol serde
  structs, typebox-rs, and per-handler wire format parsers
- Cross-reference Gap E resolution in open questions
2026-07-19 20:21:54 +00:00

73 KiB
Raw Blame History

status, last_updated
status last_updated
draft 2026-07-19

call-channels-unification — Findings: openable ALPNs are operations

Status: Draft findings, iterating. Per the research-then-sync pattern (see docs/research/stream-unification/findings.md for the precedent), this doc iterates in docs/research/; we fix inter-document drift here, then sync to docs/architecture/ and the ADRs only after it settles.

Scope: The control plane for channels-served ALPNs — how channel/open is authorized, how per-ALPN ACLs are expressed, how the quota lifecycle is accounted, and how the ALPN categorization from ADR-086 reframes under "an openable ALPN is an operation." This is above the channels wire format (ADR-071/093, settled) and below the ALPN handler's data-plane protocol (each handler's own wire format). The transport leaf (BiStream as the handler-facing duplex type) is settled in ADR-092 and is not re-litigated here. The channels wire format (8-byte header, no stream_type) is settled in ADR-093 and is not re-litigated here.

Date: 2026-07-19

Origin: An outside review surfaced three high-value gaps in the channels control plane after ADR-094 (per-identity channel cap) landed. Working through the first gap — channel/open ACL granularity — surfaced a larger unification: channels is call with a binary data plane — the same protocol, two chunk formats (JSON vs binary). The ALPN crates served under channels are call apps in the same shape alknet-docker is a call app. This doc records both the gaps and the unification.


TL;DR

The previous framing — "channel lifecycle goes through one generic channel/open operation, and AccessControl::check on that op is the ACL" — was a symptom. The actual question is the separation of concerns between the call protocol (which already has per-op ACL, identity, composition, ownership, and subscriptions) and the channels layer (which provides binary framing for ops whose response isn't JSON), and the resolution is an openable ALPN is an operation: each openable ALPN registers its own ops on the call OperationRegistry, with its own access_control, input_schema, resource_id_path, and a channel_open marker that tells the call adapter "this op's stream is binary, not JSON."

This dissolves the channel/open ACL granularity gap (each ALPN has its own ACL — checked by the existing OperationRegistry::invoke before the handler runs, like every other op), makes the resource_id_path from ADR-050 work for channel-open ops (the path is per-op, not per-ALPN-branch-of-a-single-op), and replaces the direction field with the call protocol's OperationType: channels/<alpn>/sub (OperationType::Sub — consumer subscribes to a stream) and channels/<alpn>/pub (OperationType::Pub — producer publishes a stream). The hub matches Pub ↔ Sub by (op_name, params_hash) and proxies the data plane between them.

Channels is call with a binary data plane. The call protocol already has the subscription model, ACL, identity, composition, and ownership. Channels adds one thing: binary framing (8-byte headers, channel multiplexing) for ops whose stream isn't JSON. A call connection has two modes:

  • Default call (alknet/call): all ops use JSON framing on a single stream. Pub/Sub works — events are JSON chunks.
  • Channels (alknet/channels): call on channel 0 (JSON control plane), binary streams on channels 1..N (data plane). Pub/Sub works identically — the only difference is the chunk format on the data-plane channels.

Pub/Sub unifies the model. Pub is a streaming Mutation — the client publishes a stream to the server (producer → consumer). Sub is a streaming Query — the server streams to the client (producer → consumer). The hub is the broker: it matches Pub to Sub by (op_name, params_hash) and proxies the BiStream between them. Stream deduplication falls out naturally: if two consumers subscribe to the same topic, the hub fans out from one publisher stream. The channel_open marker is orthogonal — it tells the call adapter "this stream is binary, use channels framing." Without it, the same Pub/Sub model works for JSON streams.

The ALPN crates served under channels (tty, tunnel, socks5, fs, sftp) stop being "just ALPNs" and become call apps — the same shape as alknet-docker, but where some ops carry the channel_open marker and produce a binary stream instead of a JSON stream. The hub/worker composition story unifies: a hub composes call apps, full stop; some ops return JSON, some ops carry the channel_open marker and produce a binary stream.

Three further gaps fall out of this framing:

  1. Quota lifecycle leaks (ADR-094 amendment). The per-identity cap's on_close is called from the channel/close handler with op_ctx.identity (the closer, not the opener) and is not called at all on transport drop (REQ-CH-02 clears the channel map without touching the policy). A peer whose connection dies at cap is permanently at cap — a self-DoS. Fix: decouple the decrement from channel/close and tie it to channel-state deallocation on the side that counted, via a per-connection opener ledger in channels-call (keeping channels-core auth-blind) decremented on every teardown path.

  2. Connection-count DoS is an unowned layer (new OQ against alknet-endpoint). The per-identity cap bounds channels, but each transport connection still costs a channel-0 buffer, a CallAdapter, and demux tasks before any channel/open — and ADR-094 explicitly says connections are unbounded. This is not a channels-ACL problem; it belongs at the endpoint/accept layer (per-identity connection cap, analogous shape to ChannelLifecyclePolicy). No doc owns it today. Naming it as a separate layer stops it from re-tangling every channels-ACL conversation.

  3. ALPN category reframe (ADR-086 amendment). ADR-086 §4 split the foundational handlers into "channels data-channel ALPNs" and "SSH (endpoint ALPN wrapping channels)." Under the unified model, the first category is reframed: they're not "ALPNs gated by channels" — they're call apps with binary-stream ops. They inherit call's auth/composition/identity by construction (they are call apps); the binary-stream part is the channel_open marker on the op spec. The "flat ALPN list" re-implementation problem dissolves because they're call apps, not a separate category that each re-implements auth. SSH stays distinct (endpoint ALPN wrapping channels).

No production/backward-compat constraint. The develop branch is a rewrite of main (pre-alpha). The decision is purely "what's cleanest," not "what's least disruptive." ADR-073's four op names are declared one-way doors, but there's no code and no deployments; ADR-093/094 just demonstrated that amendment is the normal mode here. This is the cheapest moment to amend.


Terminology (three axes, not one)

The doc uses three independent role axes. They overlap in common cases but are not the same thing — a hub can be a responder on one leg and an initiator on another, a producer on one channel and a consumer on another.

Axis Roles What it means
Deployment hub, worker (spoke), browser Where the code runs. Hub relays; worker/spoke hosts resources; browser is the end-user client.
Call protocol initiator, responder Who sends the call op. The initiator calls channels/tty/sub; the responder receives it.
Data plane producer, consumer Who runs the ProtocolHandler (produces the data stream) vs who receives it. The producer is the side that spawns the handler on the BiStream.

The old "ALPN-server"/"ALPN-client" vocabulary is retired. Call apps are not TLS-layer ALPNs; "producer"/"consumer" describes the data-plane role without implying a TLS ALPN. The call protocol's OperationType maps directly: Pub (initiator is producer, responder is consumer) and Sub (initiator is consumer, responder is producer).

Assembly layer is the CLI binary that wires crates together at startup (ADR-019, ADR-024). It constructs backends, injects capabilities, registers ops, and builds the ALPN lists. It is the trust boundary — handlers never hold vault references or construct their own transports. In this doc, "assembly time" means "at startup, in the CLI binary, before any connections exist."


Layering (to keep the questions separate)

The outsider's layer map, plus the ALPN-category layer this doc adds:

Layer Question it answers Owner Status
Endpoint accept May this identity hold N connections? alknet-endpoint Unowned (Gap 3). ADR-094 explicitly says connections are unbounded. New OQ against alknet-endpoint.
channels-core Byte routing; per-connection memory bounds ADR-075/076 Decided, coherent. Auth-blind by design (ADR-075).
channels-call, op-level May you call channel/open at all? AccessControl::check Decided. (Under the unified model: per-ALPN ops, each with its own ACL — Gap 1 resolved.)
channels-call, quota How many slots may you hold? ADR-094 Decided, lifecycle buggy (Gap 2). Trait shape survives; the change is where on_close is called from and where the opener identity comes from.
channels-call, per-ALPN/direction May you open this ALPN, this direction? Per-ALPN op's access_control (this doc) Resolved. Each openable ALPN is its own op; the op's access_control is the per-ALPN/direction ACL.
ALPN handler (data plane) May you touch container abc123? ADR-050 ownership, handler-internal Decided (call/docker land). Unchanged under the unified model.

The tangle was that layers 5 and 6 got blurred ("ACL is checked on channel/open" is true but only for layer 3, because the single generic op couldn't see alpn/direction/params), and layer 1 got silently assumed away.


The structural tangle (the actual gaps)

Gap 1 — channel/open ACL granularity is underspecified

The call crate's ACL is per-operation: OperationSpec.access_control, checked before the handler runs by OperationRegistry::invoke. But channel/open is one operation whose input decides what's really being requested (alpn, params, direction). One op-level AccessControl cannot express "peer X may open alknet/tty but not alknet/tunnel."

The specs gesture at per-ALPN ACLs — channel/resources/subscribe returns access: { required_scopes: ["tty:open"] } per ALPN, "advisory... the real check happens on channel/open" (channel-operations.md:150) — but no mechanism is specified for that real check. HandlerRegistry carries no AccessControl. Nobody registers alpn → required_scopes anywhere.

Three candidate shapes were considered (the outsider's analysis):

(a) A per-ALPN ACL map held by ChannelOperations, checked inside the channel/open handler after the op-level check. Parallel to how the quota policy slots in.

(b) A ChannelOpenSpec registry (alpn → AccessControl + params schema + resource_id_path + allowed directions) consulted inside the handler after the op-level check.

(c) Delegate to the ALPN handler — it gets AuthContext and can refuse.

(c) is the worst: allocation and handler spawn happen before authorization, refusal semantics diverge per ALPN, and every handler crate reimplements scope checking.

(b) looks like minimal churn, but notice what the registry actually is: a parallel structure holding ACLs, input schemas, resource pointers, and discovery data, with its own check invocation — a shadow OperationRegistry. The repo has been here before: ADR-028 was superseded by ADR-029 exactly because "a parallel authorization system duplicated the existing AccessControl," and ADR-029's whole thesis is that peer authorization is just AccessControl::check on the existing path. (b) re-commits that structural miss one layer down.

Gap 2 — The quota's accounting lifecycle leaks

ADR-094 increments in the channel/open handler and decrements in the channel/close handler via policy.on_close(&op_ctx.identity). Two problems:

  1. Responder-initiated close decrements the wrong ledger. If A opens (counted against A on B's policy) and B later sends channel/close (e.g., TTY exit happens on B's side — REQ-CH-06 makes B the closer), the close handler runs on A with op_ctx.identity = B. B's count for A is never decremented; A's policy decrements a channel it never counted.

  2. Transport drop leaks the quota. REQ-CH-02 clears the channel map on transport EOF, but nothing calls on_close. Since the policy is shared across connections (that's the whole point), a peer whose connection dies at 256 open channels is permanently at cap — a self-DoS.

The conceptual fix: decouple the decrement from the channel/close operation and tie it to channel-state deallocation on the side that counted. channels-call keeps its own channel_id → opener PeerId ledger per connection (keeping channels-core auth-blind) and decrements on any teardown path — close received, close sent locally, handler exit, or connection drop. The ledger entry must be removed atomically with its decrement: handler-exit, close-received, close-sent-locally, and connection-drop can race, and a double-decrement under-counts and weakens the cap.

Gap 3 — Connection-count DoS is an unowned layer

The per-identity cap bounds channels, but each transport connection still costs a channel-0 buffer, a CallAdapter, and demux tasks before any channel/open — and ADR-094 explicitly says connections are unbounded. This is not a channels-ACL problem and shouldn't be solved there; it belongs at the endpoint/accept layer (per-identity connection cap, analogous shape to ChannelLifecyclePolicy). Right now no doc owns it. Naming it as a separate layer stops it from re-tangling every channels-ACL conversation.

Gap 4 — ALPN category blur (the user's insight)

ADR-086 §4 split the foundational handlers into "channels data-channel ALPNs" (gated by channels, opened via channel/open, inherit ACL + bidirectionality) and "SSH" (endpoint ALPN wrapping channels). The "channels data-channel ALPNs" framing implies they're a separate kind of thing that channels happens to gate — and each would re-implement auth in a flat ALPN list. Under the unified model, they're not a separate category: they're call apps with binary-stream ops. They inherit call's auth by construction (they are call apps). The gating is a consequence, not the definition.

This also touches the HTTP/ WebSocket path: ADR-048 says "WebSocket carries the native call-protocol session." Under the unified model, a browser that wants to open a TTY channel needs to call channels/tty/sub, which lives on channel 0 inside a channels connection. So WebSocket may carry either alknet/call (bare, for call-only clients) or alknet/channels (8-byte chunk framing, with call on channel 0 inside). Channels framing is required for binary-stream clients; a call-only client (e.g. a dashboard doing docker JSON ops) can use bare alknet/call over WebSocket. OQ-65 ("WebSocket carrying channels, not just call?") is resolved: WebSocket may carry either; channels required for binary streams.


The resolution: openable ALPNs are operations

The core observation: ADR-073's claim is "no new auth machinery — channel lifecycle goes through the existing AccessControl::check." But the single generic channel/open operation is precisely what breaks that promise: it hides the authorization-relevant facts (alpn, direction, params) inside one op's input, where the call ACL machinery can't see them. Consequences:

  • Per-ALPN scopes (tty:open vs tunnel:open) have no enforcement home.
  • ADR-050's resource_id_path (ownership via JSON pointer into input) is declared per OperationSpec — a single channel/open can't use it because the pointer differs per ALPN (/params/container for tty-docker, /params/target for tunnel).
  • The two direction values are wildly different grants ("may you consume my TTY" vs "may you register a service I will consume as a client") squeezed under one ACL.

The fix that requires new machinery is keeping the generic op; the fix that requires none is the opposite of what's specced. An openable ALPN is an operation.

The marker on OperationSpec

OperationSpec gains a channel_open field:

pub struct OperationSpec {
    // ... existing fields ...
    pub access_control: AccessControl,
    pub resource_id_path: Option<String>,
    /// Marker consumed by layers that manage binary streams. When set,
    /// the op's stream is binary (channels framing) rather than JSON.
    /// The marker is registry metadata, not auth machinery — it's how
    /// layers like channels-call know an op needs a binary channel,
    /// parallel to how `resource_id_path` is how ADR-050 knows where
    /// to find the resource id. The op's `access_control` is the ACL
    /// (unchanged); the marker is the dispatch hint.
    pub channel_open: Option<ChannelOpenSpec>,
}

pub struct ChannelOpenSpec {
    pub alpn: &'static str,  // e.g., "alknet/tty"
}

The direction field is gone — the call protocol's OperationType carries the direction. OperationType::Sub means the initiator is the consumer (subscribes to a stream); OperationType::Pub means the initiator is the producer (publishes a stream). The channel_open marker is orthogonal: it says the stream is binary, not JSON. A Sub-typed op with channel_open: None is a JSON subscription; a Pub-typed op with channel_open: Some(...) is a binary publisher.

The marker is wire-visible. channel_open must survive discovery serialization — it is part of the services/schema payload, not just the in-process struct. Otherwise the hub (and any from_call importer) can't see it. The services/schema handler already serializes the full OperationSpec to JSON; channel_open is included in that serialization.

from_call relay wrapper. A FromCall-imported marked op cannot be the standard forwarding stub. The hub's version must do forward + allocate local-leg channel + record id mapping + start the byte-forward pumps. When from_call imports a marked op on a channels-backed connection, it wraps it with relay machinery instead of the plain forwarding stub. ADR-022's provenance table (leaves are forwarding stubs, no composition authority) gets a note for this case: a FromCall-imported marked op is a leaf for composition purposes but carries relay machinery that allocates channels and spawns byte-forward tasks. This is the load-bearing piece of the relay under the unified model.

Marked ops invoked outside a channels session. channels/tty/sub is registered on the call registry — which means it's also visible/invocable on a bare top-level alknet/call connection, where there is no ChannelManager and no data plane. The wrapper resolves OperationEnv::channel_manager() at invocation time; if it returns None, the wrapper returns channel:no_channels_session. Relatedly, the HTTP-side adapters (to_openapi, to_mcp) must exclude marked ops — "produces a binary stream" is not expressible over a request/response export.

Pub/Sub: the call protocol's OperationType carries the direction

The direction field is replaced by the call protocol's OperationType. Two new variants extend the existing Query, Mutation, Subscription set:

  • OperationType::Sub — the initiator subscribes to a stream from the responder. The initiator is the consumer; the responder is the producer. HandlerKind::Stream (server → client stream). Maps to channels/<alpn>/sub. Specced.
  • OperationType::Pub — the initiator publishes a stream to the responder. The initiator is the producer; the responder is the consumer. HandlerKind::Stream (client → server stream). Maps to channels/<alpn>/pub. Specced.

The existing Subscription variant is superseded by Sub (same shape, clearer name). Pub fills the structural gap — there was no way for a client to stream to the server before.

Separate op types → separate ACLs. A peer that may publish a TTY stream is not the same grant as a peer that may subscribe to one. The op type split also enables the hub-as-proxy pattern: a worker publishes to the hub, the hub owns the resource, and consumers subscribe from the hub.

Matching: Pub/Sub by (op_name, params_hash). The hub matches Pub to Sub by topic hash. When a worker calls channels/tty/pub, the hub records the published stream keyed by ("channels/tty/pub", hash(params)). When a consumer calls channels/tty/sub with matching params, the hub finds the publisher and proxies the BiStream between them. This is a pubsub model where the topic is (op_name, params_hash) — the hub is the broker. Stream deduplication falls out naturally: if two consumers subscribe to the same topic, the hub fans out from one publisher stream rather than opening duplicate channels to the worker.

JSON Pub/Sub works identically. Without the channel_open marker, Pub and Sub ops use JSON framing on the default call stream. The matching mechanism, ACL, and hub broker are the same. The marker only changes the chunk format — binary (channels) vs JSON (default call).

OperationType changes in alknet-call:

pub enum OperationType {
    Query,         // unchanged: request → single JSON response
    Mutation,      // unchanged: request → single JSON response
    Sub,           // new: client subscribes, server streams (replaces Subscription)
    Pub,           // new: client publishes, streams to server
}

The registry restriction is loosened: Sub and Pub both use HandlerKind::Stream. The existing Subscription variant is deprecated (mapped to Sub in wire serialization for backward compat during the transition; no deployments exist, so this is cosmetic).

The ChannelCore seam (wrapper shape — flag for POC)

The ALPN crate's open-op handler does ALPN-specific work (validate params, consult ownership, prepare the backend) and returns a "channel plan." channels-call wraps it: the wrapper allocates the channel_id, gets the BiStream from ChannelManager, records the opener in the ledger (Gap 2), consults ChannelLifecyclePolicy, spawns the ProtocolHandler on the BiStream with the plan's backend, returns {channel_id}.

The ALPN crate provides:

  • The OperationSpec (with channel_open marker, access_control, input_schema, resource_id_path).
  • The open handler (the ALPN-specific work — validate params, consult ownership, prepare the backend, return a plan).
  • The ProtocolHandler for the data plane (unchanged — used by both direct connections and channels-opened sessions; both paths converge at the BiStream).

channels-call provides:

  • The ChannelCore (channel-id allocation, ChannelManager integration, per-connection opener ledger, ChannelLifecyclePolicy consultation, teardown hooks).
  • A register_openable(spec, open_handler, channel_core) helper that wraps the ALPN's open handler with the channel machinery and registers the op on the call OperationRegistry.

This keeps the ALPN crate's handler focused on ALPN concerns (params, backend) and lets channels-call own the channel machinery. The alternative (invoke shape — the handler calls context.channel_core.open(...) itself) requires either OperationContext to carry a channel_core reference (inverting the call → channels-call dependency) or a generic extension mechanism on OperationContext (more complexity than the wrapper). The wrapper shape is preferred; the exact API shape (plan vs callback) is POC-worthy — flag this as the thing to pressure-test in a small POC (one ALPN crate, one open op, one channels connection, prove the wrapper allocates the channel and spawns the handler).

Per-connection state plumbing (POC-critical). register_openable registers ops "at assembly time" (Layer 0, curated, static per ADR-024). But the wrapper needs the per-connection ChannelManager — the op arrives on channel 0 of one specific channels connection, and the channel must be allocated on that connection's manager. A globally-registered handler closing over a static ChannelCore has no way to know which channels connection invoked it.

The resolution: the OperationEnv trait (already on OperationContext.env) gains an optional fn channel_manager(&self) -> Option<&ChannelManager>. The wrapper handler resolves it at invocation time — static registration, dynamic resolution. This also handles the "no channels session" case (Issue 3): if channel_manager() returns None, the wrapper returns channel:no_channels_session. The OperationEnv is already the integration point for per-connection state (ADR-024); adding a ChannelManager accessor is the natural extension.

This also affects recursive channels (inner connection needs its own binding): each channels connection's OperationEnv overlay carries its own ChannelManager reference, so nested connections resolve correctly.

The discovery split

Under the unified model, the discovery question splits cleanly:

  • "What may I open" (static, per-op): services/list (visibility- filtered + AccessControl::check(calling_peer_identity) server-side, per ADR-029 §6) + services/schema (per-op access_control). The existing server-side ACL-filtered discovery is preserved; the spec is the authority. channel:forbidden on the open op is the real check; the preview is "here's what the spec says, you can fail fast."
  • "What is currently there" (dynamic, ALPN-level): channel/resources/subscribe. Each ALPN crate that registers open ops also provides a resource enumerator (which containers are running, which TTY sessions are active). channel/resources/subscribe aggregates across all registered openable ALPNs. This is the data source the outsider's Gap 1(c) was missing — and it naturally lives in the ALPN crate, not in channels-call.

The access preview in resources/subscribe (ADR-073) becomes redundant — it's on the op spec, available via services/schema. We drop it from the resources/subscribe payload; the spec is the authority, and carrying a preview in a different shape invites staleness.

The exact aggregation shape (per-ALPN channels/<alpn>/resources/ subscribe ops merged by the generic channel/resources/subscribe, vs. callbacks registered with channels-call at registration time) is a POC-worthy detail. The architectural point is that the data source lives in the ALPN crate.


The ALPN three-category reframe (ADR-086 amendment)

ADR-086 §4 split the foundational handlers into two categories. Under the unified model, the "channels data-channel ALPNs" category dissolves — they're just call apps. The categories become:

Category TLS-layer? Identity at TLS? How they're reached Examples
Entry points yes no TLS ALPN negotiation h2, http/1.1, alknet/register
Endpoints yes yes TLS ALPN negotiation alknet/channels, alknet/call, alknet/ssh

Call apps (docker, tty, tunnel, socks5, fs, sftp, agent, etc.) are composed inside endpoints — they're not a separate TLS-layer category. They register ops on the call OperationRegistry. Some ops return JSON; some ops carry the channel_open marker and produce a binary stream. The endpoint (channels or bare call) determines the framing, not the app.

Previously "ALPNs gated by channels" implied they're a separate kind of thing that channels happens to gate, and each re-implements auth in a flat ALPN list. Now they're call apps — they inherit call's auth/composition/identity by construction. The gating is a consequence, not the definition.

Direct registration remains possible. The ProtocolHandler is still usable by both direct connections (HandlerRegistry → ProtocolHandler → BiStream) and channels-opened sessions. ADR-077's two-mode survives at the mechanism level; the canonical composition is through the call protocol. The table describes the canonical path, not a prohibition on direct use.

What this means for the ALPN crates (the lineage)

The lineage makes the unification obvious in retrospect: call → docker → tty → channels. Docker was the first call-consuming app (wraps bollard in JSON ops — ADR-058). Working docker surfaced TTY (exec needs a terminal). Working TTY surfaced channels (terminal output isn't JSON). The loop closes: the ALPN crates that channels serves become channels-consuming apps in the same shape docker is a call-consuming app. The only difference is that some ops produce a binary stream instead of (or alongside) a JSON response.

The dependency split parallels channels-core / channels-call (ADR-081):

  • Data-plane core (the ProtocolHandler, wire format, backend trait) — depends on alknet-core only. No call dep. ADR-057's "tty does not depend on call" property survives here.
  • Control-plane layer (the pub/sub ops, registration helper) — depends on the data-plane core + alknet-call + alknet-channels-call (for the channel machinery). ADR-057 is amended: the data plane stays call-free; the control plane is call by construction.

Whether that's a sub-crate split (alknet-tty-core + alknet-tty) or a feature flag (alknet-tty with a channels feature) is a packaging choice — two-way-door, not architecture. The architectural point is the dependency boundary.

SSH stays distinct

SSH is an endpoint ALPN that wraps channels. Under the unified model, the channels inside SSH have call on channel 0, and the call registry has the open ops. An SSH client opens an SSH channel; the SSH server translates that to channels/<alpn>/sub on channel 0 internally (SSH server as translator, same shape as the hub relay — ADR-079). The SSH client doesn't know about call; it just opens SSH channels. SSH's category (endpoint ALPN wrapping channels) is unchanged. This is SSH-implementation detail and SSH is deferred, but the shape holds.


The hub-relay flow (walked end-to-end)

This is the flow the outsider flagged as "where ADR-073's single-op design was doing the most implicit work." Walking it under the unified model to verify it holds.

Consumer subscribes, "give me a TTY" (the common case)

  1. Consumer (browser, another worker, another hub) calls channels/tty/sub (OperationType::Sub) with {params: {backend: docker, cmd: ["bash"], container: "abc123"}} on its channel 0 (call op on the consumer→hub leg).
  2. Hub's CallAdapter receives channels/tty/sub. The OperationRegistry checks the op's access_control against the consumer's identity. The op's spec has channel_open: Some(ChannelOpenSpec { alpn: "alknet/tty" }). Consumer is the initiator / consumer.
  3. Hub's CallAdapter recognizes the channel_open marker. The hub does NOT run a local TtyAdapter — the hub never runs protocol-specific handlers (ADR-079). It forwards to the producer (spoke/worker) via from_call: hub re-issues channels/tty/sub on the producer leg with forwarded_for = consumer.
  4. Producer's CallAdapter receives channels/tty/sub. OperationRegistry checks the op's access_control against the hub's identity (the direct caller per ADR-032). The producer's ownership store verifies the hub owns container:abc123 (per ADR-050). The producer consults ChannelLifecyclePolicy::check_open(hub) (Gap 2 — keyed by direct caller, opener recorded in the per-connection ledger).
  5. Producer's ChannelCore allocates channel_id, spawns TtyAdapter on the channel's BiStream with the docker backend, records opener (hub) in the ledger, returns {channel_id}.
  6. Hub receives the producer's {channel_id}, opens a matching channel on the consumer's side (hub is the responder for the consumer leg), records the channel_id mapping consumer_id ↔ producer_id, returns {channel_id: consumer_id} to the consumer.
  7. Hub byte-forwards between consumer_id and producer_id with 4-byte channel_id rewrite (ADR-079 unchanged).

The hub ran zero protocol-specific auth and zero protocol-specific data-plane work. It ran channels/tty/sub's access_control (call-protocol machinery) and forwarded. The relay contract from ADR-079 holds unchanged in shape; only the op name changed (from generic channel/open to per-ALPN channels/tty/sub).

Worker publishes, consumer subscribes (the proxy case)

The Pub/Sub op types are the matching mechanism. The hub is the broker.

Concrete example. A worker runs on a remote instance (vastai, runpod, a docker container). It wraps an opencode server's OpenAPI spec via from_openapi (call ops, JSON) and registers channels/tty/sub for terminal access. The worker connects to a hub and calls channels/tty/pub (OperationType::Pub) — "I'm publishing a TTY stream for this container." The hub records the published stream keyed by (op_name, params_hash). Later, a consumer (browser, another worker, another hub) calls channels/tty/sub (OperationType::Sub) with matching params. The hub matches by hash, proxies the BiStream between them.

Step-by-step:

  1. Worker calls channels/tty/pub (OperationType::Pub) with {params: {backend: docker, container: "abc123", cmd: ["bash"]}} on its channel 0. Worker is the initiator / producer.
  2. Hub's CallAdapter receives channels/tty/pub. OperationRegistry checks the op's access_control against the worker's identity. The op's spec has channel_open: Some(ChannelOpenSpec { alpn: "alknet/tty" }).
  3. Hub recognizes the channel_open marker. The hub records the published stream in its broker table keyed by ("channels/tty/pub", hash(params)). The Pub stream is held open — no channel is allocated yet. The worker is waiting for a subscriber.
  4. Later, a consumer calls channels/tty/sub (OperationType::Sub) 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 broker table. Finds the worker's Pub entry.
  6. Hub delivers the subscribe request to the worker's Pub stream as an event. The worker's pub 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/sub with the same params, the hub matches the same worker Pub entry. 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 resource and a producer for downstream consumers. The relay is protocol-agnostic: swap channel IDs, tunnel reads/writes between BiStreams. From the worker's perspective, the hub is the sole consumer — the hub owns the resource and re-exposes it under its own authority (the forwarded_for chain carries attribution, not 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 on a worker.

Teardown. If the worker disconnects before a subscriber arrives, the Pub stream is cancelled (connection drop → broker entry removed). If a consumer disconnects, the hub closes that consumer leg but keeps the producer channel open for other consumers. When the last consumer disconnects, the hub may close the producer channel or keep it (broker entry stays open for future subscribers — policy decision, not architecture).

The channel_id allocation symmetry

In both cases, channel_id allocation is by the responder (DP-1, unchanged). In the sub case, the responder is the producer (producer allocates). In the pub case, the responder is the hub on the worker→hub leg (hub allocates) and the downstream consumer on the hub→consumer leg (consumer allocates). The hub relay records the mapping across legs. This preserves ADR-073's "channel_id allocation is always by the responder" invariant.


What goes where (ADR plan)

ADR Scope Status
ADR-095 (new) "Openable ALPNs are operations" — channels is call with a binary data plane. The mental model, the channel_open marker on OperationSpec, OperationType::Pub/Sub, the ChannelCore seam (wrapper shape, POC-flagged), the hub-as-broker Pub/Sub matching, the discovery split, the ALPN category reframe. The unifying ADR. Ready to draft.
ADR-073 amendment channel/open dissolves into per-ALPN ops in channels/<alpn>/sub and channels/<alpn>/pub. channel/close, channel/control, channel/resources/subscribe stay generic (keyed by channel_id). The direction field is removed (replaced by OperationType). Error codes: channel:unknown_alpn becomes "operation not found"; channel:invalid_params becomes ordinary schema rejection. Ready to draft.
ADR-094 amendment (Gap 2) The per-connection opener ledger in channels-call. The decrement is keyed by the opener (from the ledger), not the closer. The decrement is called from every teardown path (close received, close sent, handler exit, connection drop), not just channel/close. The trait shape (check_open, on_close) survives. The teardown hooks (connection-drop, handler-exit) are new structural requirements on channels-call. Ready to draft.
ADR-086 §4 amendment "Channels data-channel ALPNs" → call apps (the third category dissolves — they're just call apps, some with binary-stream ops). The category distinction holds (them vs SSH); the description changes from "gated by channels" to "call apps." Ready to draft.
ADR-048 amendment + OQ-65 resolution WebSocket may carry either alknet/call (bare, for call-only clients) or alknet/channels (8-byte chunk framing, call on channel 0 inside). Channels framing required for binary-stream clients. OQ-65 resolved. "Native session, not gateway" survives (the decision). Ready to draft.
ADR-057 amendment TTY data plane stays call-free; control plane (pub/sub ops) depends on call. "Self-contained negotiation framing" becomes the data-plane negotiation (the 5-byte format's negotiation frame); the control-plane negotiation is the call op. Ready to draft.
ADR-058 clarification The boundary criterion (EventEnvelope-compatible → call op; incompatible → binary stream with call control plane) is preserved and sharpened. Probably a note, not a full amendment. Ready to draft.
New OQ (Gap 3) Per-identity connection cap against alknet-endpoint. Named, deferred(scope) — the deployment shape that needs it isn't concrete yet. Owned by alknet-endpoint, not channels. Ready to open.

What changes in each crate

alknet-call

  • OperationSpec gains channel_open: Option<ChannelOpenSpec> (the marker). This is a one-way-door API change (every spec-constructing code adds the field, defaulting to None).
  • OperationType gains Sub and Pub variants. The existing Subscription variant is deprecated (mapped to Sub in wire serialization). The registry restriction is loosened: Sub and Pub both use HandlerKind::Stream. Pub fills the structural gap — there was no way for a client to stream to the server before.
  • No other change. The OperationRegistry is unchanged — it still invokes ops by name, checks access_control, runs the handler. The marker is opaque to the registry; it's channels-call that reads it.

alknet-channels-call

  • Gains the ChannelCore (channel-id allocation, ChannelManager integration, per-connection opener ledger, ChannelLifecyclePolicy consultation, teardown hooks).
  • Gains the register_openable(spec, open_handler, channel_core) helper that wraps the ALPN's open handler with the channel machinery and registers the op on the call OperationRegistry.
  • The channel/close handler no longer calls policy.on_close(&op_ctx.identity) directly; instead, the per-connection ledger is walked on every teardown path and on_close is called per opener. (Gap 2.)
  • The generic ops (channel/close, channel/control, channel/resources/subscribe) stay in channels-call, keyed by channel_id.
  • The teardown hooks (connection-drop, handler-exit) are new. The connection-drop hook needs to interpose before channels-core's REQ-CH-02 clears the channel map — channels-call walks its ledger and decrements every opener before the map is cleared.

alknet-channels-core

  • Unchanged. The pure multiplexer (ADR-075/093) is auth-blind by design and stays that way. The per-connection opener ledger lives in channels-call, not channels-core.

alknet-tty (the first call app with binary-stream ops)

  • Data plane (the TtyAdapter, the 5-byte wire format, the TtyBackend trait) is unchanged. Used by both direct connections (HandlerRegistry → ProtocolHandler → BiStream) and channels-opened sessions (channels/tty/sub → allocate channel → spawn TtyAdapter on the channel's BiStream). Both paths converge at the BiStream.
  • Control plane (new): registers channels/tty/sub (OperationType::Sub) and channels/tty/pub (OperationType::Pub) on the call OperationRegistry at assembly time, alongside its ProtocolHandler on the HandlerRegistry for direct connections. The op specs carry the channel_open marker, the access_control (e.g., required_scopes: ["tty:sub"] and required_scopes: ["tty:pub"]), the input_schema (the NegotiateRequest), and the resource_id_path (e.g., /params/container for docker-backed TTY). channels/tty/pub is registered as OperationType::Pub — the worker publishes a TTY stream; the hub matches it to subscribers.
  • The sub handler validates params, consults ownership (ADR-050), prepares the TtyBackend, returns a "channel plan" to the ChannelCore wrapper, which spawns the TtyAdapter.
  • Dependency: data-plane core depends on alknet-core only (ADR-057's property survives); control plane depends on alknet-core + alknet-call + alknet-channels-call.
  • Optional: registers channels/tty/list-sessions etc. if session management is wanted. The ALPN gets a full management plane over the call protocol — which it didn't have before.

alknet-tunnel, alknet-socks5, alknet-fs, alknet-sftp

  • Same shape as TTY: data plane (ProtocolHandler) unchanged; control plane (pub/sub ops) new. Each registers its ops on the call registry at assembly time.
  • These crates are not yet specced (per ADR-085). When specced, they follow the same pattern from the start.

alknet-hub

  • Unchanged in shape. The hub composes call apps — docker (JSON ops), tty (pub/sub + binary stream), tunnel (pub/sub + binary stream), agent, etc. All register ops on the call registry. The hub's assembly layer wires them uniformly. The hub doesn't distinguish "call app" from "channels app" — both are just apps with ops registered. Some ops return JSON; some ops carry the channel_open marker and produce a binary stream. The composition model is uniform.
  • The relay contract (ADR-079) is unchanged in shape. The op name changes (from generic channel/open to per-ALPN channels/<alpn>/sub); the marker (not prefix-matching) is how the hub recognizes and translates channel-open ops.
  • from_call relay wrapper. When from_call imports a marked op on a channels-backed connection, it wraps it with relay machinery (forward + allocate local-leg channel + record id mapping + start byte-forward pumps) instead of the plain forwarding stub. ADR-022's provenance table gets a note: a FromCall-imported marked op is a leaf for composition purposes but carries relay machinery.

alknet-worker

  • Same as hub. A worker registers its ops on its call registry. The worker's assembly layer wires them uniformly.

alknet-endpoint

  • Unchanged (for now). Gap 3 (per-identity connection cap) is a new OQ against alknet-endpoint, deferred until a deployment forces it. Named now to stop the re-tangle.

alknet-http

  • WebSocket may carry either alknet/call (bare, for call-only clients) or alknet/channels (8-byte chunk framing, call on channel 0 inside). Channels framing required for binary-stream clients. ADR-048 amended; OQ-65 resolved.
  • The HTTP adapter's call-protocol surface (registration, browser API routes) is unchanged — it's call ops, not channels ops.
  • The MCP/OpenAPI adapters (to_openapi, to_mcp) must exclude marked ops — "produces a binary stream" is not expressible over a request/response export.

Open questions

  • Per-connection state plumbing (POC-critical). The OperationEnv::channel_manager() accessor is the proposed resolution for per-connection ChannelManager resolution. The exact trait shape (return type, whether it's on OperationEnv or a separate extension trait) is POC-worthy. The architectural point is that the OperationEnv is the integration point for per-connection state (ADR-024), and the wrapper resolves the ChannelManager at invocation time — static registration, dynamic resolution. Resolved by Gap E: use an extension trait in alknet-channels-call to avoid coupling alknet-call to channels types.

  • ChannelCore seam: wrapper vs invoke. This doc specs the wrapper shape (the ALPN's open handler returns a "channel plan"; channels- call's wrapper does the allocation/ledger/policy/spawn). The alternative (invoke — the handler calls context.channel_core.open(...) itself) inverts the call → channels-call dependency or requires a generic extension mechanism on OperationContext. The wrapper is preferred. The exact API shape (plan vs callback) is POC-worthy — flag for a small POC during implementation. Not a blocker for the architecture decision.

  • Resource enumeration aggregation shape. Each ALPN crate provides a resource enumerator. The generic channel/resources/subscribe aggregates. Exact shape (per-ALPN channels/<alpn>/resources/subscribe ops merged by the generic op, vs. callbacks registered with channels-call at registration time) is a POC-worthy detail. The architectural point is that the data source lives in the ALPN crate.

  • Op naming vs OQ-13. channels/tty/sub implies the ops belong to the channels service, but the TTY crate registers them — the docker precedent (docker/container/list) suggests tty/sub. Since relay recognition is via the marker (not name prefix), nothing constrains the name; but the ALPN→path-segment mapping (alknet/tty → tty) needs pinning either way, including what non-alknet/* ALPNs do.

  • Pub/Sub matching. The (op_name, params_hash) key is the proposed matching mechanism between Pub and Sub. The exact hash function, collision handling, and the broker lifecycle (when does the hub tear down a Pub entry with no subscribers?) are POC-worthy details. The architectural point is that the call protocol's OperationType::Pub/Sub is the natural fit, and the hub is the broker.

  • Does ProtocolHandler want channel-context? Probably not for TTY/tunnel/ssh (they just want a BiStream), but maybe for ALPNs that want the opener's identity for per-session logging/ACL. An optional channel-context passed alongside the BiStream. Not a blocker; defer. Two-way-door implementation detail.

  • Per-identity connection cap (Gap 3). A per-identity connection cap at the endpoint/accept layer, analogous to ChannelLifecyclePolicy but before any ChannelsAdapter / CallAdapter / channel-0 buffer exists. Lives in alknet-endpoint. deferred(scope) — the deployment shape that needs it isn't concrete yet. Named now to stop the re-tangle.

  • channel-operations.md §ACL-flow forwarded_for inconsistency. Step 4 says "the spoke's ownership store verifies the hub (or the forwarded_for browser, per policy) owns container:abc123." This contradicts ADR-032 (forwarded_for is metadata, not authority — AccessControl::check never reads it) and ADR-050 §4c ("the spoke sees the hub as the owner"). The "(or the forwarded_for browser, per policy)" clause is a spec inconsistency. Fix while editing. The spoke authorizes the hub, full stop; the hub's per-browser ACL is the hub's own layer.


Known gaps (identified during review, 2026-07-19)

These are structural gaps in the unified model that need resolution before the ADRs can be drafted. They are not open questions about POC-worthy details — they are places where the model is underspecified or contradicts itself.

Gap A — Pub handler shape is underspecified (one-way-door)

The doc says Pub uses HandlerKind::Stream (line 437), but the current StreamingHandler type is inherently server→client — the handler produces a stream:

pub type StreamingHandler = Arc<
    dyn Fn(Value, OperationContext) -> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
        + Send + Sync,
>;

For Pub, the initiator is the producer streaming to the responder. The handler on the responder side needs to consume a stream from the initiator, not produce one. The doc gestures at this ("the handler receives the client's stream") but never specifies the type signature.

This is a one-way-door API change — the handler trait shape can't be retrofitted later without breaking every handler. Options:

(a) A new HandlerKind::Sink variant with a consuming handler type: Fn(Value, OperationContext, RecvStream) -> Future<Output = ResponseEnvelope>. The RecvStream is the initiator's data stream. Clean separation from Stream — different handler shapes for different directions.

(b) The handler receives a BiStream as part of OperationContext (or a separate channel-context). The handler reads from the initiator's half and writes to the responder's half. More general but muddies the handler signature — every handler gets a BiStream it may not need.

(c) Pub is not a separate handler at all — the Pub op is matched to a Sub op by the broker, and the actual data-plane handler is always the Sub handler (the producer side). The Pub initiator's stream is proxied through the broker to the Sub handler's BiStream. This keeps the handler shape unchanged (always server→client from the handler's perspective) but requires the broker to hold the Pub stream open and splice it.

Recommendation: (a) or (c). (a) is cleaner for the type system; (c) avoids a new handler variant entirely by making Pub purely a broker-level concept. The choice depends on whether non-brokered Pub (direct client→server streaming without a hub) is a use case. If direct Pub is needed, (a) is required. If Pub only exists in the hub-relay context, (c) suffices.

Gap B — Hub broker is a new component with no spec

The doc describes matching Pub ↔ Sub by (op_name, params_hash) (lines 409-418, 660-711), but this requires a broker — a component that:

  • Holds Pub streams open while waiting for subscribers.
  • Matches incoming Sub calls to held Pub streams.
  • Fans out one producer stream to N consumers.
  • Manages teardown (producer disconnect, last consumer disconnect, policy for keeping the producer channel open when all consumers leave).

This broker doesn't exist in the current architecture and isn't specced anywhere. The doc says "the hub is the broker" but doesn't say:

  • What crate owns the broker (alknet-hub? alknet-channels-call? A new alknet-broker?).
  • What its API is (register a Pub stream, match a Sub call, fan out, teardown).
  • How it integrates with the dispatch loop. The current dispatch model runs a handler and gets a stream back — there's no mechanism to hold that stream open across multiple future Sub calls. The broker needs to interpose between the dispatch result and the wire: when a Pub handler returns, the broker holds the stream; when a later Sub arrives, the broker matches and splices.

Resolution needed: At minimum, a sketch of the broker's crate location, core API (register/match/fanout/teardown), and integration point with the dispatch loop. This is not a POC-worthy detail — it's a new architectural component that the unified model depends on.

Gap C — from_call relay wrapper can't access ChannelManager

The doc says (lines 362-372, 851-856) that from_call must wrap marked ops with relay machinery (forward + allocate local-leg channel + record id mapping + byte-forward pumps). But from_call currently:

  • Takes only a CallConnection and FromCallConfig.
  • Builds forwarding stubs that call connection.call_with_payload().
  • Has no access to a ChannelManager, ChannelCore, or any channels infrastructure.

The hub's assembly layer would need to wire a ChannelManager into from_call somehow. The doc acknowledges this is "the load-bearing piece of the relay under the unified model" but doesn't specify the mechanism.

Resolution needed: Either from_call gains a ChannelManager parameter (coupling alknet-call to alknet-channels-core), or the relay wrapping happens in a separate layer (e.g., a from_call_with_channels in alknet-channels-call or alknet-hub that wraps the from_call result with channel machinery). The latter preserves the layering (call doesn't depend on channels).

Gap D — channel_id allocation in Pub case contradicts the invariant

The doc says (lines 732-739) "channel_id allocation is always by the responder." But in the Pub case walkthrough (lines 673-701):

  • Worker calls channels/tty/pub (worker is initiator/producer).
  • Hub is responder on the worker→hub leg.
  • Step 6: "Worker's ChannelCore allocates channel_id on the worker→hub leg."

The worker is the initiator, not the responder. If the responder (hub) allocates, the hub needs a ChannelManager for the worker's connection — but the hub doesn't own the worker's channels connection; the worker does. The hub can't allocate a channel on a connection it doesn't own.

Resolution needed: Either:

(a) Relax "responder allocates" to "the side that owns the ChannelManager for that connection allocates." In the Pub case, the worker owns its own channels connection, so the worker allocates — even though it's the initiator. The invariant becomes "the connection owner allocates," which is the same as "responder allocates" in the Sub case (where the responder is the connection owner) but differs in the Pub case.

(b) The hub allocates on the worker's behalf via a relay mechanism (the hub sends a channel/allocate control message to the worker, the worker allocates and returns the id). More complex, preserves the invariant literally.

Recommendation: (a). The real invariant is "the side that holds the ChannelManager allocates." In the Sub case that's the responder; in the Pub case that's the initiator. The doc should state this explicitly.

Gap E — OperationEnv::channel_manager() couples call to channels

The doc proposes (lines 488-501) adding fn channel_manager(&self) -> Option<&ChannelManager> to the OperationEnv trait in alknet-call. This means alknet-call would depend on alknet-channels-core (or at least know about ChannelManager). The doc's own layering principle (line 178) says channels-core is auth-blind and call is above it. Adding a channels type to the call crate's core trait inverts this dependency.

Resolution: Use an extension trait in alknet-channels-call:

// in alknet-channels-call
trait ChannelOperationEnv: OperationEnv {
    fn channel_manager(&self) -> Option<&ChannelManager>;
}

The wrapper handler downcasts context.env to &dyn ChannelOperationEnv at invocation time. If the downcast fails (no channels session), returns channel:no_channels_session. This keeps alknet-call free of any channels types and preserves the layering.

Gap F — channel_open marker wire format not specified

The doc says (lines 355-360) the marker must survive discovery serialization — it is part of the services/schema payload. But spec_to_json in discovery.rs:192-208 serializes a fixed set of fields. The marker needs:

  • A JSON field name (e.g., "channel_open").
  • A JSON shape. Options:
    • "channel_open": {"alpn": "alknet/tty"} — carries the ALPN, redundant with the op name but self-describing.
    • "channel_open": true — a boolean marker; the ALPN is derived from the op name or the ChannelOpenSpec registry.
    • "channel_open": "alknet/tty" — just the ALPN string.
  • Parsing on the from_call side in rebuild_spec_for (currently ignores unknown fields; would need to parse channel_open).

The op_type enum in the schema ("query", "mutation", "subscription") also needs "sub" and "pub" added.

Resolution needed: Pick the JSON shape and specify the exact field name. The boolean marker ("channel_open": true) is simplest and sufficient — the ALPN is already in the op name (channels/tty/sub → ALPN is alknet/tty). The ChannelOpenSpec struct in the doc (line 342-344) carries alpn but that's the in-process struct; the wire format can be a boolean.

Gap G — resource_id_path ACL vs handler ownership check is redundant

The doc says (lines 54-56) resource_id_path works for channel-open ops — the ACL check in OperationRegistry::invoke extracts the resource ID from the input and checks ownership. But the doc also says (lines 443-449) the handler "consults ownership (ADR-050)" separately.

If the ACL already checked ownership via resource_id_path and an OwnershipProvider, the handler's ownership check is redundant. If the handler needs to do its own check (e.g., because the resource ID isn't in the input at a fixed JSON pointer, or because the check involves ALPN-specific logic), then resource_id_path on the spec is misleading — it implies the ACL handles it when it doesn't.

Resolution needed: Clarify the relationship:

  • The ACL check (via resource_id_path + OwnershipProvider) answers "may this identity touch resources of this type, and if a specific resource is targeted, does this identity own it?" This is the coarse gate — it runs before the handler.
  • The handler's ownership check answers ALPN-specific questions the ACL can't express (e.g., "is this container in a state that allows TTY attachment?"). This is the fine gate — it runs inside the handler.

The two are complementary, not redundant. The doc should state this explicitly and note that the handler check is ALPN-specific business logic, not a re-implementation of the ACL.


Wire format family: the [discriminant][length][payload] shape

The call protocol's EventEnvelope — { type, id, payload } — is structurally identical to the channels header and TTY's 5-byte format. All three are variations on the same theme: a fixed-size discriminant followed by a length-prefixed payload.

Format Header Discriminant fields Payload
Call JSON (ADR-064) 4-byte BE length prefix type (string), id (UUID string) JSON Value
Call binary (hypothetical) 9 bytes request_id (u32 BE), event_type (u8) raw bytes
Channels (ADR-093) 8 bytes channel_id (u32 BE) raw bytes (handler's framing)
TTY (ADR-052) 5 bytes stream_type (u8) raw bytes

The call protocol's 5 event types map directly to 5 type bytes:

Event Type byte
call.requested 0x01
call.responded 0x02
call.completed 0x03
call.aborted 0x04
call.error 0x05

A binary call protocol frame would be:

[request_id: u32 BE][event_type: u8][length: u32 BE][payload bytes]

9 bytes of header vs the JSON envelope's ~80+ bytes (UUID string + type string + JSON structure wrapping). For TTY chunks of a few bytes, this is the difference between viable and absurd — the JSON envelope overhead dwarfs the payload.

The binary call frame is the same shape as TTY's 5-byte format ([stream_type: u8][length: u32][payload]) with a request_id field added. Channels had a stream_type field before ADR-093 removed it — the binary call frame restores that shape but with call-protocol semantics (request correlation + lifecycle stage) instead of stream-type semantics (stdin/stdout/stderr/control).

The two axes don't fully collapse

The call protocol's event types (request/response/completion/error/abort) and TTY's stream types (stdin/stdout/stderr/ctrl_in/ctrl_out) are different axes:

  • Call protocol axis: what stage of the request/response lifecycle? (I'm sending you a request, I'm responding to your request, the stream is done, something went wrong, cancel everything.)
  • TTY axis: what kind of data is this? (input to the process, output from the process, error output, window resize, exit code.)

They don't map 1:1. stdout and stderr are both "responses" in the call protocol sense but different stream types in the TTY sense. Window resize is a "request" in the call protocol sense but a control message in the TTY sense. The exit code is a "completion" in the call protocol sense and a control message in the TTY sense.

This means the binary call protocol frame is not a drop-in replacement for TTY's 5-byte format — TTY still needs its own sub-multiplexing within the data plane. But the shape is the same family, and the binary call frame is the natural bridge: it carries call-protocol semantics (request correlation, lifecycle) in the same wire-format family as channels and TTY.

Composition: binary call inside channels

When a channel_open-marked op's data plane uses binary call framing, the composition inside a channels connection is:

[channel_id: u32 BE][ch_len: u32 BE][request_id: u32 BE][event_type: u8][len: u32 BE][payload]
       \_________  __________/   \___________________  ______________________/
                 |                                     |
          channels header                      binary call frame
            (8 bytes)                       (9 bytes + payload)

The channels layer strips its 8-byte header and routes by channel_id. The handler receives the binary call frame and parses request_id + event_type + payload. This is the same add/strip composition the doc describes for TTY-inside-channels (line 39-57 of channels-wire.md), but with the binary call frame replacing TTY's 5-byte format as the handler's framing.

What this means for the channel_open marker

The channel_open marker on an OperationSpec currently says "this op's stream is binary, use channels framing." Under this observation, it could say something more specific: "this op's stream uses binary call protocol framing" — the handler speaks the binary call protocol natively. The marker tells the call adapter: instead of JSON EventEnvelope frames on the default call stream, use binary call protocol frames on a dedicated channel.

This is a refinement, not a contradiction. The doc's current framing ("channels is call with a binary data plane") is correct; this observation adds that the binary data plane's wire format is the call protocol's own structure, just binary-encoded — not an unrelated format.


alknet-typedef: JSON Schema as the binary struct engine

The wire format family observation (above) converges with two other threads in the codebase: the typedef.ts schema kinds from TypeBox (TStruct, TFloat32, TInt32, etc.) and the russh-sftp protocol packets. The common pattern: a JSON Schema describes the shape of binary data, and the binary data is the struct's bytes at computed offsets.

The russh-sftp squint

russh-sftp's protocol has 29 packet types, each a struct with typed fields:

// read.rs
pub struct Read {
    pub id: u32,
    pub handle: String,
    pub offset: u64,
    pub len: u32,
}

// write.rs
pub struct Write {
    pub id: u32,
    pub handle: String,
    pub offset: u64,
    pub data: Vec<u8>,  // serde_bytes
}

The wire format is [length: u32][type: u8][payload] where payload is the struct's serde bytes. The Packet enum dispatches on the type byte — a tagged union of structs.

Under the typedef lens, Read is a TStruct with fields:

{
  "TypeDef:Struct": true,
  "properties": {
    "id": { "TypeDef:Uint32": true },
    "handle": { "TypeDef:String": true },
    "offset": { "TypeDef:Uint64": true },
    "len": { "TypeDef:Uint32": true }
  }
}

The Packet enum is a TUnion — discriminated by the type byte, each variant a TStruct. The JSON Schema describes the shape; the binary data is the struct's bytes at computed offsets. The serde derive on each struct is doing exactly what an offset map would do: serialize fields in order, deserialize by reading at computed positions.

The typedef.ts schema kinds

typedef.ts (/workspace/@alkdev/typebox/example/typedef/typedef.ts, 619 lines) defines custom TypeBox schema kinds that carry binary layout semantics:

Kind TypeBox key Rust type Size
TFloat32 TypeDef:Float32 f32 4
TFloat64 TypeDef:Float64 f64 8
TInt8 TypeDef:Int8 i8 1
TInt16 TypeDef:Int16 i16 2
TInt32 TypeDef:Int32 i32 4
TUint8 TypeDef:Uint8 u8 1
TUint16 TypeDef:Uint16 u16 2
TUint32 TypeDef:Uint32 u32 4
TString TypeDef:String length-prefixed bytes variable
TStruct TypeDef:Struct record of fields sum of field sizes
TUnion TypeDef:Union tagged union discriminator + variant
TArray TypeDef:Array repeated element count × element size
TEnum TypeDef:Enum string enum variable
TRecord TypeDef:Record string-keyed map variable
TBoolean TypeDef:Boolean bool 1
TTimestamp TypeDef:Timestamp ISO 8601 string variable

These are registered in TypeBox via TypeRegistry.Set with custom validators. The Rust jsonschema crate supports the same pattern via with_keyword("TypeDef:Float32", factory) — each TypeDef:* kind maps to a custom keyword validator in Rust. Same semantics, different language, same JSON Schema wire format.

What alknet-typedef would be

A small crate (alknet-typedef) that takes a JSON Schema with TypeDef:* custom keywords and produces:

  1. An offset map — walks the schema, computes byte offsets for each field based on type sizes and field order. TFloat32 → 4 bytes, TStruct → sum of field sizes with alignment, TArray → element size × count, TString → 4-byte length prefix + UTF-8 bytes.

  2. Read/write functions — given a &[u8] buffer and a field path, read the field's bytes at its offset (zero-copy for fixed-size types, slice for variable-length). Given a &mut [u8] buffer, write a value at its offset.

  3. Validation — via jsonschema custom keywords, validates that a buffer's bytes match the schema's type constraints (range checks for ints, UTF-8 for strings, field presence for structs).

  4. WASM-clean — serde + jsonschema + byte manipulation. No tokio, no platform deps. Compiles to wasm32-unknown-unknown for browser use (the same constraint as channels-core's sync core).

The crate is ~500 lines: the offset computation is a recursive walk of the schema JSON, the read/write functions are pointer casts and slice operations, the custom keywords are ~10 lines each. The heavy lifting is done by jsonschema and serde_json.

What this replaces

  • typebox-rs (/workspace/@alkimiadev/typebox-rs/) — the builder, schema, registry, validate, value modules are replaced by jsonschema

    • the offset map. The codebase drops from "a port of TypeBox" to "jsonschema + an offset map + ~50 lines of custom keyword implementations."
  • Per-protocol serde structs — russh-sftp's 29 packet structs with serde derives are replaced by a single JSON Schema + the typedef engine. The schema is the format definition; the engine reads/writes bytes at computed offsets. Adding a new SFTP packet type is adding a variant to the schema JSON, not writing a new Rust struct + serde impl.

  • Per-handler wire format code — TTY's 5-byte format parser, the binary call frame parser, the metatensor offset computer — all become instances of the same engine with different schemas. The engine is generic; the schema is the configuration.

Consumers

Consumer Schema describes Engine provides
russh-sftp packets 29 packet structs + Packet union Read/write SFTP frames from bytes
metatensor Model layout (ConvNet struct, tensor refs) Offset map for mmap'd tensor access
binary call frames call.requested / call.responded / etc. structs Read/write binary call frames
TTY negotiation NegotiateRequest / NegotiateResponse structs Read/write TTY control frames
channels wire ChunkHeader { channel_id, length } Already trivial (8 bytes, no schema needed)

The russh-sftp case is the most instructive: the Packet enum's TryFrom<&mut Bytes> impl is a hand-written dispatch on a type byte followed by serde deserialization. Under typedef, the dispatch is TUnion — the schema says "byte 0 is the discriminator, bytes 1..N are the variant struct." The engine reads the discriminator, looks up the variant schema, computes offsets, reads fields. Same result, no per-packet-type code.

Relationship to the call protocol

The call protocol's OperationSpec.input_schema and OperationSpec.output_schema are already JSON Schemas. With typedef, those schemas can describe binary payloads — not just JSON validation shapes. An op with channel_open: Some(...) and a typedef schema for its input/output is a binary-stream op whose wire format is schema-driven. The handler doesn't write a serde struct; it writes bytes at schema-computed offsets. The schema is the format.

This closes the loop on "channels is call with a binary data plane": the binary data plane's wire format is the call protocol's own schema system, just binary-encoded. The channel_open marker says "use binary framing"; the typedef engine says "here's how to read/write the binary payload."


References

  • ADR-073: channel lifecycle operations (amended by this resolution — channel/open dissolves into per-ALPN ops; generic ops stay)
  • ADR-094: per-identity channel cap (amended by this resolution — the ledger + teardown hooks; the trait shape survives)
  • ADR-079: hub relay — translate, not forward (unchanged in shape; the op name changes, the marker replaces prefix-matching)
  • ADR-050: dynamic resource ownership (the resource_id_path mechanism that works again under per-ALPN ops)
  • ADR-028: the "parallel authorization system" precedent (superseded by ADR-029 for exactly the structural miss this resolution avoids re-committing one layer down)
  • ADR-029: peer-graph routing (the existing AccessControl::check path this resolution preserves)
  • ADR-086: endpoint types and entry points (§4 amended — the "channels data-channel ALPNs" category dissolves — they're call apps)
  • ADR-048: WebSocket native session (amended — WebSocket may carry either alknet/call or alknet/channels; channels framing required for binary-stream clients; OQ-65 resolved)
  • ADR-057: alknet-tty does not depend on call (amended — the data plane stays call-free; the control plane depends on call)
  • ADR-058: alknet-docker on alknet/call (the boundary criterion preserved and sharpened — EventEnvelope-compatible → call; incompatible → binary stream with call control plane)
  • ADR-075: ChannelsAdapter and ChannelManager (the auth-blindness this resolution preserves — the ledger lives in channels-call, not channels-core)
  • ADR-092: BiStream as the handler leaf (the transport-leaf layer, settled; this doc is the layer above it)
  • ADR-093: channels pure channel multiplexing (the wire format, the handler-owns-sub-multiplexing property — both preserved)
  • docs/research/stream-unification/findings.md — the precedent for this research-then-sync pattern
  • docs/architecture/crates/channels/channel-operations.md — the spec this resolution rewrites (per-ALPN open ops; generic close/control/resources; the ACL-flow forwarded_for fix)
  • docs/architecture/crates/call/operation-registry.md — OperationSpec, AccessControl, OperationRegistry::invoke (the machinery this resolution reuses verbatim, with the channel_open marker added)