Protocol Specification

TopGun uses a custom WebSocket-based protocol for real-time synchronization, efficient delta updates, and distributed coordination. This document details the wire format and synchronization algorithms for developers building compatible clients.

Overview

Transport:WebSocket (binary MsgPack). All frames are binary, serialized with rmp_serde::to_vec_named().
Model:Bidirectional, Event-driven. Client pushes ops, Server pushes updates.
Sync:Hybrid Logical Clocks (HLC) + Merkle Trees for convergence.
Encoding:Named MsgPack maps with camelCase field names. All struct fields use #[serde(rename_all = “camelCase”)].

Message Format

All messages use an internally-tagged discriminated union. The type field identifies the variant, and remaining fields are at the top level (no separate envelope wrapper).

Message Format
// All messages are internally tagged with a "type" field.
// Fields are flat (no separate envelope wrapper).
// Wire format: binary MsgPack via rmp_serde::to_vec_named().

{
  "type": "MESSAGE_TYPE",
  // ... message-specific fields at top level
}

The Rust Message enum uses #[serde(tag = "type")] for this representation, matching the TypeScript z.discriminatedUnion('type', [...]) pattern.

Binary Batch Framing

The BATCH message type carries multiple inner messages in a single binary frame. Inner messages are length-prefixed with a 4-byte big-endian u32:

BATCH
// BATCH wire layout (binary MsgPack frame):
//
// ┌──────────────────────────────────────────────┐
// │  MsgPack map: { "type": "BATCH", "count": N, │
// │                  "data": <bin blob> }         │
// └──────────────────────────────────────────────┘
//
// The "data" blob contains N length-prefixed inner messages:
//
// ┌────────────┬──────────────────────────────────┐
// │ 4 bytes BE │ MsgPack message #1               │
// │ u32 length │ (complete serialized Message)     │
// ├────────────┼──────────────────────────────────┤
// │ 4 bytes BE │ MsgPack message #2               │
// │ u32 length │ (complete serialized Message)     │
// ├────────────┼──────────────────────────────────┤
// │    ...     │ ...repeated N times              │
// └────────────┴──────────────────────────────────┘
//
// Example (hex) for a single 12-byte inner message:
// 00 00 00 0C  <12 bytes of MsgPack>
Each inner message within data is a complete MsgPack-serialized Message. The 4-byte length prefix allows the receiver to split the binary blob without parsing each message’s MsgPack structure.

Connection Lifecycle

1. WebSocket Connect

Client opens a WebSocket connection to /ws. Server immediately sends AUTH_REQUIRED as a binary MsgPack frame.

2. Authentication

Client sends AUTH with a JWT token. Server verifies the token and responds with AUTH_ACK (success) or AUTH_FAIL (failure). Unauthenticated messages are rejected until AUTH_ACK.

3. Synchronization

Client sends SYNC_INIT with the last sync timestamp. Server responds with SYNC_RESP_ROOT. If roots differ, the client walks the Merkle tree via MERKLE_REQ_BUCKET / SYNC_RESP_BUCKETS / SYNC_RESP_LEAF.

4. Live Updates

Bidirectional exchange of CLIENT_OP and SERVER_EVENT.

Message Types

Authentication

Two-phase authentication: AUTH_REQUIRED on connect, then AUTH with JWT token.

AUTH_REQUIRED

Sent by the server immediately after WebSocket connect, before the socket is split.

{
  "type": "AUTH_REQUIRED"
}

AUTH

{
  "type": "AUTH",
  "token": "eyJh...",
  "protocolVersion": 1  // optional
}

AUTH_ACK

{
  "type": "AUTH_ACK",
  "connectionId": "uuid...",
  "serverNodeId": "node-1",
  "roles": ["user"]
}

AUTH_FAIL

{
  "type": "AUTH_FAIL",
  "reason": "invalid token"
}

Data Operations

Operations are idempotent and commute based on HLC timestamps.

CLIENT_OP

Single client operation wrapped in a payload object. Maps to Rust ClientOpMessage { payload: ClientOp }.

{
  "type": "CLIENT_OP",
  "payload": {
    "mapName": "users",
    "key": "u1",
    "opType": "PUT",
    "record": {
      "value": { "name": "Alice" },
      "timestamp": {
        "millis": 1706000000000,
        "counter": 1,
        "nodeId": "client-1"
      }
    },
    "writeConcern": "PERSISTED",
    "timeout": 5000
  }
}

OP_BATCH

Batch of client operations. Maps to Rust OpBatchMessage { payload: OpBatchPayload }.

{
  "type": "OP_BATCH",
  "payload": {
    "ops": [ ... ],
    "writeConcern": "APPLIED",
    "timeout": 5000
  }
}

OP_ACK

Confirms which of your operations the server took, with the Write Concern level actually achieved. When results is present it lists only the accepted operations; an operation missing from it was not accepted. When it is absent, every operation up to and including lastId was accepted.

