PacketRelayDocs

Agent orientation#

A briefing for a coding agent working in the PacketRelay repository. It assumes no prior context, states what is true rather than what would be convenient, and names the file that settles each question. Read Invariants & tripwires next — it covers the rules that look safe to break and are not.

Important

This repository has no CLAUDE.md and no AGENTS.md. The only in-repo prose is README.md, BACKLOG.md and CHANGELOG.md, and the README is materially stale — see The README is not a source below. Read the source.

What this is#

packet-relay v0.1.0, edition 2021, MSRV 1.83, MIT, publish = falseCargo.toml:2-10. One binary built from src/main.rs (Cargo.toml:12-14) and no library target.

It is a WebSocket relay for three session shapes: bridge (one desktop to one mobile), broadcast (one host to many spectators), and room (authenticated N:N). It never inspects application payloads — main.rs:4-5 states the contract, and encryption is a client responsibility.

There is no HTTP framework. Every TCP connection is accepted raw, its request head is read in 1 KiB chunks and hand-parsed (main.rs:431-473), and it is classified into one of four routes. tokio-tungstenite performs the WebSocket handshake; nothing else in the stack is a framework.

The whole tracked repository is twenty files. Six of them are Rust.

Repository layout#

src/
  main.rs        710  CLI, listener, HTTP head parse, route table, WS upgrade, reaper task
  room_auth.rs   813  signed room_hello: verify/commit, TOFU pins, ECDSA P-256
  session.rs     785  session store: kinds, roles, registration, fan-out targets, reaping
  connection.rs  616  per-connection handler: hello, admission, forwarding loop, close
  rate_limit.rs  353  per-IP admission limiter and per-connection message budget
  protocol.rs    214  wire types and the jarvis-room-hello-v1 canonicalization
testdata/
  session_ready.json   compiled into the binary with include_str!
  room_ready.json      same
Cargo.toml / Cargo.lock / rust-toolchain.toml
Dockerfile / .dockerignore / railway.json
.github/workflows/ci.yml
BACKLOG.md / CHANGELOG.md / README.md / LICENSE

Modules are flat and declared at main.rs:7-11. There is no mod.rs and no submodule tree. Adding a file means adding a mod line there, and nothing else.

What each file owns#

File Owns Does not own
main.rs Arg parsing and validation, PORT handling, the TCP accept loop, the HTTP request-head parser, the route table, the capacity semaphore, ReplayStream, the reaper task. Anything that happens after the WebSocket handshake succeeds.
connection.rs Reading and dispatching the hello, id validation, admission ordering, the notification order on join, the forwarding loop, close handling, log redaction. Where the channels are stored, and whether a hello is cryptographically valid.
session.rs The session map, Role and SessionKind, registration and ownership-aware unregistration, fan-out target selection, the caps, the reaper's semantics. Anything about sockets or WebSocket frames.
room_auth.rs verify / commit, the TOFU pin map, nonce high-water marks, the ECDSA and SHA-256 primitives, the reject-reason enum and its client-facing messages. The canonical bytes being signed.
protocol.rs RelayHello, RelayResponse, ROOM_HELLO_SIG_DOMAIN, ROOM_HELLO_SEP, room_hello_canonical_bytes, signed_hello_payload. Any behaviour. It is types and one canonicalization, nothing else.
rate_limit.rs RateLimiter (per-IP and global connection admission) and MessageBudget (per-connection traffic), plus every default constant they carry. The --max-connections semaphore, which lives in main.rs.

The dependency direction is one-way. protocol.rs imports nothing from the crate. room_auth.rs imports only protocol::signed_hello_payload (room_auth.rs:69). connection.rs imports protocol, rate_limit, room_auth and session (connection.rs:10-13). main.rs imports everything. Nothing imports connection.rs except main.rs. Keep it that way — a back-edge from session.rs into connection.rs would make the store depend on frame types, which is the boundary that lets the whole store be tested without a socket.

Route selection is by hello, not by path#

This is the single most load-bearing architectural fact, and the one most likely to be got wrong by analogy with other servers.

The route table (main.rs:474-486) reserves exactly five targets: /health, /healthz, /ready, /readyz, and anything else beginning with those prefixes (which is a Reject, so /health?detail=1 is a 404). Everything else, including / and /v1/product-route, is a WebSocket upgrade into the same handler.

