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
+113
View File
@@ -0,0 +1,113 @@
# Plan: alkhttp Implementation
Working plan guiding task decomposition and implementation. Less
specific than the tasks it produces, more specific than the
architecture docs. Status fields in task frontmatter are the source of
truth for progress; this document explains the *why* of the ordering.
Source crate: `/workspace/@alkdev/alknet/crates/alknet-http` (~11k LOC).
Target: this crate, on alkcall 0.1.1 (crates.io).
## What the spike established
A validation pass against alkcall's actual source resolved the factual
unknowns that would have shaped tasks incorrectly:
1. **The WS adapter must parse outgoing chunk boundaries.** The mux
emits one mpsc payload per chunk, but a logical write above the mux
(channel 0's `write_frame` issues length-prefix and body as two
`write_all`s) can surface as multiple chunks. "One write = one
chunk" and "one chunk = one WS message" are both unusable as
invariants. The adapter treats the WS message stream as a byte
stream in both directions and parses the 8-byte header on both
read and write sides.
2. **Chunk size cap.** `MAX_CHUNK_LEN` is 16 MiB; browser WS stacks
and intermediaries commonly cap messages far lower. The WS path
needs its own practical cap (default ~1 MiB) with oversized chunks
split across messages — legal, since the receiver's boundary is the
chunk header.
3. **The `install_channel_zero` hook is the exact seam** for the
server WS path: alkcall's `ChannelsAdapter` runs the in-line demux
loop, and the hook receives channel 0's `Connection` + `AuthContext`
— alkhttp's job is to construct the `CallConnection`, attach the
bearer-resolved identity, and run
`Dispatcher::run_loop_single_stream`. `alkcall`'s own tests
(`channels/client.rs`) demonstrate this wiring end-to-end over
duplex pairs.
4. **EOF/teardown semantics are already specified** by alkcall
(REQ-CH-01/02): `AsyncWrite::shutdown` → zero-length sentinel; demux
EOF → all channels cleared. The adapter maps WS close to transport
EOF and lets alkcall's invariants do the rest.
5. **`Dispatcher::run_loop_single_stream` exists** and is the channel-0
dispatch loop — no new dispatch code is needed anywhere in alkhttp.
This de-risks the two "high" tasks (WS adapter, WS session) from
"unknown design" to "known shape, careful implementation."
## Build order (dependency spine)
```
Phase 1 — server foundation (no WS, no adapters)
core types → server adapter + auth → gateway dispatch + routes
→ verified over tokio DuplexStream end-to-end
Phase 2 — WS + channels (the novel part)
ws byte-stream adapter → ws upgrade handler + channels session
→ verified browser-session-style over duplex WS
Phase 3 — adapters (mostly ports)
http client host → from_openapi/from_jsonschema → to_openapi
→ from_wss (depends on ws adapter) → mcp feature (from_mcp/to_mcp)
→ /publish endpoint + to_openapi v2 of the gateway doc
Phase 4 — hardening
integration test suite (full surface over duplex) → docs sync
→ publish prep (dry-run, semver check)
```
Rationale for key orderings:
- **Server core before WS** because the WS upgrade is a route on the
`HttpAdapter` — the router, auth middleware, and decoy must exist
first, and they're verifiable without WS (gateway over duplex).
- **WS adapter before `from_wss`** — same adapter, both directions;
building the consumer first would mean validating the adapter
without its hardest user (the axum WS type).
- **`/publish` in Phase 3** — the gateway spine (`/call`/`/subscribe`)
lands in Phase 1; `/publish` adds a dispatch mode to a working
gateway and gates OQ-02's version bump. Building it early would
couple an unresolved OQ to the critical path.
- **MCP last** — feature-gated, rmcp-heavy, and the gateway dispatch
spine it consumes is stable by then.
## OQ dispositions
| OQ | Disposition | Where it resolves |
|----|-------------|-------------------|
| OQ-01 (WS adapter semantics) | Partially resolved by the spike: byte-stream treatment both directions, boundary = chunk header, split oversized chunks, shutdown → EOF sentinel + Close frame. Remaining: exact buffer bounds, flush semantics — locked during the WS adapter task. | `tasks/websocket/` |
| OQ-02 (/publish framing) | Resolve in the `/publish` task: first line carries `{operation, chunk}`; terminal error = plain HTTP status + JSON body (not an NDJSON line). Then bump the gateway doc version. | `tasks/gateway/` |
| OQ-03 (from_wss reconnect) | v1: connection drop → retryable failures; policy deferred. Documented in ADR-070; no task. | — |
| OQ-04 (browser client) | Out of scope for this crate. | — |
## Conventions for the tasks
- Topic subdirectories: `tasks/server/`, `tasks/websocket/`,
`tasks/gateway/`, `tasks/adapters/`, `tasks/client/`, plus
`tasks/infra/` for repo-level concerns (CI, publish prep).
- Every task carries the full frontmatter set (`scope`, `risk`,
`impact`, `level`) — taskgraph's analysis commands rely on them.
- Ported-source references: each task cites the alknet-http source
file(s) it ports from, so the implementing agent can diff against
the original rather than re-derive.
- Verification per task: `cargo test -p <affected>` at minimum; the
Phase 4 integration task runs the full `cargo test --all-features`.
- No task crosses a subsystem boundary except through its declared
`depends_on`.
## What the tasks will NOT cover
- The alknet-side wiring (endpoint, TLS, ALPN router registration) —
alkhttp exposes `HttpAdapter` as a `ProtocolHandler`; the dial/accept
composition is the consumer's job (AGENTS.md convention 9).
- A browser/JS client for channels-over-WS (OQ-04, deferred).
- WebTransport (ADR-069: out of scope entirely).