{
  "type": "OP_ACK",
  "payload": {
    "lastId": "op-123",
    "achievedLevel": "PERSISTED",
    "results": [
      {
        "opId": "op-123",
        "success": true,
        "achievedLevel": "PERSISTED",
        "latencyMs": 45
      }
    ]
  }
}

OP_REJECTED

Names one operation the server would not take, and says whether trying again could ever help. permanent: true means it could not — stop re-sending that operation. The field always serializes, so an absent key can never be mistaken for false.

{
  "type": "OP_REJECTED",
  "payload": {
    "opId": "op-456",
    "reason": "permission denied",
    "code": 403,
    "permanent": true
  }
}

Per-Operation Verdicts

One bad operation should not block the good ones behind it. Every operation you send gets its own answer, so a write your token may not make is refused on its own while the rest of the batch is applied and acknowledged.

The two channels never overlap:

ChannelMeans
OP_ACK.results[]The accepted set, and nothing else. Failures are never reported here.
OP_REJECTEDExactly one frame per refused operation, sent before the acknowledgment.

Reading the two together gives a client three rules, which the SDK already implements for you:

Never both:An operation is either covered by the acknowledgment or named in a rejection, never both.
Ack ⇒ complete:If the exchange ends in an OP_ACK, every operation you sent that carried an id has exactly one verdict.
ERROR ⇒ retry:If the exchange ends in an ERROR frame instead, only the rejections already sent are final. Everything unnamed stays pending and is safe to re-send — a batch that failed this way applied nothing.

A rejection carrying permanent: true is terminal across reconnects: the operation is dropped from the outbound queue rather than retried forever. In the SDK this surfaces as client.onWriteRejected(…) (and the useWriteRejections() React hook), and confirmWrite() resolves to ‘rejected’ instead of timing out. The locally-written value is kept, never rolled back, so the refusal is something you present to the user rather than data that silently disappears.

Over HTTP

The POST /sync transport carries the same two channels in one response body: ack.results for the accepted operations, errors[] for everything that failed. context names the request item an entry is attributed to — and both halves of the request write into it, so its presence alone does not mean an operation was refused. Match context against the ids you sent as operations in that same request; a query id belongs to the queries half and refuses no write:

errors[].contextMeans
an id you sent as an operationA permanent refusal of that one operation — the HTTP equivalent of OP_REJECTED. Retire it; retrying cannot help. The fold attributes nothing else to a single operation, so an operation-attributed entry is always permanent.
an id you sent as a queryAn error from the queries half of the same response — an RBAC read denial (403) or a malformed/expired cursor (400). It refuses no write. Retiring an operation on it reports a refusal that never happened. The two id namespaces are disjoint by convention, not by type, so an id claimed by both sets is ambiguous: never retire it.
absentA batch-level error naming no request item. Nothing is said about any individual write, so retire nothing on it. code separates the two kinds that arrive here: a transient failure, where retrying the batch is correct; and a non-attributed permanent failure — 501 (unknown or wrong service), or an operation that carried no id — where retrying fails identically and you must correct the batch.
POST /sync — good, forbidden, good
{
  "serverHlc": { "millis": 1730000000000, "counter": 3, "nodeId": "node-1" },
  "ack": {
    "lastId": "op-3",
    "results": [
      { "opId": "op-1", "success": true, "achievedLevel": "PERSISTED" },
      { "opId": "op-3", "success": true, "achievedLevel": "PERSISTED" }
    ]
  },
  "errors": [
    {
      "code": 403,
      "message": "write access denied for map: restricted-map",
      "context": "op-2"
    }
  ]
}
A batch with a refused operation never comes back as a bare ack with no results. That would claim the whole batch was taken, including the operation the server just refused.

Write Concern Levels

Operations can specify a writeConcern to control acknowledgment:

LevelDescription
FIRE_AND_FORGETNo acknowledgment, immediate return
MEMORYAcknowledged when in server memory (default)
APPLIEDAcknowledged when CRDT merge complete
REPLICATEDAcknowledged when broadcast to peers
PERSISTEDAcknowledged when written to storage

See the Write Concern Guide for detailed usage.

Query Subscriptions

Subscribe to live query results.

QUERY_SUB

{
  "type": "QUERY_SUB",
  "payload": {
    "queryId": "q1",
    "mapName": "users",
    "query": {
      "where": { "role": "admin" },
      "limit": 10
    }
  }
}

QUERY_UNSUB

{
  "type": "QUERY_UNSUB",
  "payload": {
    "queryId": "q1"
  }
}

Synchronization (Merkle)

TopGun uses a prefix-trie Merkle Tree. The client walks the tree to find differing buckets, then fetches leaf records for those buckets.

SYNC_INIT

Client initiates sync. Flat message (no payload wrapper). Maps to Rust SyncInitMessage.

{
  "type": "SYNC_INIT",
  "mapName": "users",
  "lastSyncTimestamp": 1678000000
}

SYNC_RESP_ROOT

Server returns root hash for comparison.

{
  "type": "SYNC_RESP_ROOT",
  "payload": {
    "mapName": "users",
    "rootHash": 12345678,
    "timestamp": { ... }
  }
}