The protocol is then chosen solely by the first application frame. read_hello (connection.rs:490-546) parses one text frame into a RelayHello variant, and Role::session_kind() (session.rs:30-36) maps the role to the session kind. A binary first frame is refused outright (connection.rs:530-533).

The consequence for a change: adding a protocol means adding a RelayHello variant, not adding a route. Adding a path does nothing, because no path reaches a distinct handler. Any work that begins "add a /ws/host endpoint" is starting from a model this codebase does not have.

Conventions#

Wire names are explicit, never derived. Every enum variant carries its own #[serde(rename = "…")] (protocol.rs:81-109, protocol.rs:116-154). There is no rename_all. The Rust names keep their …Hello suffix and the enum carries #[allow(clippy::enum_variant_names)] (protocol.rs:79) to permit it, on purpose: the Rust identifier and the wire tag are meant to read as the same name, so a reader can grep either one.

Client-facing errors are string literals. The store returns Result<_, &'static str> (session.rs:112-116) and the handler wraps the string in RelayResponse::Error { message }. The literals are asserted in tests — session.rs:772 matches Err("server at capacity") — so changing a message is a test-visible change, which is the intent.

Test-only code is cfg-gated, including constructors. SessionStore::new() (session.rs:98-100) and the entire signing side of the crypto (room_auth.rs:352-357, which imports SigningKey and EncodePublicKey) exist only under #[cfg(test)]. The relay never signs anything in production; if you find yourself needing a signing key outside a test module, you are building the wrong thing.

Logging is tracing with structured fields, default filter packet_relay=info when RUST_LOG is absent or unparseable (main.rs:281-286). Fields are key-value (peer = %addr), not interpolated prose. Room session ids go through log_session (connection.rs:552-559), which emits the first six characters plus a length; every other role logs the full id.

Doc comments carry the reasoning. room_auth.rs:1-62 is the entire trust model written out — why TOFU rather than a registry, why fingerprinted ids close the first-mover squat and unstructured ids do not, why the nonce is monotonic, why verify and commit are split. It is the best thing to read before touching authentication, and it is longer than the code it introduces. Match that register: explain the failure mode, not the fix.

Tests live at the bottom of the file they test. Six #[cfg(test)] modules, 53 tests, no tests/ directory. See Build & test.

Safe to change, and cross-repository contracts#

Two categories, and the difference matters more here than in most repositories, because four clients that live in other repositories reproduce part of this code's behaviour byte-for-byte.

Local, safe to change#

  • CLI defaults, within the ranges Args::validate enforces (main.rs:212-266). Adding a flag means adding a field, a default constant, and a validation arm.
  • Log messages, levels and fields — except the redaction in log_session.
  • Internal helper names and module-private structure.
  • New tests. BACKLOG.md:101-111 lists exactly which ones are missing.
  • BACKLOG.md and CHANGELOG.md.

Cross-repository contracts#

Contract Where Who else depends on it
jarvis-room-hello-v1, 0x1F, and the five-field order protocol.rs:13, :18, :42-61 Four Room clients in other repositories reproduce these bytes to sign a hello.
The base64 wrapping of the canonical bytes protocol.rs:63-73 Same four clients — the signature is over the base64 string, not the raw bytes.
Every RelayResponse tag and field name protocol.rs:113-155 Every client deserialises them.
testdata/session_ready.json, testdata/room_ready.json protocol.rs:161-169 Shared fixtures; the clients read the same files.
member_frame.member_id is the authoritative sender protocol.rs:146-150 Consumers must prefer it over any user_id inside payload.
The PORT environment variable main.rs:313-316 The hosting platform sets it; it overrides --port.
Warning

Changing the domain separator, the separator byte or the field order in room_hello_canonical_bytes is not a refactor. It invalidates every signature the four clients produce, and the relay's only symptom is "invalid room hello signature" on every join — a message that names neither the cause nor the client. The golden vector at room_auth.rs:807 is the tripwire that catches it in this repository.

Client-facing error message strings sit between the two categories. Nothing in this repository proves whether any client branches on their text, so treat them as a soft contract: the tests pin them, and whether a client also does is unverified.

Dead dependencies#

Two, both compiled on every build, both found by reading Cargo.toml against src/.

