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.

method.identity.computation

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. TemplateHosttemplate-host)
  • sig_bytes is 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.

signature.hash.algorithm

The signature hash MUST be computed by hashing a canonical byte representation of the method signature using BLAKE3.

signature.varint

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).

signature.endianness

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

signature.primitive

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

signature.container

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.

signature.bytes.equivalence

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

signature.struct

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

signature.enum

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:

  • 0x00 for unit variants
  • 0x01 + encode(T) for newtype variants
  • 0x02 + struct encoding (without the 0x30 tag) for struct variants

Variants MUST be encoded in declaration order.

Method Signature Encoding

signature.method

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.def reveals if it's a struct, enum, list, option, etc.
  • shape.type_params gives generic parameters
  • shape.scalar_type() returns the scalar type for primitives
  • roam_schema::classify_shape() provides high-level classification for codegen

Helper functions in roam_schema:

  • is_tx(shape) / is_rx(shape) — check for streaming types
  • is_stream(shape) — check for any streaming type
  • contains_stream(shape) — recursively check for streams
  • is_bytes(shape) — check for Vec<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

wire.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.

signature.stream

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))?;
wire.stream.not-in-errors

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 */ }
    // ...
}