MERKLE_REQ_BUCKET

Client requests bucket hashes if root differs.

{
  "type": "MERKLE_REQ_BUCKET",
  "payload": {
    "mapName": "users",
    "path": "a1"
  }
}

SYNC_RESP_BUCKETS

Server returns bucket-level hashes for a tree level.

{
  "type": "SYNC_RESP_BUCKETS",
  "payload": {
    "mapName": "users",
    "buckets": {
      "a1": 11111111,
      "a2": 22222222
    }
  }
}

SYNC_RESP_LEAF

Server returns actual records for a leaf bucket.

{
  "type": "SYNC_RESP_LEAF",
  "payload": {
    "mapName": "users",
    "path": "a1",
    "records": [
      {
        "key": "u1",
        "record": { "value": {...}, "timestamp": {...} }
      }
    ]
  }
}

Distributed Locks

Distributed locking with fencing tokens.

LOCK_REQUEST

{
  "type": "LOCK_REQUEST",
  "payload": {
    "requestId": "uuid",
    "name": "resource-A",
    "ttl": 5000
  }
}

LOCK_GRANTED

{
  "type": "LOCK_GRANTED",
  "payload": {
    "requestId": "uuid",
    "fencingToken": 101
  }
}

LOCK_RELEASE

{
  "type": "LOCK_RELEASE",
  "payload": {
    "requestId": "uuid",
    "name": "resource-A",
    "fencingToken": 101
  }
}

Pub/Sub

Real-time messaging with topics.

TOPIC_SUB

{
  "type": "TOPIC_SUB",
  "payload": {
    "topic": "chat"
  }
}

TOPIC_UNSUB

{
  "type": "TOPIC_UNSUB",
  "payload": {
    "topic": "chat"
  }
}

TOPIC_PUB

{
  "type": "TOPIC_PUB",
  "payload": {
    "topic": "chat",
    "data": { "msg": "hello" }
  }
}

TOPIC_MESSAGE

{
  "type": "TOPIC_MESSAGE",
  "payload": {
    "topic": "chat",
    "data": { "msg": "hello" },
    "publisherId": "client-2",
    "timestamp": 1678900000
  }
}

Full Message Type Reference

The Rust Message enum defines 77 message types across 7 domains. The most common types are documented above. The complete list of type discriminants:

DomainTypes
AuthAUTH, AUTH_REQUIRED, AUTH_ACK, AUTH_FAIL
SyncCLIENT_OP, OP_BATCH, BATCH, SYNC_INIT, SYNC_RESP_ROOT, SYNC_RESP_BUCKETS, SYNC_RESP_LEAF, MERKLE_REQ_BUCKET, OP_ACK, OP_REJECTED, ORMAP_SYNC_INIT, ORMAP_SYNC_RESP_ROOT, ORMAP_SYNC_RESP_BUCKETS, ORMAP_MERKLE_REQ_BUCKET, ORMAP_SYNC_RESP_LEAF, ORMAP_DIFF_REQUEST, ORMAP_DIFF_RESPONSE, ORMAP_PUSH_DIFF
QueryQUERY_SUB, QUERY_UNSUB, QUERY_RESP, QUERY_UPDATE
SearchSEARCH, SEARCH_RESP, SEARCH_SUB, SEARCH_UPDATE, SEARCH_UNSUB
MessagingTOPIC_SUB, TOPIC_UNSUB, TOPIC_PUB, TOPIC_MESSAGE, LOCK_REQUEST, LOCK_RELEASE, LOCK_GRANTED, LOCK_RELEASED, COUNTER_REQUEST, COUNTER_SYNC, COUNTER_RESPONSE, COUNTER_UPDATE, PING, PONG, ENTRY_PROCESS, ENTRY_PROCESS_BATCH, ENTRY_PROCESS_RESPONSE, ENTRY_PROCESS_BATCH_RESPONSE, JOURNAL_SUBSCRIBE, JOURNAL_UNSUBSCRIBE, JOURNAL_EVENT, JOURNAL_READ, JOURNAL_READ_RESPONSE, REGISTER_RESOLVER, REGISTER_RESOLVER_RESPONSE, UNREGISTER_RESOLVER, UNREGISTER_RESOLVER_RESPONSE, MERGE_REJECTED, LIST_RESOLVERS, LIST_RESOLVERS_RESPONSE
ClusterPARTITION_MAP_REQUEST, PARTITION_MAP, CLUSTER_SUB_REGISTER, CLUSTER_SUB_ACK, CLUSTER_SUB_UPDATE, CLUSTER_SUB_UNREGISTER, CLUSTER_SEARCH_REQ, CLUSTER_SEARCH_RESP, CLUSTER_SEARCH_SUBSCRIBE, CLUSTER_SEARCH_UNSUBSCRIBE, CLUSTER_SEARCH_UPDATE
EventsSERVER_EVENT, SERVER_BATCH_EVENT, GC_PRUNE, ERROR, SYNC_RESET_REQUIRED