4. Transport (Layer 1) — MMP Specification

4. Layer 1: Transport

4.1 Wire Format

Over a raw byte-stream transport (TCP, §4.3), each frame is a length-prefixed UTF-8 JSON object:

+-------------------+---------------------------+
| 4 bytes           | N bytes                   |
| UInt32BE (length) | UTF-8 JSON payload        |
+-------------------+---------------------------+
  • —The length field is a 4-byte big-endian unsigned 32-bit integer encoding the byte length of the JSON payload.
  • —Implementations MUST reject frames with length 0 or length exceeding 1,048,576 bytes (1 MiB). Rejection MUST close the transport connection.
  • —The JSON payload MUST be a valid JSON object with a type field (string). Frames that fail JSON parsing or lack a type field MUST be silently discarded.
  • —Implementations MUST handle partial reads (TCP stream reassembly).
  • —Implementations MUST silently ignore frames with unrecognised type values (forward compatibility).

Message-delimited transports. The 4-byte length prefix is used only on raw byte streams. Over the WebSocket relay (§4.4), the WebSocket protocol already delimits messages, so each frame is carried as exactly one WebSocket text message (UTF-8 JSON) with MUST NOT a length prefix. The JSON payload is transmitted minified; the byte length in the examples below is of the minified form.

Frame size. Senders MUST NOT produce frames exceeding MAX_FRAME_SIZE bytes (default: 1,048,576). A raw-stream receiver MUST reject a frame whose 4-byte prefix is 0 or exceeds the limit and close the connection.

4.2 Wire Examples

Handshake frame:

MMP 2.0 · JSON · Client hello
Open fixture
First Core Secure handshake payload (client-hello):
{
  "type": "client-hello",
  "protocolVersion": "2.0",
  "room": "conformance-room",
  "nodeId": "018f47a0-7b21-7abc-8def-111111111111",
  "name": "vector-client",
  "identityPublicKey": "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc",
  "e2ePublicKey": "ew1H2TQn-DERYHgcfHM_2J-IlwrvSQ2KoO4ZpMuKGxQ",
  "nonce": "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU",
  "implementation": {
    "name": "mmp-vector",
    "version": "1.0.0"
  },
  "extensions": [
    "receipts-v1",
    "admission-attestation-v1"
  ]
}

The server-hello and client-finish frames complete transcript authentication and key confirmation.
See §5.2 and the full handshake-v2 vector.

Ping frame:

Length prefix: 00 00 00 0f  (15 bytes)
Payload: {"type":"ping"}

CMB frame:

