Files
alkcall/docs/architecture/decisions/036-channel-0-pre-negotiated-call.md
glm-5.2 cc470a363a docs: port architecture specs + 45 ADRs from alknet, renumbered
Port the call + channels architecture documentation from the alknet
mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045.

Renumbering map (alknet -> alkcall):
  Core:        001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012
  Call:        005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030
  Shared:      003,009,013 -> 031-033
  Channels:    071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045

3 superseded/reversed ADRs kept for historical trail:
  - ADR-013 (irpc foundation, superseded by ADR-014)
  - ADR-023 (peer-scoped filtering, superseded by ADR-024)
  - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only)

Ported docs (11 spec files + README + open-questions):
  - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md
  - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md
  - README.md (index with doc table, ADR table grouped by category, key principles)
  - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap)

Cross-reference rewriting:
  - All ADR-NNN references rewritten single-pass (no chaining bug)
  - Markdown link paths fixed
  - Title lines aligned with filenames
  - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note

The open-questions.md includes OQ-22 (new): the call protocol pub/sub
gap — subscribe exists but pub does not, needed for channels
channel/resources/subscribe fan-out. This is the next ADR to write
(alkcall ADR-046).
2026-08-12 07:06:57 +00:00

7.7 KiB

ADR-036: Channel 0 Is Pre-Negotiated alknet/call

Status

Accepted (amended 2026-07-18 by ADR-035 — channel 0's stream_types field is removed; the channels layer has no stream_type concept; the call protocol's EventEnvelope framing is the channels payload, carried transparently — see "Amendment (ADR-035, 2026-07-18)" below)

Amendment (ADR-035, 2026-07-18)

Channel 0's stream_types field (the [0, 1] active set) is removed. The channels layer has no stream_type concept (ADR-035) — it carries the call protocol's EventEnvelope framing (ADR-014) transparently in the 8-byte header's payload. The call protocol's bidirectionality (client writes requests, server writes responses) is a call-protocol concern, not a channels-layer concern; the channels layer routes by channel_id only and yields a BiStream to the CallAdapter. The CallAdapter's accept_bi() returns one BiStream (per ADR-009); the call protocol reads/writes EventEnvelope frames on it, exactly as on a top-level alknet/call connection.

The body below describes the original (with stream_types) shape; the amendment above is the operative decision. See ADR-035 for the resolution rationale and the cross-ADR impacts.

Context

A channels connection carries N logical channels. One of them must carry the call protocol — the JSON-RPC layer that orchestrates channel lifecycle (channel/open, channel/close, channel/control, channel/resources). The question is how channel 0 relates to the call protocol: is it a special "control plane" with its own framing, or is it just alknet/call pre- negotiated?

The phase-0 research (docs/research/alknet-channels/phase-0-findings.md §DP-2) recommends channel 0 is alknet/call pre-negotiated — no special framing, no separate control-plane wire format. The call protocol runs on channel 0 exactly as it runs on a top-level alknet/call QUIC connection.

This matters because the alternative (a special control plane) would mean the channels layer has its own JSON protocol for channel lifecycle, parallel to and duplicating the call protocol's OperationRegistry, AccessControl, OperationContext, and forwarded_for machinery. That duplication is the "re-implement every protocol's framing per transport" problem the hub motivation (§Hub Motivation) identifies as the thing channels exists to collapse.

Decision

Channel 0 is alknet/call, pre-negotiated. Both sides of a channels connection know that channel_id = 0 is routed to the CallAdapter without an explicit channel/open exchange. The CallAdapter receives a Connection backed by channel-0 chunk reassembly and dispatches operations exactly as it does on a top-level alknet/call connection.