time = "=0.3.36" has no reference in src/Cargo.toml:26 declares it with the parsing and formatting features. Grepping the source for time:: returns only std::time and tokio::time paths: connection.rs:494, connection.rs:499, main.rs:15, main.rs:340, main.rs:432, main.rs:656, rate_limit.rs:6, room_auth.rs:65, session.rs:5, session.rs:454, session.rs:745. None of them is the time crate. Timestamps come from SystemTime::duration_since(UNIX_EPOCH) (room_auth.rs:313-319) and durations from std::time::Duration. Whether the crate is still pulled in transitively by something else in the graph is unverified; what is verified is that no direct use exists.

p256's ecdh feature is compiled and unusedCargo.toml:20 enables ["ecdsa", "ecdh", "pkcs8"]. The only p256 imports are p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}, p256::pkcs8::DecodePublicKey and p256::PublicKey (room_auth.rs:338-341), plus the test-only signing imports (room_auth.rs:356-357). Grepping src/ for ecdh returns nothing. The relay verifies signatures and derives no shared secrets; there is no key agreement anywhere in the codebase.

Both are removable, and removing either is a build-graph change rather than a behaviour change — which is why they should be removed deliberately, with the --locked gates run, rather than opportunistically in the middle of other work.

rand = "0.8" is correctly scoped: it is a dev-dependency (Cargo.toml:30-31) and is used at room_auth.rs:368 to generate test signing keys.

The README is not a source#

README.md describes a system larger than the one in src/. The specific disagreements, with the code's answer:

README says The code says
--product-connection-reserve, default 32, gives the product route an additive connection reserve (README.md:160) No such flag exists. RouteCapacity holds one legacy semaphore (main.rs:52-70). The only surviving product string in src/ is a test asserting /v1/product-route classifies as Legacy (main.rs:611).
A planned consumer with /ws/host, /ws/device, an HTTPS ticket plane, ACLs, revocation, an Origin check, PostgreSQL replay and Web Push (README.md:66-77) None of those strings exist in src/. This is Planned work with no implementation.
spectator_hello joins a broadcast (README.md:200) It calls ensure_session(Broadcast) (connection.rs:99-107), so a spectator auto-creates an empty broadcast session.

BACKLOG.md is closer to accurate but also drifts: it states 61 tests (BACKLOG.md:103) where the count is 53, states the container has no USER directive (BACKLOG.md:189) where Dockerfile:25 sets USER 65532:65532, and still describes /v1/product-route as a live route (BACKLOG.md:32-35) after its removal on 2026-08-28 (CHANGELOG.md:10-28).

Traps#

Ordered by how much time they cost when they bite.

testdata/ is a compile-time dependency. protocol.rs:161-169 uses include_str! with CARGO_MANIFEST_DIR. A build tree without testdata/ fails to compile, not to test. Dockerfile:7 copies it for exactly this reason.

There is no library target, so there can be no tests/ directory. Cargo.toml:12-14 declares only [[bin]]. A file under tests/ would have nothing to import. Integration coverage means either restructuring into a lib+bin pair or binding an ephemeral listener from inside a #[cfg(test)] module, which is what args_tests does.

The product route was Removed on 2026-08-28. src/product_route.rs, src/product_crypto.rs, PROTOCOL documentation, the RequestRoute::Product variant, its reserved pool, its flag, and the aes-gcm, ed25519-dalek, hkdf, x25519-dalek and zeroize_derive dependencies all went with it (CHANGELOG.md:10-28). Do not reintroduce any of it while chasing a README mention.

All state is in-process. Sessions (session.rs:93), TOFU pins (room_auth.rs:141) and limiter state (rate_limit.rs:78) are Arc<RwLock<…>> in memory. A restart drops every session, every pin and every nonce high-water mark. Two replicas do not share routing, so horizontal scaling silently splits sessions across instances.

There is no graceful shutdown. main runs an unconditional accept loop with no signal handling (main.rs:357-381). Combined with panic = "abort" on the release profile (Cargo.toml:37), the only exit is termination.

Rate limiting runs before any byte is read (main.rs:360-369), so a health probe consumes connection budget like any other client, and a breach drops the TCP stream with no HTTP or WebSocket response at all — only a warn! log. Debugging a "connection refused for no reason" report starts there.

Hellos have no deny_unknown_fields (protocol.rs:76-80). An extra field in a hello is silently ignored, so a client typo in a field name that is optional-looking fails as a missing required field, while a typo in an extra field fails as nothing at all.

Next#