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
typefield (string). Frames that fail JSON parsing or lack atypefield MUST be silently discarded. - —Implementations MUST handle partial reads (TCP stream reassembly).
- —Implementations MUST silently ignore frames with unrecognised
typevalues (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:
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:
{
"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.nodeIdis 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 meansdefault). 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:
towithpayload;payloadalone; orfanoutalone.fanoutis a non-empty array of entries, each with exactly atonodeId, in the lowercase form of §3.1.1, and apayloadframe; atothat is not one makes the whole envelope malformed. In every form,payloadis 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 arelay-errorwhosekindisfanoutand whosereasonis one ofmalformed,too-many-entries,duplicate-recipientorself(§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 toMAX_FRAME_SIZEon its own. A relay that listsfanoutMUST accept envelopes of at leastRELAY_MIN_FANOUT(64) entries within that size. A relay addsfromandfromNameto what it delivers, so a delivered message can be larger than the one sent: a client MUST accept a delivered message of up toMAX_FRAME_SIZEplusRELAY_ENVELOPE_ALLOWANCE(4,096 bytes). A relay MUST NOT deliver a larger one. Re-serialising a payload can lengthen it (a JSON serialiser may write1e21as1e+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, thefanoutarray 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 leastRELAY_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-authandrelay-pongincluded, because messages paced at the sender can arrive bunched after a network stall. Withoutfanout, 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-leftfor 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 asauth,duplicate-identityorfanout. - —
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 withinkind, 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 |
|---|---|---|
| 4001 | Auth timeout | Retry with auth |
| 4002 | Auth invalid | Fix auth frame |
| 4003 | Invalid token | Check token config |
| 4004 | Replaced | Log collision, do NOT reconnect |
| 4005 | Heartbeat timeout | Reconnect with backoff |
| 4006 | Duplicate rejected | Do NOT reconnect |
| 4008 | Rate limit exceeded | Reconnect with backoff; send less (use fan-out where listed, §4.4.4) |
| 1001, 1009, 1011, 1013 | WebSocket 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:
| Priority | Transport | Rationale |
|---|---|---|
| 1 (highest) | TCP (LAN) | Lowest latency, no intermediary, no cloud dependency |
| 2 | WebSocket 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.