What this means concretely

  1. Channel 0 uses the same 9-byte chunk format as every other channel (ADR-034). Its chunks have channel_id = 0 in the header. No special first-byte trick, no separate framing.

  2. The CallAdapter is unchanged. It receives a Connection, calls accept_bi(), gets one bidi stream (the channel-0 reassembled stream), and runs its dispatch loop. EventEnvelope frames ride on stream_type = 0 of channel 0. The CallAdapter does not know it is inside a channels connection.

  3. Channel lifecycle operations are call operations. channel/open, channel/close, channel/control, channel/resources are registered on the call protocol's OperationRegistry at assembly time (ADR-037). They are dispatched through the existing OperationContext (identity, scopes, capabilities, ownership, forwarded_for), gated by the existing AccessControl::check. No new auth machinery, no new framing, no protocol version bump.

  4. Channel 0 is allocated at ChannelsAdapter::handle entry. The ChannelsAdapter constructs channel 0's reassembly buffers, wraps them as a Connection (via Connection::from_source with a ChannelBidiStreamSource — ADR-008/074), and hands that Connection to the CallAdapter — exactly as if alknet/call had been the top-level ALPN. The CallAdapter is looked up in the same HandlerRegistry as every other ALPN.

Channel 0's stream_type usage

stream_type direction purpose
0 write (client→server) EventEnvelope frames from the client (call.requested, call.aborted)
1 read (server→client) EventEnvelope frames from the server (call.responded, call.completed, call.error)

Channel 0 uses stream_types [0, 1] — the call protocol is bidirectional via two unidirectional halves, the same way every channel type works (ADR-034 §stream_type decomposition). The call protocol's (SendStream, RecvStream) pair maps directly: SendStream backed by stream_type 0, RecvStream backed by stream_type 1. Both sides write to their write half and read from their read half — no shared stream_type both sides write to.

The call protocol is JSON-only and single-stream by design (ADR-014). stream_types 2-255 on channel 0 are reserved for future call-protocol sub-streams.

Consequences

Positive:

  • No control-plane duplication. The channels layer reuses the call protocol's OperationRegistry, AccessControl, OperationContext, forwarded_for, and StreamingHandler (ADR-021) machinery verbatim. Channel lifecycle is just another class of call operations.
  • The CallAdapter is transport-agnostic by construction — it works identically whether the Connection is a top-level QUIC stream or a channels-reassembled channel-0 stream. This is the "streams are streams" insight made concrete.
  • channel/resources/subscribe (ADR-037) is a Subscription operation on channel 0, using the already-implemented StreamingHandler / invoke_streaming path (ADR-021). The resource registry is a live view, not a polled snapshot.
  • Auth is inherited: channel/open goes through AccessControl::check exactly like any other call operation. The channels layer does not re- implement auth.

Negative:

  • Channel 0 is a single point of orchestration. If channel 0's CallAdapter hangs, no new channels can be opened. This is the same property as the call protocol today (one dispatch loop per connection) and is not a new vulnerability.
  • The call protocol's JSON-only nature means channel lifecycle operations are JSON. For high-frequency control (e.g., per-keystroke resize), this is more overhead than a binary control frame. The division (ADR-037 §DP-4) handles this: stream_type 3 on the data channel for data-ordered control, call operations for lifecycle and infrequent control.

Door type

One-way. Channel 0's role as alknet/call pre-negotiated is a wire- format and protocol-structure commitment. Changing it after deployments exist (e.g., to a special control plane) requires a version migration and re-architecting the channel lifecycle operations. The reservation of stream_type 1-255 on channel 0 is a two-way-door detail (they're currently unused; assigning them is additive).

References

  • ADR-034: channels wire format (the 8-byte chunk header channel 0 uses, as amended by ADR-035)
  • ADR-035: channels pure channel multiplexing (amends this ADR — channel 0's stream_types field removed; the call protocol's framing is the channels payload, carried transparently)
  • ADR-037: channel lifecycle operations (registered on channel 0's OperationRegistry)
  • ADR-014: irpc never integrated — hand-rolled EventEnvelope framing (the call protocol channel 0 carries)
  • ADR-021: StreamingHandler for subscriptions (the machinery channel/resources/subscribe uses)
  • ADR-008: BidiStreamSource trait (the Connection extension point)
  • docs/research/alknet-channels/phase-0-findings.md §DP-2, §Channel 0