docs: correct WS framing claims from spike; add implementation plan

Spike against alkcall source resolved ADR-067 assumptions:
- write_chunk issues header+payload as separate write_alls; channel
  0's write_frame issues prefix+body separately — a logical write can
  surface as multiple chunks, so the WS adapter must parse outgoing
  chunk boundaries (byte-stream treatment both directions), not assume
  write-per-chunk or message-per-chunk
- MAX_CHUNK_LEN is 16 MiB; the WS path needs a practical message cap
  with oversized chunks split across messages
- install_channel_zero + run_loop_single_stream confirmed as the exact
  server-side seam; EOF/teardown invariants already specified by
  alkcall (REQ-CH-01/02)

Corrections applied to websocket.md, ADR-067, OQ-01.

docs/plans/implementation.md: scoped plan guiding task decomposition —
spike findings, 4-phase build order, OQ dispositions, task conventions.
This commit is contained in:
2026-08-28 05:55:03 +00:00
parent 320ea87b08
commit eaf1a203bc
4 changed files with 160 additions and 24 deletions
@@ -46,18 +46,23 @@ in the alknet design). The path is an axum route on the `HttpAdapter`
router, subject to the same reserved-path collision rule as any
default-surface route ([ADR-046](046-assembly-layer-custom-http-routes.md)).
### Framing: the WS message boundary carries chunks, not envelopes
### Framing: the chunk header is the boundary, not the WS message
The alknet design's "one `EventEnvelope` = one binary WS message, no
length prefix" framing is **superseded**. The WS message boundary now
carries channels chunks; the call protocol's envelopes ride inside
channel 0 as length-prefixed JSON (alkcall ADR-014 frame format), the
same as any other in-line channels transport.
length prefix" framing is **superseded**. The WS binary message stream
is treated as a byte stream; the 8-byte chunk header is the only
framing. Call-protocol envelopes ride inside channel 0 as
length-prefixed JSON (alkcall ADR-014 frame format), the same as any
other in-line channels transport. A chunk may span WS messages and a
WS message may carry chunk fragments (in practice the write path
usually produces one chunk per message, but nothing may depend on
it — channel 0's frame writer issues two writes, prefix then body,
which can surface as two chunks).
Layering on the wire, for a call frame over WS:
```
WS binary message
WS binary message(s) — byte stream
└── chunk header [channel_id: u32 BE][length: u32 BE] (8 bytes)
└── payload = frame [len: u32 BE][EventEnvelope JSON] (channel 0)
```
+14 -7
View File
@@ -22,15 +22,22 @@ with their resolutions; new alkhttp OQs start at OQ-01.
needs nailing down before implementation:
(a) inbound buffer bound (bounded channel between the WS read task
and the `AsyncRead` half — what bound, what policy on overflow);
(b) write-side chunk completeness (the adapter assumes the mux emits
each chunk as one contiguous `write_all` — verify against alkcall's
`MuxRunner` and codify, or add an internal chunking layer);
(b) write-side chunk boundary parsing (verified against alkcall
source: the mux emits one mpsc payload per chunk, but a logical
write above the mux — e.g. channel 0's `write_frame`, which issues
prefix and body as separate `write_all`s — can surface as multiple
chunks; the adapter must parse outgoing chunk headers rather than
assume write-per-chunk; also confirm the WS-message cap policy for
chunks up to `MAX_CHUNK_LEN` = 16 MiB — split across messages, and
what the practical cap is for browser stacks);
(c) flush mapping (`AsyncWrite::flush` → WS message emission point);
(d) close mapping (WS close code → transport EOF → REQ-CH-02
teardown; and does `AsyncWrite::shutdown` map to a WS Close frame or
to a zero-length chunk sentinel?).
- **Blocked on**: nothing (implementation-blocking, not
decision-blocking — resolve during implementation of the WS adapter)
teardown; `AsyncWrite::shutdown` maps to the zero-length EOF
sentinel (REQ-CH-01) then a WS Close frame — confirm this ordering
against the mux's pump-exit behavior).
- **Blocked on**: nothing (the spike resolved the factual
sub-questions; the remaining items are implementation decisions to
lock during the WS adapter task)
### OQ-02: `/publish` body framing details
+22 -11
View File
@@ -112,9 +112,14 @@ WS binary message (message boundary = transport frame)
channel N: handler-owned framing (opaque to channels)
```
- **One WS binary message = one chunk** (header + payload). The WS
message boundary is the chunk boundary — no re-splitting, no
coalescing across messages required.
- **WS messages are transport frames, not protocol boundaries.** The
WS binary message stream is treated as a byte stream; the 8-byte
chunk header is the only framing. A chunk may span WS messages and a
WS message may carry chunk fragments — the adapter (below) is the
seam. (In practice the write path usually emits one chunk per
message, but nothing may depend on it: channel 0's frame writer
issues two writes — length prefix, then body — which the mux can
deliver as two mpsc payloads.)
- **Channel 0's payload is the call protocol's frame format**
(alkcall ADR-014): a 4-byte big-endian length prefix + UTF-8 JSON
`EventEnvelope`. This is exactly the framing channel 0 uses over
@@ -141,17 +146,23 @@ whole `Message`s. The adapter bridges the two, in both directions:
drains the buffer. Backpressure: the reader task awaits a bounded
buffer slot before admitting the next message (bound: OQ-01).
- **Outbound (bytes → WS):** the `AsyncWrite` half accumulates bytes
into a pending buffer and emits exactly one WS binary message per
chunk — the mux writes header+payload as one contiguous
`write_all` (the channels mux's `MuxRunner` composes chunks
atomically), so the adapter's write-side job is buffer-until-chunk-
complete, then flush as one message. Flush semantics and the
chunk-completeness assumption are OQ-01 items to verify against
alkcall's `MuxRunner` behavior.
into a pending buffer; a background task scans the pending bytes for
complete chunks (8-byte headerpayload length) and emits each
complete chunk as one WS binary message, carrying any partial tail
until its chunk completes. The adapter **parses the outgoing byte
stream** to find chunk boundaries — it does not assume one write
equals one chunk (verified against alkcall: `write_chunk` issues
header+payload as separate writes, and channel 0's `write_frame`
issues prefix+body as separate writes; each can surface as separate
mux payloads). Oversized chunks (a single chunk exceeding the
WS-message cap, up to `MAX_CHUNK_LEN` = 16 MiB) are split across
multiple WS messages — legal, since the receiver's boundary is the
chunk header, not the message. Flush semantics are an OQ-01 item.
- **Close mapping:** WS close (either side) → transport EOF → the
demux clears all channels (REQ-CH-02: every handler sees EOF) and
channel 0's dispatch loop fails outstanding pendings with
`connection closed`.
`connection closed`. `AsyncWrite::shutdown` maps to the zero-length
EOF sentinel (REQ-CH-01) followed by a WS Close frame.
The adapter is shared with the `from_wss` consumer path
([ADR-070](decisions/070-from-wss-consumer-adapter.md)) — one