Command Protocol
The wire format, replay protection, and real-time delivery mechanisms of the mediator protocol.
Formal spec: DCTRL-0004 — Mediator Protocol defines all commands, wire formats, error codes, and the WebSocket protocol.
Command Wire Format
All commands follow a uniform structure — a signed envelope with a header, payload, and Ed25519 signature:
{
"header": {
"version": 1,
"channel": "DIRECT_AUTHENTICATED | TWO_WAY_PRIVATE",
"sender_did": "did:decentrl:...",
"sender_signing_key_id": "did:decentrl:...#signing",
"recipient_did": "did:decentrl:...",
"timestamp": 1699123456000,
"nonce": "550e8400-e29b-41d4-a716-446655440000"
},
"payload": { },
"signature": "<Ed25519 over canonical JSON of { header, payload }>"
}The signature is computed over canonical JSON — keys are deep-sorted and the string is UTF-8 encoded before signing. This ensures deterministic verification regardless of JSON serialization order.
version is the mediator protocol version (currently 1). It is part of the signed header. A mediator rejects any other version with UNSUPPORTED_VERSION; a header without a version fails validation with INVALID_COMMAND.
Processing Order
A mediator checks every command in the same order:
- Protocol version — must be
1 - Timestamp window — within ±5 minutes of the mediator's clock
- Sender authentication — resolve the sender's DID, find the signing key, verify the signature over
{ header, payload } - Nonce — recorded only after the signature is valid; a duplicate is rejected
- Rate limit — per authenticated sender DID
- Channel-specific authorization — registration, contracts, and the command itself
Recording the nonce only after authentication means nobody can burn another sender's nonces with forged commands. Rate limiting after the nonce means replays can't spend a victim's quota.
Replay Protection
Every command carries a UUID nonce and a millisecond-precision timestamp. Mediators enforce two protections:
- Timestamp drift window — commands with timestamps outside a 5-minute window are rejected
- Nonce deduplication — seen nonces are stored and duplicates are rejected with
DUPLICATE_NONCE
A nonce is kept until the command's own timestamp plus the window has passed — not until its arrival time plus the window. Otherwise a command dated in the future could be replayed once its nonce was cleaned up while its timestamp was still valid.
Together, these prevent both replay attacks and nonce reuse. The WebSocket AUTHENTICATE message gets the same treatment: its nonce is deduplicated too (separately from command nonces).
Idempotent Writes
Nonces stop a command from being replayed, but a client that retries after a lost reply sends a new command for the same event. The writes that store events are idempotent, so that retry doesn't create a second record:
SAVE_EVENTS— every event carries adedupe_key, the owner's HMAC of the sender and the signed event ID. The mediator keeps one row per(owner, dedupe_key)and answers each event withSTOREDorDUPLICATE, in request order:{ "type": "SUCCESS", "payload": { "results": [{ "status": "STORED" }] } }. A duplicate leaves the stored row, its tags and its processed state untouched. The mediator never learns the event ID. It stamps each stored event with its own clock and ignores the client's timestamp;QUERY_EVENTSfilters and orders by that storage time, which is what clients resume reading from (DCTRL-0004 §8.10).PUBLISH_PUBLIC_EVENT— carries the signedevent_id. The first post for a(publisher, event_id)wins; publishing it again returnsSUCCESSwith the existingpublic_event_idandduplicate: true.
Both are enforced by unique indexes, so concurrent repeats store exactly one record.
Rate Limiting
Mediators rate-limit per authenticated sender DID using a fixed window. The defaults are 600 commands per minute and 100 PUBLISH_PUBLIC_EVENT commands per hour. Over the limit, the mediator responds with RATE_LIMIT_EXCEEDED (HTTP 429). Only commands with a valid signature count, so forged commands can't spend someone else's quota. There is no per-IP limit.
Real-Time Delivery via WebSocket
Mediators support WebSocket connections for push-based event delivery:
1. Client connects via WebSocket
2. Client sends authentication:
{
type: AUTHENTICATE,
did: "did:decentrl:...",
signing_key_id: "did:decentrl:...#signing",
timestamp: 1699123456000,
nonce: "...",
signature: "<Ed25519>"
}
3. Mediator verifies: timestamp drift < 5 min, resolves DID, verifies signature, rejects a reused nonce, checks registration
4. Mediator responds: { type: AUTH_SUCCESS }
5. On new events: mediator pushes { type: PENDING_EVENTS, events: [...] }This eliminates polling. The mediator pushes notifications for new pending events, contract updates, and other state changes in real time.
HTTP API
For clients that don't maintain persistent connections, mediators expose a standard HTTP API. Each command is a POST request with the signed envelope as the body. The mediator responds with the result.
Both HTTP and WebSocket use the same command format and the same authentication model — the only difference is the transport.