MMP 2.0 · JSON · Signed CMB
Open fixture
{
  "type": "cmb",
  "protocolVersion": "2.0",
  "timestamp": 1711540800000,
  "cmb": {
    "categories": {
      "focus": {
        "text": "user coding for 3 hours, energy declining",
        "meta": {
          "key": "8b18a6a666984aa302c18b670b7c0f580ba9a13f72bcc1c80006b4ebc4eaa891",
          "parents": [
            "cmb-1010101010101010101010101010101010101010101010101010101010101010"
          ]
        }
      },
      "issue": {
        "text": "sedentary since morning, skipping lunch",
        "meta": {
          "key": "f7c2fb57884d9b68704b0381d38b6bba2574b10169b9002af019caa42942361d",
          "parents": [
            "cmb-1010101010101010101010101010101010101010101010101010101010101010"
          ]
        }
      },
      "intent": {
        "text": "recommend movement break before fatigue worsens",
        "meta": {
          "key": "6c7876aca1833fa873a673dcbfdffff0c340e2313d7aa741a10f0c9e4e685f7d",
          "parents": [
            "cmb-1010101010101010101010101010101010101010101010101010101010101010"
          ]
        }
      },
      "motivation": {
        "text": "three agents reported declining energy in the last hour",
        "meta": {
          "key": "4e65c95741b7be92674e62919599b09b60590885592993b916a82c03ac11c34d",
          "parents": [
            "cmb-1010101010101010101010101010101010101010101010101010101010101010"
          ]
        }
      },
      "commitment": {
        "text": "fitness monitoring active, ten-minute stretch queued",
        "meta": {
          "key": "112a9cd044c87587cfcc68044a128f9126fc1255f412a8d990b7b27a27ac483e",
          "parents": [
            "cmb-1010101010101010101010101010101010101010101010101010101010101010"
          ]
        }
      },
      "perspective": {
        "text": "fitness agent, afternoon session, home office",
        "meta": {
          "key": "f5dd4c3134cb72d9a153126889432fb5cd59bae7b70c311a2079597caa417f3a",
          "parents": [
            "cmb-1010101010101010101010101010101010101010101010101010101010101010"
          ]
        }
      },
      "mood": {
        "text": "concerned, low energy",
        "meta": {
          "key": "20504e25877d24384571595d4e99dec308d69e9b207e9ce7871554ce835a9c95",
          "parents": [
            "cmb-1010101010101010101010101010101010101010101010101010101010101010"
          ]
        },
        "valence": -0.3,
        "arousal": -0.4
      }
    },
    "metadata": {
      "key": "cmb-091003a832567f0c4ec11b6a5d5be4557f2f0dbf561ba182d251d87edcfcd7a7",
      "addressScheme": "mmp-cmb-merkle-v2",
      "signatureSuite": "mmp-sig-v2.0",
      "createdByNodeId": "018f47a0-7b21-7abc-8def-aaaaaaaaaaaa",
      "createdBy": "sensor-a",
      "createdTimestamp": 1711540800000,
      "room": "spec-examples",
      "to": null,
      "lineage": {
        "parents": [
          "cmb-1010101010101010101010101010101010101010101010101010101010101010"
        ],
        "method": "svaf-heuristic"
      },
      "application": null,
      "assertionId": "asrt-bb2003c7c34ceb1f4be7889342ff38dd34a1943e14e4724ddf257c466c1e76a8",
      "sigAlg": "ed25519",
      "sig": "ueE00X-6Q3E3X_fKmB048CGA0fcfpwX6MjcuNL7yH7eIG58SQOg0OS3W9JrdNNWnB1ULQhoq92DZIiPSsa_1CQ"
    }
  }
}

This example shows the legacy unsigned form (cmb- key, no sig); §8.2.1 and §18.3.1 define the current cmb-/signed form.

4.3 TCP Transport (LAN)

The primary LAN transport. Nodes MUST listen on a TCP port and advertise it via DNS-SD (Section 5.1). Connection timeout MUST be no longer than 10,000 ms.

4.4 WebSocket Relay Transport (WAN)

A relay is an optional WebSocket intermediary that enables connectivity between peers on different networks. Peers on the same LAN discover each other directly via Bonjour mDNS (§5.1) and do not require a relay. The relay provides internet-scale routing between peers behind NAT, a peer directory with wake-channel gossip, and per-token channel isolation for multi-tenant deployments.

A relay is pure routing infrastructure. It does not store CMBs, evaluate SVAF, or participate in mesh cognition. Payloads are opaque to the relay. The relay MUST NOT inspect or modify frame payloads.

4.4.1 Authentication

Clients connect via WebSocket (RFC 6455) and MUST send a relay-auth frame within 10 seconds. Failure results in close code 4001.

{
  "type": "relay-auth",
  "nodeId": "<uuid-v7>",
  "name": "<display-name>",
  "token": "<channel-token>",
  "room": "<room identifier>",
  "engine": "<implementation label>",
  "wakeChannel": {
    "platform": "apns",
    "token": "<push-token>",
    "environment": "production"
  }
}
  • —nodeId, name: MUST be present; a missing one results in close code 4002. nodeId is the lowercase form of §3.1.1; a relay MUST refuse any other spelling with 4002.
  • —token: SHOULD be present if the relay requires authentication. Invalid token results in close code 4003.
  • —wakeChannel: MAY be present. Registers push notification credentials for waking this peer when offline (§5.7).
  • —room: MAY be present: a room identifier (§5.8; absent means default). A relay that scopes routing, roster, presence and fan-out by room uses it to address the connection. It is addressing, never permission: the channel token stays the boundary, and the §5.8 checks at each endpoint still apply.
  • —engine: MAY be present: an implementation label of at most 32 characters, for the relay operator’s logs. A relay MUST NOT forward it.

