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
rmp_serde::to_vec_named().#[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).
// 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 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>
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
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
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:
| Channel | Means |
|---|---|
| OP_ACK.results[] | The accepted set, and nothing else. Failures are never reported here. |
| OP_REJECTED | Exactly 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:
OP_ACK, every operation you sent that carried an id has exactly one verdict.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[].context | Means |
|---|---|
| an id you sent as an operation | A 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 query | An 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. |
| absent | A 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. |
{
"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"
}
]
} 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:
| Level | Description |
|---|---|
| FIRE_AND_FORGET | No acknowledgment, immediate return |
| MEMORY | Acknowledged when in server memory (default) |
| APPLIED | Acknowledged when CRDT merge complete |
| REPLICATED | Acknowledged when broadcast to peers |
| PERSISTED | Acknowledged when written to storage |
See the Write Concern Guide for detailed usage.
Query Subscriptions
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)
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
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
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:
| Domain | Types |
|---|---|
| Auth | AUTH, AUTH_REQUIRED, AUTH_ACK, AUTH_FAIL |
| Sync | CLIENT_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 |
| Query | QUERY_SUB, QUERY_UNSUB, QUERY_RESP, QUERY_UPDATE |
| Search | SEARCH, SEARCH_RESP, SEARCH_SUB, SEARCH_UPDATE, SEARCH_UNSUB |
| Messaging | TOPIC_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 |
| Cluster | PARTITION_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 |
| Events | SERVER_EVENT, SERVER_BATCH_EVENT, GC_PRUNE, ERROR, SYNC_RESET_REQUIRED |