- 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
1497 lines
73 KiB
Markdown
1497 lines
73 KiB
Markdown
---
|
||
status: draft
|
||
last_updated: 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:
|
||
|
||
```rust
|
||
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`:**
|
||
|
||
```rust
|
||
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
|
||
`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
|
||
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:
|
||
|
||
```rust
|
||
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`:
|
||
|
||
```rust
|
||
// 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:
|
||
|
||
```rust
|
||
// 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:
|
||
|
||
```json
|
||
{
|
||
"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) |