Roadmap impact: this is a new Phase 11. The observability / benchmarks / acceptance phase that was previously Phase 11 becomes Phase 12. Reason:
brain-httpis foundational — Phase 12's metrics, SSE log streaming, and HTTP-served dashboards all depend on a real HTTP layer. Building observability on the existing 500-LOC hand-roll and refactoring later is the wrong sequence.
Ship a Brain-owned HTTP transport crate that replaces the two existing
hand-rolled HTTP surfaces (brain-server::admin and brain-cli::http)
and provides WebSocket + Server-Sent Events as first-class capabilities.
Built on hyper 1.x as the wire substrate. We own the application- level decisions (routing, error mapping, SSE flush policy, WebSocket close handshake); hyper owns the wire-level mechanics (HTTP/1.1 parsing, keep-alive, chunked transfer encoding, body backpressure, optional HTTP/2 path).
The crate is HTTP-version-neutral by construction because hyper
is. The Service trait, Body trait, and http::Request<B> /
http::Response<B> types all live above the version. HTTP/2 is one
feature flag away when we want it.
Three options considered; decision is hyper-raw. Tabulated decision
matrix lives in
.claude/research/brain-http-design.md;
short summary:
- Hand-roll (~8-9 kLOC, 4-6 weeks): expensive ownership of the HTTP/1.1 parser, keep-alive state machine, chunked encoder, HTTP/2 framing if we ever want it. We'd own a CVE surface that the hyper team already owns at scale.
- axum (~0.5-1 kLOC, 1-2 weeks): framework over hyper. Adds ~80 transitive deps. Bundles middleware machinery we don't use (Brain has 15 admin routes, no middleware, no CORS, no compression). Net negative ergonomically for our surface size.
- hyper-raw (~2.5-3 kLOC, 2-3 weeks): we get the hard parts (parser, keep-alive, chunked, HTTP/2-ready, body backpressure) from hyper; we own the design decisions (routing shape, error mapping, SSE flush, WS close handshake). Half the dep tree of axum. Production users behind the wire layer: TiKV, Linkerd, reqwest, every Rust HTTP shop including axum itself.
- Phase 10 complete (Rust SDK & CLI shipped).
brain-cli::httpandbrain-server::adminare the migration targets.
.claude/research/brain-http-design.md— the full design report (918 lines).- hyper 1.x docs — Builder, service_fn, server::conn::http1, body::Incoming, upgrade.
crates/brain-server/src/admin/mod.rs— current hand-rolled HTTP/1.1 server (500 LOC, M3 migration target).crates/brain-cli/src/http/mod.rs— current hand-rolled blocking HTTP client (200 LOC).crates/brain-server/src/network/connection.rs— TCP listener / TLS / shutdown pattern. Helpers re-home intobrain-http::tcpduring M2.crates/brain-server/src/network/subscribe.rs— cross-runtime streaming pattern (template for SSE / WS).CLAUDE.md§6 (approved crates — hyper added per justification in the M1 commit), §7 (nounsafeoutsidebrain-storage), §9 (no Tokio inside a shard — HTTP stays on the Tokio side of the Brain boundary).
- New crate
crates/brain-http/. - All HTTP runs on Tokio (Posture A from the design report — no Glommio entanglement). Data plane stays on its existing Tokio→Glommio binary channel.
brain-server::adminrewired to usebrain-http::server. Hand-roll deleted.brain-cli::httpmigration decision: most likely keep the existing 200-LOC blocking client for now (works, no value in churn) and defer to a later phase if/when async client is needed. Documented in M5.- WebSocket server + client (via
tokio-tungstenite, integrated through hyper'sUpgrade). - SSE server + client (on hyper's
Body, with explicit flush discipline). - Criterion benchmarks, Tracing spans per connection lifecycle.
- Tag:
phase-11-complete.
The five abstractions that survive into a future HTTP/2 addition without rewriting handlers — they're not Brain-specific designs, they come from hyper:
http::Request<Body>/http::Response<Body>— typed shapes from thehttpcrate. HTTP/1.1 and HTTP/2 both produce them.Bodytrait —http_body::Bodyis the version-neutral body abstraction. We usehttp_body_utilcombinators (Empty,Full,BoxBody,StreamBody).Servicetrait —tower::Service(re-exported viahyper).service_fnwraps a closure.Router— ours. Matches(Method, &str)→Handler. Pure routing; no wire-format knowledge. Built as a small match-based dispatcher in M2.- Connection acceptance —
hyper::server::conn::http1::Buildertoday, swappable tohyper-util::server::conn::auto::Builder(ALPN-negotiated HTTP/1.1 or HTTP/2) when we want HTTP/2. Same handler code either way.
Things that stay HTTP/1.1-specific in v1:
- Connection acceptance uses
http1::Builder(single-version). - WebSocket Upgrade is an HTTP/1.1 mechanism (RFC 6455). HTTP/2's WebSocket equivalent (RFC 8441) has near-zero adoption; skip.
When HTTP/2 lands (future phase):
- Enable
hyper'shttp2feature flag. - Swap
http1::Builder::serve_connectionforauto::Builderinserver/accept.rs. - Add ALPN to the TLS layer.
- No
Serviceor handler changes.
Per CLAUDE.md §6, each new dep justified inline
in the M1 commit.
| Dep | Why | Cost |
|---|---|---|
hyper v1 |
HTTP/1.1 wire codec, keep-alive state machine, chunked encoder, body backpressure, HTTP/2-ready foundation. Production-validated at Linkerd / TiKV / reqwest scale. Building this ourselves is 4-6 weeks of CVE-prone work. | ~25 kLOC, ~25 transitive deps |
hyper-util v0.1 |
Required helpers: TokioIo (Tokio↔hyper I/O bridge), GracefulShutdown. The hyperium team split these out of hyper 1.0 to keep the core minimal. |
thin, no new transitive deps |
http-body-util v0.1 |
Body combinators: Empty, Full, BoxBody, StreamBody. Pairs with hyper. |
thin |
http v1 |
Typed HTTP vocabulary (Method, StatusCode, HeaderMap, Request<B>, Response<B>). hyper re-exports from this. |
already pulled transitively |
bytes v1 |
Zero-copy buffer (hyper requires it). | already in tree via tokio |
tokio-tungstenite v0.21 |
WebSocket framing + masking + close handshake, RFC 6455. Pairs with hyper's Upgrade for the handshake. Mature; the maintainer is responsive. |
~3 kLOC, no extra heavy deps |
Existing deps used: tokio, tracing, thiserror, tokio-rustls
(behind a tls feature flag).
No new deps for: routing (~100 LOC match), SSE (~150 LOC on
hyper::body), TCP helpers (re-home from brain-server::network).
Folder-per-concern. Every concern in its own folder; only lib.rs at
the root of src/.
crates/brain-http/
├── Cargo.toml
├── README.md
├── src/
│ ├── lib.rs # crate-level re-exports + docs
│ │
│ ├── error/ # version-neutral
│ │ ├── mod.rs # Error, ErrorKind (thiserror)
│ │ └── status.rs # StatusCode → Brain Error mapping
│ │
│ ├── body/ # version-neutral helpers
│ │ ├── mod.rs # re-exports from http_body_util
│ │ ├── stream.rs # StreamBody helpers
│ │ └── limits.rs # bounded body reader
│ │
│ ├── service/ # version-neutral
│ │ ├── mod.rs # service_fn, BoxService, types
│ │ └── handler.rs # AsyncHandler trait + adapters
│ │
│ ├── router/ # version-neutral
│ │ ├── mod.rs # Router type
│ │ ├── route.rs # (Method, path, Handler) entry
│ │ └── matcher.rs # static + parametric matching
│ │
│ ├── server/ # Tokio + hyper
│ │ ├── mod.rs # HttpServer builder + serve
│ │ ├── accept.rs # TcpListener accept + per-connection spawn
│ │ ├── connection.rs # hyper http1::Builder wrap
│ │ ├── limits.rs # max body / header / request timeout
│ │ ├── shutdown.rs # graceful shutdown via hyper-util
│ │ └── tls.rs # rustls feature gate
│ │
│ ├── client/ # decision in M5 — see below
│ │ └── mod.rs # placeholder until M5 lands
│ │
│ ├── ws/ # WebSocket
│ │ ├── mod.rs # public surface
│ │ ├── upgrade.rs # hyper Upgrade handler → tungstenite
│ │ ├── server.rs # WsServer<F> wrapper
│ │ └── client.rs # WsClient (M8)
│ │
│ ├── sse/ # Server-Sent Events
│ │ ├── mod.rs # SseEvent struct, SseStream body
│ │ ├── encoder.rs # event → wire bytes
│ │ ├── stream.rs # impl Body for SseStream
│ │ └── client.rs # EventSource w/ Last-Event-ID reconnect
│ │
│ ├── tcp/ # Tokio TCP helpers
│ │ ├── mod.rs # bind helpers
│ │ ├── socket.rs # TCP_NODELAY, SO_REUSEADDR/PORT, KEEPALIVE
│ │ └── timeout.rs # idle-read timeout wrapper
│ │
│ └── observability/ # tracing integration
│ ├── mod.rs
│ └── span.rs # per-connection / per-request span helpers
└── tests/
├── server_smoke.rs
├── server_keepalive.rs
├── server_router.rs
├── server_streaming.rs
├── ws_handshake.rs
├── ws_echo.rs
├── ws_control_frames.rs
├── sse_basic.rs
└── sse_reconnect.rs
Each milestone is shippable in isolation. Verify suite green after every
milestone. Plan-first per AUTONOMY.md §21.
Reads: hyper 1.x docs (Builder, service_fn, body::Incoming).
Writes: new brain-http crate registered in workspace. error/,
body/ (re-exports + helpers), service/, observability/.
Workspace Cargo.toml updated with new deps.
Done when: crate compiles; integration test asserts a no-op
service can be wired into hyper's service_fn; dep justifications
in commit message.
Reads: crates/brain-server/src/network/connection.rs for the
TCP setup + shutdown patterns to mirror.
Writes: tcp/, server/accept.rs, server/connection.rs,
router/, server/limits.rs, server/shutdown.rs.
Done when: integration test issues GET/POST and round-trips
bodies via hyper; keep-alive works automatically (free from hyper);
graceful shutdown drains in-flight requests.
Reads: every file under crates/brain-server/src/admin/.
Writes: each admin sub-module (worker, snapshot,
config_route, audit, agent, shard_route, diagnostics,
rebuild) becomes a Service function. Delete the hand-rolled
request parser, header drain, and write_response helper.
Done when: all existing admin integration tests pass; admin
hand-roll deleted (~500 LOC out, ~150 LOC in for the rewiring).
Reads: WHATWG EventSource; design report §4.4 + R3 (flush
discipline pitfall).
Writes: body/stream.rs, sse/ module.
Done when: integration test verifies SSE events arrive within
50 ms of emit (proves flush discipline); reconnect test verifies
Last-Event-ID carries through.
Reads: crates/brain-cli/src/http/mod.rs.
Decision (shipped): path (a) — brain-cli::http stays as-is
(~200 LOC blocking GET/POST/DELETE, zero external deps,
well-tested via the admin integration suites). brain-http does
NOT expose a client in v1.
Writes: crates/brain-http/src/client/mod.rs — a pure-rustdoc
module that documents the deferral, the three trigger conditions
for revisiting (Phase 12 OTLP push / new outbound consumer /
reqwest summarizer dep audit), and the natural shape when a
client is added (hyper_util::client::legacy::Client over a
feature-gated Connect impl, with a blocking facade wrapping a
Tokio runtime).
Done when: decision documented; brain-cli::http unchanged;
the client feature flag stays declared (it's the future
on-switch) but compiles to a docs-only module.
Reads: RFC 6455; tokio-tungstenite docs; hyper Upgrade example.
Writes: ws/upgrade.rs, ws/server.rs.
Done when: echo server integration test round-trips text + binary
frames; close handshake test passes (both initiated-by-us and
initiated-by-peer); ping/pong control-frame test passes.
Writes: ws/client.rs — thin wrapper around tokio_tungstenite:: connect_async.
Done when: client ↔ brain-http server echo round-trip passes.
Reads: CLAUDE.md §10 (tracing/OTel pattern).
Writes: observability wiring + criterion benches + load test.
Shipped:
accept.rsper-connection task.instrument()-ed withconnection_span(peer);connection.rs::handle_requestwrapped inrequest_span(&req);http.response.status_coderecorded after the handler returns via newobservability::record_status.- Criterion benches:
benches/router.rs(exact / prefix / 404 fallback),benches/sse_encoder.rs(small / multi-line / full event),benches/end_to_end.rs(GET round-trip over loopback).cargo bench -p brain-httpproduces stable numbers. tests/load.rs—#[ignore]-d 10k-concurrent-connections / 5-minute load test, operator-invoked (cargo test ... -- --ignored load_10k --nocapture). Linux-only; requiresulimit -n≥20k.
Done when: all three workstreams above shipped; clippy -D warnings green; load-test infra present (numbers in commit message
on operator run).
Total: 8 milestones, ~2.5-3 kLOC production + ~1.5 kLOC tests. Realistic timeline: 2-3 weeks of focused work.
- All 8 milestones complete.
- Verify suite (fmt-check + build + clippy + test + check-skills) green.
-
brain-server::adminhand-roll deleted (M3). - Admin endpoints unchanged externally (all 47 existing tests pass).
- WebSocket server (M6) + client (M7) smoke-tested round-trip.
- SSE server smoke-tested with
Last-Event-IDreconnect (M4). - Criterion baselines available via
cargo bench -p brain-http(M8). - Tag
phase-11-complete. (awaiting user signal)
- Strict ordering. M1 → M2 → M3 is the first vertical slice; on shipping M3 we delete the brain-server admin hand-roll. After M3, Phase 12 (observability) can start in parallel with M4-M8 because metrics + structured logs don't need WebSocket or SSE.
- No HTTP/2 in this phase. hyper supports it via a feature flag;
enabling it is a one-line change once there's a concrete client
that needs it. Phase 11 ships with
http1feature only. - brain-cli HTTP stays hand-rolled in v1. No churn there. The 200-LOC blocking client works for the CLI's needs (low rate, no concurrency, no streaming). M5 documents the decision; revisit when the SDK or another consumer needs async HTTP.
- No
unsafe. PerCLAUDE.md§7, brain-http is outsidebrain-storageand cannot useunsafe. hyper itself containsunsafeinternally — that's their crate's responsibility, not ours. Brain's invariant is about our code. - Runtime stays Tokio. Posture A from the design report §3.3.
Anything that needs to cross into a shard goes through the existing
brain-server/network/dispatch.rschannel pattern.