On success, the relay registers the connection, sends a relay-peers response, and broadcasts relay-peer-joined to all other clients on the same channel.

4.4.2 Peer List

Immediately after authentication, the relay sends the current peer list:

{
  "type": "relay-peers",
  "peers": [
    { "nodeId": "<uuid>", "name": "<name>", "wakeChannel": {...}, "offline": false }
  ],
  "features": ["fanout"]
}

The array includes all connected peers on the same channel (excluding the requester) plus offline peers with registered wake channels (offline: true). Clients SHOULD treat each non-offline entry as a peer-joined event.

features MAY be present. It lists the optional relay behaviours this relay implements; the one defined here is fanout (§4.4.4). A client MUST ignore a feature it does not recognise, and MUST NOT use a feature the relay did not list on that connection. An absent features lists none.

4.4.3 Peer Presence

{ "type": "relay-peer-joined", "nodeId": "<uuid>", "name": "<name>" }
{ "type": "relay-peer-left",   "nodeId": "<uuid>", "name": "<name>" }

Broadcast to all peers on the same channel when a peer joins or leaves.

4.4.4 Message Routing

On the relay, a frame is wrapped in a relay-layer routing envelope — this envelope is a transport wrapper, not itself an application frame, so the §4.1 “discard frames without a type” rule applies to the inner payload (the frame), not to the envelope:

{ "to": "<target-nodeId>", "payload": { "type": "cmb", ... } }

If to is present, the relay forwards to that peer only. If absent, the relay broadcasts to all peers on the same channel. A sealed frame (§18.2.1) is sealed for one session, so a sender MUST send it with to or in a fan-out, never as a broadcast: a broadcast sealed frame reaches peers that cannot open it. to is a nodeId in the lowercase form of §3.1.1; an envelope whose to is anything else is malformed. A relay drops a malformed unicast envelope without delivering it, and MAY answer it with a relay-error. The relay adds from and fromName to forwarded frames. The relay MUST NOT route frames across channels.

Fan-out. A sender that holds a separate session with each recipient (§18.2.1) seals one frame per recipient, so a room broadcast to N peers is N frames. A relay that lists fanout in features (§4.4.2) also accepts a fan-out envelope, which carries them in one client message:

