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.
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 = false —
Cargo.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::validateenforces (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-111lists exactly which ones are missing. BACKLOG.mdandCHANGELOG.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. |
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 unused — Cargo.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#
- Invariants & tripwires — read before touching cleanup, canonicalization, the verify/commit split or the reject reasons.
- Build & test — the gate ladder, the 53 tests and the coverage gaps.
- How a connection works — accept to forward, in order.
- Message reference — every hello and response, field by field.