The transport layer
The server is the schema
HaRPCis a stream-based RPC protocol where the server’s type system is the interface definition. Client bindings derive from the implementation itself, not from a separate schema that approximates it.
The schema problem
Every schema-first RPC framework creates the same structural problem. The interface is defined in an intermediate language: protobuf, GraphQL SDL, OpenAPI YAML. Then the server implements that interface in its own language, with its own type system. Two representations of the same thing, maintained in parallel, connected by generated glue code.
This sounds manageable until the intermediate language hits a wall. Protobuf cannot express sum types. GraphQL cannot expressthe generic type parameters. OpenAPI cannot express dependent types or refinement constraints. The schema language becomes a ceiling: the server can model richer invariants than the schema can describe, so the interface is a lossy projection of the implementation. Both sides settle for less than they could have.
HaRPC removes the intermediate language. Subsystems are types in the server’s own language. Procedures are methods on those types. The types that appear in function signatures are the types that appear on the wire, and client bindings are generated from those same types. One source of truth, and it is the code that runs. The reference server implementation is in Rust, but the model works in any language with a sufficiently expressive type system.
No backwards compatibility forever
Protobuf’s design encodes an assumption: once a field exists, it exists forever. Field numbers are never reused. Removed fields become tombstones. The wire format accumulates compromises across every version that has ever shipped, because the protocol treats any breaking change as an error in the protocol designer’s judgment rather than a natural part of how software evolves.
This is not a realistic model. APIs change because the domain they model changes. Locking a wire format to its first draft forces increasingly awkward workarounds: wrapper messages,oneoffields overloaded to mean “we redesigned this but cannot say so,” entire subsystems duplicated under new names because the old shape cannot be migrated.
HaRPC treats version evolution as a first-class protocol mechanism. Every subsystem carries a semantic version: major and minor. Every frame on the wire includes the subsystem descriptor with its version. Clients declare which version they expect; the server checks compatibility on every request. A minor version bump adds capability. A major version bump signals a breaking change, and old clients receive a clear, typed error rather than silently misinterpreting new fields.
Procedures carry their own lifecycle metadata: the version at which they were introduced, and optional deprecation information. The protocol knows which procedures exist at which versions, so version negotiation is precise rather than approximate.
Transport and encoding independence
The wire protocol is a framing layer over a bidirectional byte stream. Each frame starts with a five-byte magic sequence, a version byte, a four-byte request identifier, and a flags byte. The body that follows depends on whether this is the beginning of a request (carrying a subsystem descriptor, a procedure descriptor, and a payload) or a continuation frame (carrying only payload). That is the entire protocol. It assumes nothing about what carries the bytes.
In practice, this means the same protocol runs over TCP, over WebSocket, over QUIC, over Unix domain sockets, over anything that provides an ordered bidirectional byte stream. The transport is a deployment decision, not a protocol decision.
Encoding is separated the same way. The wire protocol carries bytes; how those bytes represent structured data is a separate decision. A JSON codec ships with HaRPC. A binary codec, a MessagePack codec, or any other serialization format plugs in through the same interface. The encoding choice can even differ between request and response, so a server can accept JSON but respond with a more compact format if the client supports it.
Streams, not request/response
HTTP-shaped RPC protocols model every interaction as a single request followed by a single response. This works for CRUD operations. It breaks down when the interaction has intermediate state: a long-running computation that streams partial results, a negotiation that requires back-and-forth, a bulk transfer that should not buffer the entire payload before sending.
HaRPC frames carry BeginOfRequest and EndOfRequest flags independently. A request can span multiple frames, and the server can begin responding before the request is complete. Multiple requests interleave on the same connection, distinguished by their request identifier. The protocol is natively multiplexed: a slow streaming read does not block a fast metadata lookup running on the same session.
Complexity on the server
The server is where complexity belongs: subsystem delegation with typed routing, composable middleware, session management with background cleanup, version negotiation, codec resolution. All of it lives in one place, under the same type system that defines the interface.
The client is deliberately simple. Open a connection, frame a request, read frames back. The wire protocol is a fixed specification with no conditional complexity, so implementing a client in a new language means implementing the framing logic and nothing else. HaRPC already ships clients in Rust and TypeScript; the barrier to a third is low by design.
This asymmetry is the point. A protocol that demands equal sophistication from both sides limits adoption to ecosystems with mature tooling. A protocol where the client is trivial means every language gets a client, and the language best suited to correctness gets the server.