{ "fanout": [
    { "to": "<nodeId-1>", "payload": { "type": "cmb-encrypted", ... } },
    { "to": "<nodeId-2>", "payload": { "type": "cmb-encrypted", ... } }
] }
  • —Shape. An envelope carries exactly one of: to with payload; payload alone; or fanout alone. fanout is a non-empty array of entries, each with exactly a to nodeId, in the lowercase form of §3.1.1, and a payload frame; a to that is not one makes the whole envelope malformed. In every form, payload is a JSON object (a frame). A sender MUST NOT name the same recipient twice in one envelope, or itself. A relay MUST refuse a malformed fan-out envelope as a whole and deliver none of it, answering with a relay-error whose kind is fanout and whose reason is one of malformed, too-many-entries, duplicate-recipient or self (§4.4.9).
  • —Delivery. The relay delivers each entry exactly as it would deliver a { to, payload } envelope from the same sender: within the sender’s channel (and room, where the relay scopes by room, §5.8) only, as { from, fromName, payload }, with the payload delivered as the same JSON value it was sent as (a relay may serialise it again, but never changes it, and never past the delivered-size bound below). An entry whose recipient is not connected is dropped as a unicast would be; the other entries are still delivered. For each recipient the relay preserves the order of that sender’s frames, fan-out entries included.
  • —Confidentiality of the recipient set. A recipient receives only its own entry, in the same form as a unicast; it MUST NOT be able to learn from the delivered bytes that a fan-out occurred or who else was addressed. Recipients that collude can still compare the timing of what they received. The relay learns the recipient set, as it would from N unicasts.
  • —Limits. The whole envelope is one WebSocket message. A relay MUST accept a client message of at least MAX_FRAME_SIZE (§19.1), and each payload is also subject to MAX_FRAME_SIZE on its own. A relay that lists fanout MUST accept envelopes of at least RELAY_MIN_FANOUT (64) entries within that size. A relay adds from and fromName to what it delivers, so a delivered message can be larger than the one sent: a client MUST accept a delivered message of up to MAX_FRAME_SIZE plus RELAY_ENVELOPE_ALLOWANCE (4,096 bytes). A relay MUST NOT deliver a larger one. Re-serialising a payload can lengthen it (a JSON serialiser may write 1e21 as 1e+21), so a relay that re-serialises measures what it is about to deliver and drops a message over the bound, as it drops a unicast to an absent recipient; a relay that forwards the payload’s bytes as received meets the bound by construction. A sender splits a larger fan-out into several envelopes. Nesting depth is bounded for the whole envelope, not per payload: a relay MAY refuse a message whose arrays and objects nest more than 64 levels deep, counted from the envelope itself, and MUST NOT refuse one within 64 for depth alone. A unicast payload therefore has 63 levels of its own, and a fan-out payload 61 (the envelope, the fanout array and the entry take three). MMP frames need far fewer: a CMB’s categories sit four levels deep.
  • —Rate. A relay MAY limit each client’s inbound message rate, and closes a client that exceeds its limit with 4008. A relay that limits the rate MUST allow at least RELAY_MIN_RATE (25) messages a second, sustained, with a burst of at least RELAY_MIN_BURST (300) (§19.1), and MUST count a fan-out envelope as one message toward that limit. It MAY separately limit the bytes or entries a client sends per second. These are floors, not advertised values; nothing on the wire states a relay’s own limits. A sender SHOULD keep headroom below them, counting every message it sends, relay-auth and relay-pong included, because messages paced at the sender can arrive bunched after a network stall. Without fanout, a sender SHOULD pace its per-recipient envelopes within the floors rather than burst them.

The fan-out envelope changes routing, never meaning: each payload is the frame its recipient would have received by unicast, sealed for that recipient’s session, and a record’s binding comes from its signed audience (§9.2.2), not from how many envelopes carried it.

The envelope’s to is routing only. It MUST NOT be used to decide a CMB’s binding at the receiver — whether the record is peer-bound (directed) or room-bound (autonomous). The binding is the record’s authenticated metadata.to (§9.2.2), which the author signs (§8.8.4) and a sealed frame binds into its associated data (§18.2.1). The envelope is not authenticated, the relay does not forward its to to the recipient, and a sender whose frames are sealed per peer session addresses every frame to one peer, room broadcasts included. See §9.2.2 for the directed-vs-autonomous delivery contract.

4.4.5 Keepalive

The relay sends relay-ping at a regular interval (RECOMMENDED: 10 seconds). Clients MUST respond with relay-pong. A client that misses two consecutive pings is closed with code 4005. Clients MAY send unsolicited relay-pong frames; the relay MUST accept them.

4.4.6 Re-authentication

If the relay loses a client’s registration (e.g. relay restart behind a TLS proxy), it sends { "type": "relay-reauth" }. The client MUST re-send relay-auth in response.

4.4.7 Duplicate Identity

When a client authenticates with a nodeId already held by an existing connection:

  • —Fresh existing (< 5s): the relay MUST reject the newcomer with close code 4006. This prevents ping-pong loops where two processes with the same identity kick each other.
  • —Stale existing (≥ 5s): the relay MAY replace the existing connection by closing it with code 4004. The relay MUST NOT broadcast relay-peer-left for the replaced connection.

Clients receiving code 4004 SHOULD log the collision and MUST NOT automatically reconnect. Clients receiving code 4006 SHOULD NOT reconnect — the existing holder is the legitimate one.

4.4.8 Channel Isolation

A relay MAY support multiple isolated channels. Each authentication token maps to exactly one channel. Cross-channel routing MUST NOT occur: frames, peer lists, and presence notifications are scoped to the channel. A relay with no token configured operates in open mode (single default channel, no authentication).

4.4.9 Close Codes

Before it closes a client, or when it refuses a message without closing, a relay SHOULD send a relay-error:

