Rust Specification
Introduction
This document specifies implementation details for the Rust implementation of roam. These details are NOT required for implementations in other languages — Swift, TypeScript, etc. get their types and method identities from code generated by the Rust proto crate.
Method Identity
Every method has a unique 64-bit identifier computed from its service name, method name, and signature.
The method ID MUST be computed as:
method_id = blake3(kebab(ServiceName) + "." + kebab(methodName) + sig_bytes)[0..8]
Where:
kebab()converts to kebab-case (e.g.TemplateHost→template-host)sig_bytesis the BLAKE3 hash of the method's argument and return types[0..8]takes the first 8 bytes as a u64
This means:
- Renaming a service or method changes the ID (breaking change)
- Changing the signature changes the ID (breaking change)
- Case variations normalize to the same ID (
loadTemplate=load_template)
Signature Hash
The sig_bytes used in method identity is a BLAKE3 hash of the method's
structural signature. This is computed at compile time by the #[roam::service]
macro using facet type introspection.
The signature hash MUST be computed by hashing a canonical byte representation of the method signature using BLAKE3.
Variable-length integers (varint) in signature encoding use the
same format as [POSTCARD]: unsigned LEB128. Each byte contains 7
data bits; the high bit indicates continuation (1 = more bytes).
All fixed-width integers in signature encoding are little-endian. The final u64 method ID is extracted as the first 8 bytes of the BLAKE3 hash, interpreted as little-endian.
The canonical representation encodes the method signature as a tuple (see
r[signature.method] below). Each type within is encoded recursively:
Primitive Types
Primitive types MUST be encoded as a single byte tag:
| Type | Tag |
|---|---|
bool |
0x01 |
u8 |
0x02 |
u16 |
0x03 |
u32 |
0x04 |
u64 |
0x05 |
u128 |
0x06 |
i8 |
0x07 |
i16 |
0x08 |
i32 |
0x09 |
i64 |
0x0A |
i128 |
0x0B |
f32 |
0x0C |
f64 |
0x0D |
char |
0x0E |
String |
0x0F |
() (unit) |
0x10 |
bytes |
0x11 |
Container Types
Container types MUST be encoded as a tag byte followed by their element type(s):
| Type | Tag | Encoding |
|---|---|---|
| List | 0x20 |
tag + encode(element) |
| Option | 0x21 |
tag + encode(inner) |
| Array | 0x22 |
tag + varint(len) + encode(element) |
| Map | 0x23 |
tag + encode(key) + encode(value) |
| Set | 0x24 |
tag + encode(element) |
| Tuple | 0x25 |
tag + varint(len) + encode(T1) + encode(T2) + ... |
| Stream | 0x26 |
tag + encode(element) |
Note: These are wire-format types, not Rust types. Vec, VecDeque, and
LinkedList all encode as List. HashMap and BTreeMap both encode as Map.
Any "bytes" type MUST use the bytes tag (0x11) in signature encoding.
This includes the dedicated bytes wire-format type and a list of u8.
As a result, bytes and List<u8> MUST produce identical signature hashes.
Struct Types
Struct types MUST be encoded as:
0x30 + varint(field_count) + (field_name + field_type)*
Where each field_name is encoded as varint(len) + utf8_bytes.
Fields MUST be encoded in declaration order.
Note: The struct's name is NOT included — only field names and types. This allows renaming types without breaking compatibility.
Enum Types
Enum types MUST be encoded as:
0x31 + varint(variant_count) + (variant_name + variant_payload)*
Where each variant_name is encoded as varint(len) + utf8_bytes.
variant_payload is:
0x00for unit variants0x01+ encode(T) for newtype variants0x02+ struct encoding (without the 0x30 tag) for struct variants
Variants MUST be encoded in declaration order.
Method Signature Encoding
A method signature MUST be encoded as a tuple of its arguments followed by the return type:
0x25 + varint(arg_count) + encode(arg1) + ... + encode(argN) + encode(return_type)
This structure ensures unambiguous parsing — without the argument count,
fn add(a: i32, b: i32) -> i64 would have the same bytes as
fn foo(a: i32, b: i32, c: i64) (which returns unit).
Example
For a method:
async fn add ( & self , a : i32 , b : i32 ) -> i64 ;
The canonical bytes would be:
0x25 // Tuple tag (method signature)
0x02 // 2 arguments
0x09 // a: i32
0x09 // b: i32
0x0A // return: i64
BLAKE3 hash of these bytes gives sig_bytes.
Introspection Types
The Diagnostic service uses these types for introspection and debugging.
Type information uses facet::Shape directly rather than a parallel type system.
struct ServiceDetail {
name : Cow < ' static , str >,
methods : Vec < MethodDetail >,
doc : Option < Cow < ' static , str >>,
}
struct MethodDetail {
service_name : Cow < ' static , str >,
method_name : Cow < ' static , str >,
args : Vec < ArgDetail >,
return_type : &' static Shape , // facet Shape, not TypeDetail
doc : Option < Cow < ' static , str >>,
}
struct ArgDetail {
name : Cow < ' static , str >,
ty : &' static Shape , // facet Shape
}
struct ServiceSummary {
name : Cow < ' static , str >,
method_count : u32 ,
doc : Option < Cow < ' static , str >>,
}
struct MethodSummary {
name : Cow < ' static , str >,
method_id : u64 ,
doc : Option < Cow < ' static , str >>,
}
enum MismatchExplanation {
/// Service doesn't exist
UnknownService { closest : Option < Cow < ' static , str >> },
/// Service exists but method doesn't
UnknownMethod { service : Cow < ' static , str >, closest : Option < Cow < ' static , str >> },
/// Method exists but signature differs
SignatureMismatch {
service : Cow < ' static , str >,
method : Cow < ' static , str >,
expected : MethodDetail ,
},
}
Using Shape for Type Introspection
Instead of a custom TypeDetail enum, roam uses facet::Shape directly.
Use facet_core to inspect shapes:
shape.defreveals if it's a struct, enum, list, option, etc.shape.type_paramsgives generic parametersshape.scalar_type()returns the scalar type for primitivesroam_schema::classify_shape()provides high-level classification for codegen
Helper functions in roam_schema:
is_tx(shape)/is_rx(shape)— check for streaming typesis_stream(shape)— check for any streaming typecontains_stream(shape)— recursively check for streamsis_bytes(shape)— check forVec<u8>or&[u8]
Usage
When Diagnostic.explain_mismatch returns SignatureMismatch, the client
can diff its local MethodDetail against the expected field to show
exactly where the types diverge:
Method `TemplateHost.load_template` signature mismatch:
arg `context_id`: expected ContextId { id: u64 }
got ContextId { id: u32 }
^^^
Wire Type Mappings
Certain roam types have special wire representations that differ from their Rust representation.
Stream
Stream<T> MUST be encoded on the wire as a u64 stream ID.
In Rust code, Stream<T> is a typed handle for sending/receiving values.
But when serialized in Request/Response payloads, only the stream ID is
sent — the type T is known from the method signature.
Stream<T> MUST be encoded in signature hashing as:
0x26 + encode(T).
// Method signature:
async fn process ( & self , input : Stream < Chunk >) -> Stream < Result >;
// Wire encoding of arguments: just the stream ID
// payload = postcard::to_vec(&(input_stream_id: u64))?;
// Wire encoding of response: just the stream ID
// payload = postcard::to_vec(&Result::<u64, RoamError<Infallible>>::Ok(output_stream_id))?;
The #[roam::service] proc macro MUST reject methods where Stream<T>
appears inside the error type E of a Result<T, E> return type. This
is a compile-time check that enforces r[streaming.error-no-streams]
from the main specification.
RoamError
The RoamError<E> type is defined in the main specification.
The Rust implementation provides this type with Facet derivation:
# [ derive ( Facet )]
pub enum RoamError < E > {
User ( E ), // discriminant 0
UnknownMethod , // discriminant 1
InvalidPayload , // discriminant 2
Cancelled , // discriminant 3
}
The variant order MUST match the main spec — Postcard encodes enum discriminants as varints starting from 0.
Generated Client Types
For a method:
async fn get_user ( & self , id : UserId ) -> Result < User , UserError >;
The generated client method returns:
async fn get_user ( & self , id : UserId ) -> Result < User , RoamError < UserError >>;
Callers can distinguish application errors from protocol errors:
match client. get_user ( id). await {
Ok ( user) => { /* success */ }
Err ( RoamError :: User ( e)) => { /* application error: e */ }
Err ( RoamError :: UnknownMethod ) => { /* protocol version mismatch? */ }
Err ( RoamError :: Cancelled ) => { /* we or they cancelled */ }
// ...
}