{ "type": "relay-error", "kind": "fanout", "reason": "duplicate-recipient",
  "message": "fan-out entry 3 names a recipient an earlier entry names" }

{ "type": "relay-error", "kind": "auth", "code": 4003, "message": "<why the token was refused>" }
  • —message (MUST): text for the operator. It MUST NOT quote what the client sent.
  • —kind (SHOULD): a token naming what was refused, such as auth, duplicate-identity or fanout.
  • —code (MUST when a close follows): the close code below that the relay is about to use, so a client can act on the refusal even when the close reason is cut short.
  • —reason (MAY): a token naming the specific cause within kind, such as a fan-out refusal (§4.4.4).

A client MUST ignore a kind or reason it does not recognise. It acts on the close code that follows, which is never cut short, and MAY act on code for a close that does not arrive. A relay-error is a transport-scope frame from the relay, never a peer frame.

Code Name Client Action
4001Auth timeoutRetry with auth
4002Auth invalidFix auth frame
4003Invalid tokenCheck token config
4004ReplacedLog collision, do NOT reconnect
4005Heartbeat timeoutReconnect with backoff
4006Duplicate rejectedDo NOT reconnect
4008Rate limit exceededReconnect with backoff; send less (use fan-out where listed, §4.4.4)
1001, 1009, 1011, 1013WebSocket closes (RFC 6455): going away, message too big, internal error, try again later (a receiver too far behind)Reconnect with backoff

4.5 IPC Transport (Local)

Local tools MAY connect to a node via IPC (Unix domain socket, named pipe, or localhost TCP) to query mesh state. The framing is identical to TCP transport. IPC is an implementation convenience for local tooling (dashboards, CLI, monitoring) — it is not a substitute for peer-to-peer transport. Agents that participate in coupling MUST connect as full peer nodes via TCP or WebSocket.

The reference runtime provides a persistent IPC socket at a well-known path — an implementation convenience of the SYM runtime, not a wire requirement; a Class 1 Emitter (§17.1) need not provide it. Where provided, the socket SHOULD accept multiple simultaneous connections. Each IPC connection SHOULD remain open for the lifetime of the client application — short-lived connections (one query, then disconnect) are permitted but SHOULD be avoided by applications that query frequently.

Well-known IPC path: ~/.sym/daemon.sock (Unix domain socket) or localhost:19517 (TCP fallback).

4.6 Multi-Transport Per Peer

A peer MAY be reachable via multiple transports simultaneously (e.g. LAN TCP + WAN relay). Implementations MUST support maintaining multiple active transports for the same peer and select the highest-priority healthy transport for sending:

PriorityTransportRationale
1 (highest)TCP (LAN)Lowest latency, no intermediary, no cloud dependency
2WebSocket Relay (WAN)Cross-network, higher latency, relay dependency
3 (lowest)Wake (push)Last resort — wake the peer, then connect via 1 or 2

When a node receives an inbound connection from a peer that is already connected via a different transport, it MUST NOT reject the new connection. Instead it MUST add the new transport as a secondary path. Frames SHOULD be sent via the highest-priority healthy transport.

A transport is healthy if it has received a frame (including pong) within the heartbeat timeout (Section 5.4). An unhealthy transport SHOULD be closed after the timeout. The peer is only removed (peer-left event) when all transports for that peer are closed — not when a single transport drops.

This enables graceful failover: if a relay drops, the LAN transport continues. If LAN drops, the relay takes over. The peer remains connected throughout — only the active transport changes.

Q&A

Why must each agent run its own transport?

Coupling is per-node. SVAF category weights (αf) are per-node. Memory stores are per-node. An agent that shares another node’s transport and identity cannot have independent coupling decisions. Multiple agents on the same device each run their own Bonjour advertisement, relay connection, and TCP listener. They discover each other the same way agents on different devices do — there is no special local path.

Is the resource cost of N agents acceptable?

N agents on one device means N Bonjour advertisements and N relay connections. For small N (4–8 agents), this is well within OS limits. Bonjour is designed for many services per host. Relay WebSocket connections are lightweight. The protocol correctness benefit (per-agent coupling) outweighs the marginal resource cost.