decentrl/docs
Specifications

DCTRL-0004Mediator Protocol

HTTP API, command wire format, authentication, WebSocket real-time protocol, and error handling.

FieldValue
RFCDCTRL-0004
TitleMediator Protocol
StatusDraft
Created2026-03-14
Version0.1
RequiresDCTRL-0001, DCTRL-0002, DCTRL-0003
Required ByDCTRL-0005, DCTRL-0006, DCTRL-0007

Abstract

This document specifies the Mediator Protocol — the HTTP API, command wire format, authentication flows, the commands of the DIRECT_AUTHENTICATED, TWO_WAY_PRIVATE and GROUP channels, WebSocket real-time protocol, and error handling for Decentrl mediators. Mediators are network participants that provide message routing and encrypted storage services to registered identities while operating encryption-blind.

Status of This Document

This is a draft specification.

Table of Contents

  1. Introduction
  2. Terminology
  3. HTTP API
  4. Command Wire Format
  5. Replay Protection
  6. DIRECT_AUTHENTICATED Channel
  7. TWO_WAY_PRIVATE Channel
  8. Command Reference
  9. Pagination
  10. WebSocket Protocol
  11. Error Codes
  12. Security Considerations
  13. References

1. Introduction

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119].

A mediator is a network participant identified by its own DID (typically did:web) that provides two core services:

  1. Message routing: Accepting encrypted events from senders and making them available to recipients.
  2. Encrypted storage: Storing encrypted events, communication contracts, and contract requests on behalf of registered identities.

Mediators operate encryption-blind: they can verify command signatures, check contract existence, and route messages, but they cannot read message contents, derive encryption keys, or access plaintext data. They do see routing metadata; see Section 12.5.


2. Terminology

Registered Identity — An identity that holds a registration contract with the mediator [DCTRL-0003 Section 9]. Registration is permanent.

Command — A JSON message sent to the mediator's POST / endpoint, containing a header, payload, and signature.

Channel — The communication channel type that determines authentication and routing rules. Four channels are defined: DIRECT_AUTHENTICATED, TWO_WAY_PRIVATE, ONE_WAY_PUBLIC [DCTRL-0007], and GROUP [Section 8.14].

Group — A group identity hosted by this mediator, with its record of admin chain, members, key version and events [DCTRL-0006].

Pending Event — An encrypted event stored by the mediator awaiting retrieval by the recipient.


3. HTTP API

3.1 Endpoints

A conforming mediator MUST implement the following HTTP endpoints:

MethodPathContent-TypeDescription
GET /application/jsonReturns the mediator's DID document
GET /.well-known/did.jsonapplication/jsonReturns the mediator's DID document (did:web resolution)
GET /healthapplication/jsonHealth check
POST /application/jsonAll mediator commands
GET /public/:didapplication/jsonPublic channel events [DCTRL-0007]
GET /ws—WebSocket upgrade

3.2 DID Document Endpoint

GET /, GET /.well-known/did.json and GET /did.json MUST return the mediator's DID document. /did.json serves path-based DIDs behind a reverse proxy that strips the path prefix.

The mediator's DID is derived from its public base URL (DOMAIN) per W3C did:web: the host, with any non-default port percent-encoded, followed by the path segments. http://localhost:8080 → did:web:localhost%3A8080; https://mediator.example.com → did:web:mediator.example.com; https://example.com/m → did:web:example.com:m. The port is part of the identity, so several mediators can share a host.

Development mode. With DID_WEB_DEV_MODE=true the mediator resolves every did:web over plain http and may fetch loopback/private hosts. It MUST be off for a mediator reachable from the internet; otherwise senders could make it fetch internal URLs (SSRF). Loopback DIDs (localhost, 127.0.0.1, [::1]) always resolve over http, but a mediator outside development mode refuses to fetch them.

Outside development mode, a mediator MUST refuse to fetch a did:web document when the host is a private or loopback hostname or IP literal, or when the host's DNS resolves to any private, loopback, link-local, or CGNAT (100.64.0.0/10) address. The resolved address MUST be checked at connection time, so a DNS answer that changes after validation (DNS rebinding) cannot bypass the check. The mediator MUST NOT follow HTTP redirects when fetching a DID document.

{
  "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"],
  "id": "<mediator DID>",
  "controller": "<mediator DID>",
  "verificationMethod": [{
    "id": "<mediator DID>#signing",
    "type": "Ed25519VerificationKey2020",
    "controller": "<mediator DID>",
    "publicKeyMultibase": "<z + base58btc Ed25519 public key>"
  }],
  "keyAgreement": [{
    "id": "<mediator DID>#prekey",
    "type": "X25519KeyAgreementKey2020",
    "controller": "<mediator DID>",
    "publicKeyMultibase": "<z + base58btc X25519 public key>"
  }],
  "authentication": ["<mediator DID>#signing"],
  "service": [{
    "id": "#mediator-service",
    "type": "DecentrlMediator",
    "serviceEndpoint": { "uri": "<mediator HTTP URL>" }
  }]
}

3.3 Health Check

GET /health MUST return:

  • 200: { "status": "ok" } — mediator is operational
  • 503: { "status": "error", "detail": "<description>" } — mediator is unavailable

3.4 CORS

Mediators SHOULD allow cross-origin requests with:

  • Allowed origins: * (or configurable)
  • Allowed methods: GET, POST, OPTIONS
  • Allowed headers: Content-Type

3.5 Cross-Mediator Routing

Every identity is served by the mediator named in its DID document. There is no mediator-to-mediator protocol: clients address the mediator of whoever must receive a command.

CommandSent to
REQUEST_COMMUNICATION_CONTRACT (to an identity)The recipient's mediator
COMMUNICATION_CONTRACT_RESPONSEThe requestor's mediator
TWO_WAY_PRIVATE eventsThe recipient's mediator
GROUP commandsThe group's mediator, named in the group's DID
Registration, SAVE_COMMUNICATION_CONTRACT, ACKNOWLEDGE_*, QUERY_*, SAVE_EVENTS, UPDATE_EVENT_TAGS, CREATE_GROUP, public events, WebSocketThe sender's own mediator
WebSocket subscriptions to groupsThe group's mediator [Section 10.8]

When Bob accepts Alice's request he saves his copy of the contract on his own mediator and sends the response to Alice's, so each inbox mediator holds a contract owned by its user; that is what authorises delivery to them. Senders do not need to be registered with the recipient's mediator, only resolvable. A client that cannot resolve the counterparty's mediator MUST fail before writing anything, so a pending request can be retried.


4. Command Wire Format

4.1 Universal Command Envelope

All commands sent to POST / MUST use this JSON structure:

{
  "header": {
    "version": 1,
    "channel": "DIRECT_AUTHENTICATED | TWO_WAY_PRIVATE | ONE_WAY_PUBLIC | GROUP",
    "sender_did": "<DID string>",
    "sender_signing_key_id": "<DID>#signing",
    "recipient_did": "<DID string>",
    "timestamp": 1699123456000,
    "nonce": "<UUID v4>"
  },
  "payload": <command-specific>,
  "signature": "<base64 Ed25519 signature>"
}

4.2 Header Fields

FieldTypeRequiredDescription
versionintegerREQUIREDMediator protocol version (MEDIATOR_PROTOCOL_VERSION). MUST be 1 for this specification.
channelstringREQUIRED"DIRECT_AUTHENTICATED", "TWO_WAY_PRIVATE", "ONE_WAY_PUBLIC" [DCTRL-0007], or "GROUP" [Section 8.14]
sender_didstringREQUIREDDID of the command sender
sender_signing_key_idstringREQUIREDFull key ID of the sender's signing key
recipient_didstringREQUIREDDID of the intended recipient; for GROUP, the group's DID
timestampnumberREQUIREDUnix timestamp in milliseconds (Date.now())
noncestringREQUIREDUUID v4 for deduplication

The version field is part of the signed header, so it cannot be altered in transit. A mediator MUST reject with UNSUPPORTED_VERSION a command whose version is any integer other than the version it implements. A command without a version fails schema validation and MUST be rejected with INVALID_COMMAND.

4.3 Payload

For DIRECT_AUTHENTICATED and GROUP commands, the payload is a JSON object containing a type field and command-specific data.

For TWO_WAY_PRIVATE commands, the payload is a plain string (the encrypted event blob).

4.4 Signature

The signature field is an Ed25519 signature over the canonical JSON serialization [DCTRL-0002] of { header, payload }:

signable = { header: command.header, payload: command.payload }
signature = sign_json_object(signable, sender_signing_private_key)

The signature field itself is excluded from the signed object.


5. Replay Protection

5.1 Timestamp Validation

The mediator MUST reject commands where the timestamp deviates from the server's current time by more than a configurable window:

if |current_time_ms - command.header.timestamp| > TIMESTAMP_WINDOW_MS:
    reject with TIMESTAMP_OUT_OF_RANGE

The default TIMESTAMP_WINDOW_MS is 300,000 (5 minutes). Implementations MAY make this configurable.

5.2 Nonce Deduplication

The mediator MUST reject commands with a (nonce, sender_did) pair that has already been recorded:

if nonce_store.contains(command.header.nonce, command.header.sender_did):
    reject with DUPLICATE_NONCE

The mediator MUST record a nonce only after the command's signature has been verified [Section 5.4]. Otherwise anyone could burn another sender's nonces with forged commands.

The mediator MUST store the (nonce, sender_did) pair until header.timestamp + TIMESTAMP_WINDOW_MS, the last moment the command would still pass the timestamp check. Expiry MUST NOT be computed from the arrival time: a command dated up to TIMESTAMP_WINDOW_MS in the future would otherwise lose its nonce while its timestamp is still valid, and could be replayed.

Recording a nonce MUST be atomic (e.g., a unique constraint), so concurrent duplicates are rejected.

5.3 Nonce Cleanup

The mediator SHOULD periodically delete nonces whose expiration has passed. The recommended cleanup interval is 10 minutes [Section 8.15].

5.4 Processing Order

The mediator MUST process every command in this order, stopping at the first failure:

1. Protocol version      header.version == 1                        else UNSUPPORTED_VERSION
2. Timestamp window      |now - header.timestamp| <= TIMESTAMP_WINDOW_MS
                                                                    else TIMESTAMP_OUT_OF_RANGE
3. Sender authentication resolve sender DID, find signing key, verify signature over { header, payload }
                                                                    else SENDER_NOT_FOUND /
                                                                    SENDER_SIGNING_KEY_NOT_FOUND /
                                                                    INVALID_SIGNATURE
4. Record nonce          [Section 5.2]                              else DUPLICATE_NONCE
5. Rate limit            [Section 5.5]                              else RATE_LIMIT_EXCEEDED
6. Channel-specific authorization and handling [Sections 6, 7, 8.14, DCTRL-0007]

The nonce is recorded after authentication so that only a sender can spend its own nonces. The rate limit is applied after the nonce so that replayed commands cannot spend the victim's quota.

For the GROUP channel, step 6 is itself ordered [Section 8.14.2]: payload schema, the group-event rate limit (POST_GROUP_EVENT, DELETE_GROUP_EVENT and REQUEST_GROUP_JOIN), the group record, the sender's access, then the command's own rules.

5.5 Rate Limiting

The mediator MUST rate-limit commands per authenticated sender DID using a fixed window. Only commands that passed sender authentication are counted, so forged commands cannot spend another sender's quota. A command over the limit MUST be rejected with RATE_LIMIT_EXCEEDED.

LimitScopeDefaultConfiguration
All commandsPer sender DID, per minute600RATE_LIMIT_COMMANDS_PER_MINUTE
PUBLISH_PUBLIC_EVENTPer sender DID, per hour100RATE_LIMIT_PUBLIC_EVENTS_PER_HOUR [DCTRL-0007 Section 10]
POST_GROUP_EVENT, DELETE_GROUP_EVENT and REQUEST_GROUP_JOINPer sender DID, per minute, across all groups, the three commands together120RATE_LIMIT_GROUP_EVENTS_PER_MINUTE [Sections 8.14.8, 8.14.11, 8.14.17]
SUBSCRIBE_GROUPS (WebSocket)Per authenticated DID, per minute30— [Section 10.8]

This specification does not define per-IP rate limiting of commands. Groups-only WebSocket connections are capped per IP address [Section 10.7].


6. DIRECT_AUTHENTICATED Channel

6.1 Purpose

The DIRECT_AUTHENTICATED channel is used for identity-to-mediator administrative operations. The sender authenticates with their Ed25519 signing key, and the mediator verifies the signature against the sender's DID document.

6.2 Authentication Flow

For commands with channel: "DIRECT_AUTHENTICATED", the mediator MUST (steps 1–3 are the sender authentication of Section 5.4, performed once for every channel):

  1. Resolve the sender's DID to a DID document.

    • If resolution fails → respond with SENDER_NOT_FOUND
  2. Extract the signing public key from the verification method matching sender_signing_key_id.

    • If the key ID is not found → respond with SENDER_SIGNING_KEY_NOT_FOUND
  3. Verify the signature over canonical JSON of { header, payload } using the extracted public key.

    • If verification fails → respond with INVALID_SIGNATURE
  4. Authorization check (depends on command type):

    For REQUEST_COMMUNICATION_CONTRACT and COMMUNICATION_CONTRACT_RESPONSE:

    • If recipient_did equals the mediator's own DID → skip recipient check
    • Otherwise → verify recipient_did is registered with this mediator
    • If not registered → respond with RECIPIENT_NOT_REGISTERED

    For all other commands (including CREATE_GROUP [Section 8.13]):

    • Verify recipient_did equals the mediator's own DID
    • If not → respond with UNAUTHORIZED_COMMAND
    • Verify sender_did is registered with this mediator
    • If not → respond with UNAUTHORIZED_COMMAND

7. TWO_WAY_PRIVATE Channel

7.1 Purpose

The TWO_WAY_PRIVATE channel is used for encrypted event delivery between identities. The mediator verifies the sender's signature, checks that the recipient is registered, and confirms a valid communication contract exists.

7.2 Authentication Flow

For commands with channel: "TWO_WAY_PRIVATE", the mediator MUST (steps 1–3 are the sender authentication of Section 5.4):

  1. Resolve the sender's DID. Error: SENDER_NOT_FOUND
  2. Extract the signing public key. Error: SENDER_SIGNING_KEY_NOT_FOUND
  3. Verify the signature. Error: INVALID_SIGNATURE
  4. Resolve the recipient's DID. Error: RECIPIENT_NOT_FOUND
  5. Verify the recipient is registered with this mediator. Error: RECIPIENT_NOT_REGISTERED
  6. Verify a communication contract exists: Look up a SignedCommunicationContract owned by recipient_did where both sender_did and recipient_did appear in the contract (as either requestor or recipient). Contracts do not expire; there is no contract only if none was ever stored or the recipient revoked it [Section 8.7]. Both cases return the same error: COMMUNICATION_CONTRACT_NOT_FOUND
  7. Store the pending event: Create a pending event record with the payload string.

The mediator does not parse or validate the payload. For contract traffic it is the private payload of [DCTRL-0005 Section 4]: JSON whose contract_id and epoch are readable by the mediator, around AES-GCM ciphertext it cannot read.

7.3 Response

{
  "type": "SUCCESS",
  "pendingEventId": "<server-generated ID>"
}

The pendingEventId is an opaque string generated by the mediator. Clients use it for acknowledgment.


8. Command Reference

8.1 REQUEST_COMMUNICATION_CONTRACT

Payload:

{
  "type": "REQUEST_COMMUNICATION_CONTRACT",
  "encrypted_contract_request": "<base64 AES-GCM ciphertext>",
  "requestor_ephemeral_public_key": "<base64 X25519 public key>"
}

Behavior:

If recipient_did equals the mediator's DID (registration):

  1. Decrypt the contract request using X25519(mediator_prekey_private, requestor_ephemeral_public) [DCTRL-0003 Section 9]
  2. Validate the decrypted request: it MUST be a well-formed contract request whose requestor_did equals the command's sender_did, whose recipient_did equals the mediator's DID, and whose timestamp is at most 5 minutes in the future and at most 30 days in the past [DCTRL-0003 Section 5]. Otherwise (including when decryption fails) return INVALID_COMMAND
  3. Verify the requestor's signature; if it is invalid return INVALID_SIGNATURES
  4. Generate the mediator's ephemeral key pair
  5. Sign the contract as recipient
  6. Store the signed contract [Section 8.5]; a newer registration replaces the identity's earlier one. Registration does not expire
  7. Return the signed contract

If recipient_did is another identity:

  1. Verify the recipient is registered
  2. Unless the recipient already holds a contract with the sender here (an existing contact requesting again), refuse with PENDING_REQUESTS_LIMIT when the recipient already holds as many unacknowledged requests from other senders as its limit: 1000 when the recipient is a group this mediator hosts and has not deleted (join requests arrive as contract requests [DCTRL-0006 Section 6.4]), 50 otherwise
  3. Replace any unacknowledged request from the same sender to the same recipient (one open request per pair; a retry with fresh keys supersedes the old one) and store the encrypted request as a pending contract request for the recipient

Recipients dismiss a request without accepting it by acknowledging it (ACKNOWLEDGE_PENDING_COMMUNICATION_CONTRACT_REQUESTS), which also frees its place under the limit.

A contact the recipient has revoked [Section 8.7] no longer has a contract here, so it counts as a stranger for this limit.

Response — registration:

{
  "type": "SUCCESS",
  "code": "MEDIATOR_REGISTRATION_SUCCESS",
  "payload": {
    "signed_communication_contract": { ... }
  }
}

Response — forwarded:

{
  "type": "SUCCESS",
  "code": "REQUESTED"
}

8.2 COMMUNICATION_CONTRACT_RESPONSE

Payload:

{
  "type": "COMMUNICATION_CONTRACT_RESPONSE",
  "signed_communication_contract": { <SignedCommunicationContract> }
}

Behavior:

  1. Verify the sender is a participant in the contract
  2. Verify the other participant is registered with this mediator
  3. Verify both signatures on the contract [DCTRL-0003 Section 8]
  4. Store the contract on behalf of the other participant [Section 8.5]

Response:

{ "type": "SUCCESS" }

8.3 QUERY_PENDING_COMMUNICATION_CONTRACT_REQUESTS

Payload:

{
  "type": "QUERY_PENDING_COMMUNICATION_CONTRACT_REQUESTS",
  "pagination": { "page": 0, "page_size": 10 }
}

Returns unacknowledged contract requests where the command sender is the recipient.

Response:

{
  "type": "SUCCESS",
  "payload": {
    "pending_communication_contract_requests": [
      {
        "id": "<server-generated ID>",
        "sender_did": "<DID>",
        "encrypted_contract_request": "<base64>",
        "requestor_ephemeral_public_key": "<base64>"
      }
    ],
    "pagination": { "page": 0, "page_size": 10, "total": 42 }
  }
}

8.4 ACKNOWLEDGE_PENDING_COMMUNICATION_CONTRACT_REQUESTS

Payload:

{
  "type": "ACKNOWLEDGE_PENDING_COMMUNICATION_CONTRACT_REQUESTS",
  "communication_contract_ids": ["<id1>", "<id2>"]
}

Deletes the specified contract requests. Requests not belonging to the sender are silently ignored. The mediator MUST NOT retain acknowledged requests.

Response:

{ "type": "SUCCESS" }

8.5 SAVE_COMMUNICATION_CONTRACT

Payload:

{
  "type": "SAVE_COMMUNICATION_CONTRACT",
  "signed_communication_contract": { <SignedCommunicationContract> }
}

Behavior:

  1. Verify both signatures [DCTRL-0003 Section 8]
  2. Store the contract with owner = sender

Storing a contract. Every contract store (this command, COMMUNICATION_CONTRACT_RESPONSE, and registration) keeps at most one contract per owner and counterpart [DCTRL-0003 Section 11]:

  • The newer contract wins: later timestamp, and for equal timestamps the greater contract ID [DCTRL-0003 Section 7], compared as strings. A newer contract replaces the stored one.
  • A contract older than or equal to the stored one is ignored.
  • If the owner revoked the counterpart [Section 8.7], a contract is ignored unless it is newer than the revoked contract (compared as above, no clocks involved) and its timestamp is no more than 5 minutes (the request clock skew [DCTRL-0003 Section 6]) before the revocation time.
  • Ignored contracts still get SUCCESS, so a replay learns nothing.
  • When a newer contract replaces the stored one, pending events from the counterpart to the owner whose payload names the replaced contract (contract_id, readable in the clear [DCTRL-0005 Section 4]) are deleted: nobody holds the keys to open them.
  • Stores and revocations for one owner are processed one at a time, so concurrent commands cannot leave two contracts for a pair.

Response:

{ "type": "SUCCESS" }

8.6 QUERY_COMMUNICATION_CONTRACTS

Payload:

{
  "type": "QUERY_COMMUNICATION_CONTRACTS",
  "filter": {
    "did": "<DID>"
  },
  "pagination": { "page": 0, "page_size": 10 }
}

The did filter is OPTIONAL and matches as either requestor_did or recipient_did. Returns contracts owned by the command sender: at most one per counterpart [Section 8.5].

Response:

{
  "type": "SUCCESS",
  "payload": {
    "communication_contracts": [
      {
        "id": "<server-generated ID>",
        "signed_communication_contract": { ... }
      }
    ],
    "pagination": { "page": 0, "page_size": 10, "total": 7 }
  }
}

8.7 REVOKE_COMMUNICATION_CONTRACT

Payload:

{
  "type": "REVOKE_COMMUNICATION_CONTRACT",
  "participant_did": "<DID>"
}

Withdraws the sender's consent to communicate with participant_did [DCTRL-0003 Section 12]. The sender sends this only to its own mediator; nothing is sent to the participant or to the participant's mediator.

Behavior (one transaction, serialized with the sender's contract stores [Section 8.5]):

  1. Delete the sender's contracts with participant_did, whichever of the two requested them
  2. Delete pending events from participant_did to the sender
  3. Delete pending contract requests from participant_did to the sender
  4. Record a revocation for (sender, participant_did) with the current time and the newest deleted contract's timestamp and contract ID (kept from an earlier revocation when none was deleted)

From then on, the participant's TWO_WAY_PRIVATE events to the sender fail with COMMUNICATION_CONTRACT_NOT_FOUND, the same response a stranger gets. A contract for the pair that is not newer than the revoked one, or was created well before the revocation, is never stored for the sender again [Section 8.5]; one created later (after a new request) is stored normally. The participant is a stranger for the pending-request limit [Section 8.1].

The command is idempotent and succeeds for a DID the sender never had a contract with. Revocation records are kept indefinitely.

Response:

{ "type": "SUCCESS" }

8.8 QUERY_PENDING_EVENTS

Payload:

{
  "type": "QUERY_PENDING_EVENTS",
  "filter": {
    "sender_did": "<DID>"
  },
  "pagination": { "page": 0, "page_size": 10 }
}

All filter fields are OPTIONAL. Returns unacknowledged pending events where the command sender is the recipient.

Response:

{
  "type": "SUCCESS",
  "payload": {
    "pending_events": [
      {
        "id": "<server-generated ID>",
        "payload": "<opaque encrypted string>",
        "sender_did": "<DID>"
      }
    ],
    "pagination": { "page": 0, "page_size": 10, "total": 3 }
  }
}

8.9 ACKNOWLEDGE_PENDING_EVENTS

Payload:

{
  "type": "ACKNOWLEDGE_PENDING_EVENTS",
  "event_ids": ["<id1>", "<id2>"]
}

Deletes the specified pending events. Events not belonging to the sender are silently ignored. The mediator MUST NOT retain acknowledged events: once delivered, the transit ciphertext no longer exists on the mediator. Together with daily key epochs [DCTRL-0003 Section 10], this limits what a later device compromise can decrypt: ciphertext of epochs whose keys both parties have deleted stays unreadable (transit forward secrecy).

Response:

{ "type": "SUCCESS" }

8.10 SAVE_EVENTS

Payload:

{
  "type": "SAVE_EVENTS",
  "events": [
    {
      "sender_did": "<DID>",
      "recipient_did": "<DID>",
      "contract_id": "<optional string>",
      "timestamp": 1699123456,
      "payload": "<base64 storage-encrypted event>",
      "encrypted_tags": ["<base64 tag1>", "<base64 tag2>"],
      "dedupe_key": "<base64 keyed hash of sender DID and event ID>"
    }
  ]
}

Events are stored with owner_did = the command sender. If event.sender_did equals the owner (self-authored event), the event is marked as app-processed. Otherwise it is marked unprocessed.

The mediator MUST store each event with its own clock's current time, in whole seconds or finer, and MUST ignore event.timestamp (kept in the payload schema for compatibility). The stored time is the storage time QUERY_EVENTS filters, orders and returns by: clients resume reading from it, so no device's clock, wrong or hostile, can make them skip events. The time an event claims to have been sent travels inside its encrypted payload.

dedupe_key is REQUIRED (1 to 128 characters). The owner computes it as a keyed hash of the event's sender DID and event ID [DCTRL-0005 Section 9.5]; the mediator treats it as opaque and never learns the event ID. The mediator MUST keep at most one stored event per (owner_did, dedupe_key), enforced by a unique index so concurrent saves of the same event cannot both succeed. A missing or invalid dedupe_key fails schema validation (INVALID_COMMAND).

Each event is saved on its own, in request order:

  • STORED: no event with this dedupe_key existed for the owner; the event was stored.
  • DUPLICATE: one already existed (from an earlier command, from another client of the same identity, or earlier in this same command). Nothing is changed: not the stored event, its tags, nor its app-processed state.

A duplicate does not abort the other events of the command.

Response:

{
  "type": "SUCCESS",
  "payload": {
    "results": [{ "status": "STORED" }, { "status": "DUPLICATE" }]
  }
}

results has one entry per event, in request order. A client MUST treat any other response as a failed save.

8.11 QUERY_EVENTS

Payload:

{
  "type": "QUERY_EVENTS",
  "filter": {
    "after_timestamp": 1699000000,
    "before_timestamp": 1699999999,
    "participant_did": "<DID>",
    "encrypted_tags": ["<base64 tag>"],
    "unprocessed_only": true
  },
  "pagination": { "page": 0, "page_size": 10 }
}

All filter fields are OPTIONAL. after_timestamp and before_timestamp are inclusive bounds on the storage time [Section 8.10], in unix seconds. participant_did matches as either sender_did or recipient_did. encrypted_tags matches events that have at least one matching tag. unprocessed_only returns events not yet processed by the application. Returns events owned by the command sender, oldest first by storage time, with each event's storage time as timestamp. pagination.total counts every matching event, so a client pages until it has read total, whatever it discards.

Response:

{
  "type": "SUCCESS",
  "payload": {
    "events": [
      {
        "id": "<server-generated ID>",
        "payload": "<base64 storage-encrypted event>",
        "encrypted_tags": ["<base64 tag1>"],
        "timestamp": 1699123456
      }
    ],
    "pagination": { "page": 0, "page_size": 10, "total": 100 }
  }
}

8.12 UPDATE_EVENT_TAGS

Payload:

{
  "type": "UPDATE_EVENT_TAGS",
  "events": [
    {
      "event_id": "<server-generated ID>",
      "encrypted_tags": ["<base64 tag1>", "<base64 tag2>"]
    }
  ]
}

event_id here is the stored event's server-generated id from QUERY_EVENTS, not the event ID chosen by the publisher [DCTRL-0005 Section 3.2].

Replaces all existing tags on the specified events and marks them as app-processed. Events not owned by the command sender are silently skipped.

Response:

{ "type": "SUCCESS" }

8.13 CREATE_GROUP

Sent on the DIRECT_AUTHENTICATED channel by a group identity to its own mediator, once, to create the group [DCTRL-0006 Section 7.5].

Payload:

{
  "type": "CREATE_GROUP",
  "genesis_link": {
    "type": "decentrl.group.admin",
    "group_did": "<the sender's DID>",
    "admin_epoch": 0,
    "admin_did": "<first admin's DID>",
    "key_version": 0,
    "history": "full",
    "signature": "<base64 Ed25519 signature by the group identity>"
  }
}

history is OPTIONAL: "from_join" or "full", absent meaning "from_join" [DCTRL-0006 Section 4.5].

Behavior (serialized with every other write to the same group [Section 8.14.2]):

  1. If a group record for sender_did exists, deleted or not, return GROUP_EXISTS
  2. Verify genesis_link as link 0 of the group's admin chain [DCTRL-0006 Section 4.2]: group_did equals sender_did, epoch 0, key_version 0, admin_did other than the group, signed by the group's signing key. Otherwise return INVALID_ADMIN_LINK
  3. Verify the group owns a stored contract with admin_did on this mediator. Otherwise return NOT_INVITED
  4. Create the group record (admin = admin_did, chain = [genesis_link], admin_from_seqs = [0], history = the link's policy, key_version 0) with admin_did as its only member (joined_after_seq 0)

Response:

{ "type": "SUCCESS" }

8.14 GROUP Channel

The GROUP channel carries commands addressed to a group hosted by this mediator [DCTRL-0006]. DCTRL-0006 Section 7 specifies the rules in full. This section specifies the wire format and the errors.

8.14.1 Addressing

  • header.channel is "GROUP".
  • header.recipient_did is the group's DID. The command is sent to the mediator named in that DID.
  • header.sender_did is a member's personal DID, or, for the join commands open to non-members [Section 8.14.2], any DID, authenticated like any sender [Section 5.4].
  • There is no registered-sender rule and no check that the group is registered: members may be registered on any mediator. The group record alone decides what the sender may do.
  • payload is a JSON object with a type field.

A GROUP command signed by the group identity itself is refused with NOT_A_GROUP_MEMBER: the group is never a member.

8.14.2 Processing Order

After step 5 of [Section 5.4], the mediator MUST process a GROUP command in this order, stopping at the first failure:

6a. Payload schema          the command's payload schema                else INVALID_COMMAND
6b. Group-event rate limit  POST_GROUP_EVENT, DELETE_GROUP_EVENT,       else RATE_LIMIT_EXCEEDED
                            REQUEST_GROUP_JOIN [Section 5.5]
6c. Group record            a record exists for recipient_did           else GROUP_NOT_FOUND
6d. Tombstone               the group is not deleted                    else GROUP_DELETED
6e. Access                  sender is the group itself, or neither      → NOT_A_GROUP_MEMBER
                            admin nor member for a command not open
                            to outsiders
                            LEAVE_GROUP from the admin                  → ADMIN_CANNOT_LEAVE
                            admin-only command from a member            → NOT_GROUP_ADMIN
                            remove or approve command from a member     → NOT_PERMITTED
                            whose grant lacks that capability
6f. Command rules           [Sections 8.14.3 – 8.14.24]

Each command has an access class [DCTRL-0006 Section 7.3]:

AccessCommands
Open to outsiders (anyone but the group)QUERY_GROUP_JOIN_INFO, REQUEST_GROUP_JOIN, CANCEL_GROUP_JOIN
Member (the admin included)LEAVE_GROUP, ACCEPT_GROUP_ADMIN, QUERY_GROUP, POST_GROUP_EVENT, QUERY_GROUP_EVENTS, DELETE_GROUP_EVENT, QUERY_GROUP_KEY_PARCELS, ACK_GROUP_KEY_PARCELS, QUERY_GROUP_COUNTS, QUERY_GROUP_PRESENCE
Admin onlyUPDATE_GROUP_MEMBERS, HAND_OVER_GROUP, DELETE_GROUP, SET_GROUP_GRANT, SET_GROUP_GRANT_SCOPE, SET_GROUP_JOIN_KEY, DELIVER_GROUP_KEY_PARCELS
The admin, or a member granted removeREMOVE_GROUP_MEMBER
The admin, or a member granted approveQUERY_GROUP_JOIN_REQUESTS, DECLINE_GROUP_JOIN, APPROVE_GROUP_JOIN

Every command that writes (all but QUERY_GROUP, QUERY_GROUP_EVENTS, QUERY_GROUP_JOIN_INFO, QUERY_GROUP_KEY_PARCELS, QUERY_GROUP_COUNTS and QUERY_GROUP_PRESENCE) MUST run steps 6c–6f one at a time per group, and CREATE_GROUP takes the same per-group turn. A role then cannot change between the check and the write, and event seq values follow commit order. A mediator that stores events in a database whose sequence does not follow commit order, or that runs as several instances, needs a per-group counter instead.

8.14.3 UPDATE_GROUP_MEMBERS

Admin only. Adds and removes members; with key_version, rotates the group key.

Payload:

{
  "type": "UPDATE_GROUP_MEMBERS",
  "add": ["<DID>"],
  "remove": ["<DID>"],
  "key_version": 4
}

add and remove are OPTIONAL (default empty), at most 1000 DIDs each. key_version is OPTIONAL, an integer ≥ 1.

Errors, checked in this order:

ConditionError
A DID is in both add and remove, or add contains the group's DIDINVALID_COMMAND
remove contains the adminCANNOT_REMOVE_ADMIN
key_version present and not the current version + 1KEY_VERSION_CONFLICT
key_version absent, and a current member is removed or a rotation is dueKEY_VERSION_CONFLICT
More than 1000 members would resultGROUP_FULL
The group owns no stored contract with a DID being addedNOT_INVITED (nobody is added)

Behavior (one transaction): delete removed members and their waiting key parcels; add new members with joined_after_seq = the group's last seq; with key_version, set the group's version and clear rotation_due; if the member named by the pending handover link is removed, clear the link; log an ended grant entry (removed) for each removed grantee and drop its delete scopes; delete the join requests of added members. Adding a member or removing a non-member is a no-op.

Response:

{
  "type": "SUCCESS",
  "members": ["<DID>"],
  "key_version": 4
}

members is the member list after the change, in the order members were added.

8.14.4 LEAVE_GROUP

A member other than the admin leaves (ADMIN_CANNOT_LEAVE for the admin). The mediator deletes the membership and the leaver's waiting key parcels, sets rotation_due, clears the pending handover link if it named the leaver, and, if the leaver held a grant, logs an ended entry (left) and drops its delete scopes [DCTRL-0006 Section 4.7].

Payload:

{ "type": "LEAVE_GROUP" }

Response:

{ "type": "SUCCESS" }

8.14.5 HAND_OVER_GROUP

Admin only. Names the next admin with the next link of the admin chain.

Payload:

{
  "type": "HAND_OVER_GROUP",
  "link": {
    "type": "decentrl.group.admin",
    "group_did": "<group DID>",
    "admin_epoch": 1,
    "admin_did": "<successor's DID>",
    "key_version": 4,
    "signature": "<base64 Ed25519 signature by the current admin>"
  }
}

Errors:

ConditionError
link is not a valid successor of the chain's last link [DCTRL-0006 Section 4.2]INVALID_ADMIN_LINK
link.admin_did is not a memberNEW_ADMIN_NOT_MEMBER
link.key_version is not the group's current versionSTALE_HANDOVER

Behavior: stores link as the pending link, replacing any earlier one.

Response:

{ "type": "SUCCESS" }

8.14.6 ACCEPT_GROUP_ADMIN

Sent by the member the pending link names.

Payload:

{ "type": "ACCEPT_GROUP_ADMIN" }

Errors:

ConditionError
No pending link, or it names another memberNO_PENDING_HANDOVER
The pending link's key_version is no longer the group's current versionSTALE_HANDOVER

Behavior: appends the pending link to the admin chain and the group's last seq (the highest seq of any of its events, or 0) to admin_from_seqs [DCTRL-0006 Section 4.6], makes the sender the admin, clears the pending link, and sets rotation_due. If the sender held a grant, logs an ended entry (became_admin).

Response:

{ "type": "SUCCESS" }

8.14.7 QUERY_GROUP

Admin or member.

Payload:

{ "type": "QUERY_GROUP" }

Response:

{
  "type": "SUCCESS",
  "admin_did": "<DID>",
  "admin_chain": [ { <admin link> } ],
  "admin_from_seqs": [0, null, 57],
  "history": "from_join",
  "pending_link": { <admin link> } | null,
  "members": ["<DID>"],
  "key_version": 4,
  "rotation_due": false,
  "grant_log": [
    { "kind": "grant", "from_seq": 118, "grant": { <signed grant> } },
    { "kind": "ended", "from_seq": 240, "grantee_did": "<DID>", "reason": "left" }
  ],
  "grant_scopes": [
    { "grantee_did": "<DID>", "key_version": 4, "values": ["<base64 group tag value>"], "sealed_prefixes": "<base64 AES-GCM ciphertext>" }
  ],
  "join_key": { <join key statement> } | null,
  "staff_log": [
    { "seq": 250, "actor_did": "<DID>", "action": "approve", "target_did": "<DID>" }
  ]
}

grant_log is the group's whole grant log, oldest first; a grant entry holds the signed grant [DCTRL-0006 Section 4.7], and reason of an ended entry is "left", "removed" or "became_admin". grant_scopes holds the delete scope rows the sender may see: all of them for the admin, a scoped grantee's own for that grantee, none otherwise [DCTRL-0006 Section 4.8]. join_key is the statement last published with SET_GROUP_JOIN_KEY [Section 8.14.15]. staff_log holds the newest 200 entries, oldest first, for the admin and grantees, and is empty for other members; action is "approve", "decline" or "remove", and seq the group's last seq when it happened. A mediator from before DCTRL-0006 version 0.4 omits these four fields, and clients read them as empty.

admin_from_seqs has one entry per link of admin_chain: the group's last seq when that link took effect, or null where it was not recorded (groups created before DCTRL-0006 version 0.3 read as [0, null, …]) [DCTRL-0006 Section 4.6]. history is the policy copied from the genesis link at creation; it is informational, and clients take the policy from the verified genesis link [DCTRL-0006 Section 4.5].

8.14.8 POST_GROUP_EVENT

Admin or member. Appends an event to the group.

Payload:

{
  "type": "POST_GROUP_EVENT",
  "key_version": 4,
  "payload": "<base64 AES-GCM ciphertext of the sender's group envelope>",
  "dedupe_key": "<base64 keyed hash of the event ID>",
  "tags": ["<base64 HMAC-SHA256 under the version's group tag key>"]
}

key_version is an integer ≥ 1. payload is 1 to 65,536 characters. dedupe_key is 1 to 128 characters [DCTRL-0006 Section 8.3]. tags is OPTIONAL: at most 32 distinct values, each 44 base64 characters (a 32-byte HMAC): one per prefix of the event's plaintext tags [DCTRL-0006 Section 8.7]. A mediator implementing DCTRL-0006 before version 0.4 allows only 8; a duplicate or malformed value fails schema validation (INVALID_COMMAND). The mediator does not parse payload, stores tags as opaque values, and SHOULD NOT log them.

Behavior:

  1. If the sender's dedupe_key in this group belongs to a deletion notice [Section 8.14.11], return INVALID_COMMAND
  2. If the sender already posted this dedupe_key to this group, return that event's seq with duplicate: true and store nothing; add deleted: true if that event has since been deleted. This check comes before the version check, so a retry after a lost reply succeeds even after a rotation
  3. If key_version is not the group's current version, return STALE_GROUP_KEY
  4. Store the event with the next seq, and its tags

Uniqueness of (group, sender_did, dedupe_key) MUST be enforced by a unique constraint.

Response:

{
  "type": "SUCCESS",
  "seq": 42,
  "duplicate": false
}

A duplicate of a deleted event answers { "type": "SUCCESS", "seq": 42, "duplicate": true, "deleted": true }. The sender then stores no own copy [DCTRL-0006 Section 8.4].

8.14.9 QUERY_GROUP_EVENTS

Admin or member. Reads events in the group's order, forward from a cursor or backward from the newest, optionally only those carrying some tags.

Payload:

{
  "type": "QUERY_GROUP_EVENTS",
  "after_seq": 41,
  "limit": 100,
  "direction": "forward",
  "before_seq": 90,
  "tags": ["<base64 group tag value>"]
}

limit is 1 to 100. after_seq is an integer ≥ 0, default 0. direction is "forward" (default) or "backward". before_seq is OPTIONAL, an integer ≥ 1, used by backward reads only. tags is OPTIONAL: 1 to 64 distinct values of the same form as in POST_GROUP_EVENT.

Behavior: the sender's floor is its joined_after_seq in a from_join group and 0 in a full group [DCTRL-0006 Section 7.12].

  • forward returns events with seq greater than both after_seq and the floor, in ascending seq order.
  • backward returns events with seq greater than the floor and, if before_seq is given, less than before_seq, in descending seq order. after_seq is ignored.
  • With tags, only events carrying at least one of the values are returned.
  • Deleted events are never returned, nor deletion notices whose target_seq is at or below the floor.

At most limit events are returned; has_more is true when more such events exist. A mediator that numbers all its groups' events from one sequence MUST look events up by (group, seq).

Response:

{
  "type": "SUCCESS",
  "events": [
    {
      "seq": 42,
      "sender_did": "<DID>",
      "key_version": 4,
      "payload": "<base64 ciphertext>",
      "created_at": 1699123456000
    },
    {
      "seq": 43,
      "sender_did": "<DID>",
      "key_version": 4,
      "payload": "<base64 ciphertext of the deletion notice>",
      "created_at": 1699123457000,
      "kind": "deletion",
      "target_seq": 40
    }
  ],
  "has_more": false
}

created_at is the mediator's storage time in milliseconds. Only deletion notices carry kind ("deletion") and target_seq; ordinary events have exactly the fields of the first entry.

8.14.10 DELETE_GROUP

Admin only.

Payload:

{ "type": "DELETE_GROUP" }

Behavior (one transaction): deletes every member, every event and every event tag of the group, its grant log, delete scopes, join requests, key parcels and staff log, clears the pending link, and marks the record deleted. The record is kept as a tombstone: every later GROUP command is refused with GROUP_DELETED, and CREATE_GROUP with GROUP_EXISTS.

Response:

{ "type": "SUCCESS" }

8.14.11 DELETE_GROUP_EVENT

Admin or member. Deletes one event of the group and appends the deleter's signed notice [DCTRL-0006 Section 7.14].

Payload:

{
  "type": "DELETE_GROUP_EVENT",
  "target_seq": 42,
  "key_version": 4,
  "payload": "<base64 AES-GCM ciphertext of the deleter's notice envelope>",
  "dedupe_key": "<base64 keyed hash of the target>",
  "tags": ["<base64 group tag value>"]
}

target_seq is an integer ≥ 1. key_version, payload, dedupe_key and tags have the same form and limits as in POST_GROUP_EVENT [Section 8.14.8]. payload is a group envelope of a decentrl.group.deletion event [DCTRL-0006 Section 8.6]; the mediator does not parse it.

Errors, checked in this order:

ConditionError
The sender's dedupe_key in this group belongs to an ordinary eventINVALID_COMMAND
No event of this group has seq = target_seq, or it is at or below the sender's floor and the sender is not the adminEVENT_NOT_FOUND
The target is a deletion noticeNOT_DELETABLE
The target was already deletedEVENT_ALREADY_DELETED
The sender is neither the target's sender nor the admin, and its grant holds no deleteNOT_EVENT_AUTHOR
The sender is neither the target's sender nor the admin, and the target's sender is staff: the admin, a grantee, or the member a pending handover namesTARGET_IS_STAFF
The sender is neither the target's sender nor the admin, its delete is scoped, and none of the target's tag values is among its scope values for the target's key version (or it has no scope row for that version)NOT_PERMITTED
key_version is not the group's current versionSTALE_GROUP_KEY

The rules are those of DCTRL-0006 Section 7.14.

If the sender's dedupe_key belongs to one of its deletion notices, the mediator returns that notice's seq with duplicate: true and changes nothing; this check comes before the others.

Behavior (one transaction): empties the target's payload and marks it deleted, keeping its row; removes the target's tags; appends the notice with the next seq, as a row of kind deletion with target_seq and the given tags. target_seq MUST be unique over all notices, enforced by a unique constraint, so an event is deleted at most once.

Response:

{
  "type": "SUCCESS",
  "seq": 44,
  "duplicate": false
}

seq is the notice's.

8.14.12 SET_GROUP_GRANT

Admin only. Grants a member capabilities, changes them, or, with none, revokes them [DCTRL-0006 Section 7.15].

Payload:

{
  "type": "SET_GROUP_GRANT",
  "grant": {
    "type": "decentrl.group.grant",
    "group_did": "<group DID>",
    "grantee_did": "<member DID>",
    "admin_epoch": 2,
    "not_before_seq": 118,
    "capabilities": { "delete": "scoped", "remove": true, "approve": true },
    "signature": "<base64 Ed25519 signature by the current admin>"
  }
}

capabilities holds any of delete ("all" or "scoped"), remove (true) and approve (true), and nothing else; {} revokes [DCTRL-0006 Section 4.7].

Errors:

ConditionError
grant is not signed by the current admin for this group and the current admin_epoch, or names the admin or the groupINVALID_GRANT
grant.grantee_did is not a memberGRANTEE_NOT_MEMBER
grant.not_before_seq is above the group's last seq, or below the from_seq of the grantee's latest ended entryINVALID_GRANT

Behavior (one transaction): appends the grant to the grant log with from_seq = the group's last seq; drops the grantee's delete scopes (the admin writes a scoped grantee's rows again).

Response:

{ "type": "SUCCESS", "from_seq": 118 }

8.14.13 SET_GROUP_GRANT_SCOPE

Admin only. Sets a scoped grantee's delete scope under one key version [DCTRL-0006 Section 4.8].

Payload:

{
  "type": "SET_GROUP_GRANT_SCOPE",
  "scope": {
    "grantee_did": "<member DID>",
    "key_version": 4,
    "values": ["<base64 group tag value>"],
    "sealed_prefixes": "<base64 AES-GCM ciphertext>"
  }
}

key_version is an integer ≥ 1. values holds at most 64 values of the same form as POST_GROUP_EVENT's tags.

Errors:

ConditionError
The grantee's current grant does not hold delete: "scoped"INVALID_GRANT
key_version is above the group's current versionKEY_VERSION_CONFLICT

Behavior: creates or replaces the scope row for (grantee_did, key_version).

Response:

{ "type": "SUCCESS" }

8.14.14 REMOVE_GROUP_MEMBER

The admin, or a member granted remove. Takes a member out at once; the key must then rotate [DCTRL-0006 Section 7.17].

Payload:

{ "type": "REMOVE_GROUP_MEMBER", "member_did": "<DID>" }

Errors, checked in this order:

ConditionError
member_did is the senderINVALID_COMMAND
member_did is the adminCANNOT_REMOVE_ADMIN
member_did is not a memberNOT_MEMBER
The sender is not the admin and member_did is staffTARGET_IS_STAFF

Behavior (one transaction): as LEAVE_GROUP for member_did [Section 8.14.4], with an ended entry of reason removed; appends a remove entry to the staff log.

Response:

{ "type": "SUCCESS" }

8.14.15 SET_GROUP_JOIN_KEY

Admin only. Publishes the join key of the current key version [DCTRL-0006 Section 6.6].

Payload:

{
  "type": "SET_GROUP_JOIN_KEY",
  "statement": {
    "type": "decentrl.group.join-key",
    "group_did": "<group DID>",
    "key_version": 5,
    "public_key": "<base64 X25519 public key>",
    "signature": "<base64 Ed25519 signature>"
  }
}

Errors:

ConditionError
statement.key_version is not the group's current versionKEY_VERSION_CONFLICT
statement is not for this group, or not signed by the admin whose tenure covers its key_versionINVALID_COMMAND

Behavior: replaces the stored statement.

Response:

{ "type": "SUCCESS" }

8.14.16 QUERY_GROUP_JOIN_INFO

Open to anyone but the group. Read-only.

Payload:

{ "type": "QUERY_GROUP_JOIN_INFO" }

Response:

{
  "type": "SUCCESS",
  "join_key": { <join key statement> } | null,
  "admin_chain": [ { <admin link> } ],
  "history": "from_join",
  "member_count": 42,
  "my_request": "pending",
  "is_member": false
}

my_request is "pending" if the sender has a join request that has not lapsed, else "none". is_member is true for members and the admin.

8.14.17 REQUEST_GROUP_JOIN

Open to anyone but the group. Counts toward the group-event rate limit [Section 5.5].

Payload:

{
  "type": "REQUEST_GROUP_JOIN",
  "sealed_note": "<base64 ephemeral X25519 key>.<base64 AES-GCM ciphertext>",
  "note_key_version": 5
}

sealed_note (at most 4096 characters) and note_key_version (an integer ≥ 1) are OPTIONAL [DCTRL-0006 Section 6.6].

Errors, checked in this order:

ConditionError
The sender is a member or the adminALREADY_A_MEMBER
The sender has a join request that has not lapsedJOIN_REQUEST_EXISTS
The group has 1000 membersGROUP_FULL
The group holds 1000 join requests that have not lapsedJOIN_QUEUE_FULL
sealed_note is present and no join key is published, note_key_version is absent, or it is above the published key's version; or note_key_version is present without sealed_noteINVALID_COMMAND

Behavior: stores the request with the current time, replacing a lapsed request of the sender's. A request lapses 30 days after it is stored.

Response:

{ "type": "SUCCESS" }

8.14.18 CANCEL_GROUP_JOIN

Open to anyone but the group. Deletes the sender's join request, lapsed or not.

Payload:

{ "type": "CANCEL_GROUP_JOIN" }

Errors: NO_JOIN_REQUEST if the group holds no request from the sender.

Response:

{ "type": "SUCCESS" }

8.14.19 QUERY_GROUP_JOIN_REQUESTS

The admin, or a member granted approve. Deletes lapsed requests, then returns the rest, oldest first (a request renewed after lapsing counts from its renewal).

Payload:

{ "type": "QUERY_GROUP_JOIN_REQUESTS" }

Response:

{
  "type": "SUCCESS",
  "requests": [
    {
      "requester_did": "<DID>",
      "sealed_note": "<sealed note>" | null,
      "note_key_version": 5 | null,
      "created_at": "2026-09-23T10:00:00.000Z"
    }
  ]
}

created_at is an ISO 8601 string.

8.14.20 DECLINE_GROUP_JOIN

The admin, or a member granted approve.

Payload:

{ "type": "DECLINE_GROUP_JOIN", "requester_did": "<DID>" }

Errors: NO_JOIN_REQUEST if the group holds no request from requester_did.

Behavior (one transaction): deletes the request; appends a decline entry to the staff log.

Response:

{ "type": "SUCCESS" }

8.14.21 APPROVE_GROUP_JOIN

The admin, or a member granted approve. Lets a requester in with its key parcels [DCTRL-0006 Section 7.22].

Payload:

{
  "type": "APPROVE_GROUP_JOIN",
  "requester_did": "<DID>",
  "key_version": 5,
  "parcels": ["<base64 ephemeral X25519 key>.<base64 AES-GCM ciphertext>"]
}

key_version is an integer ≥ 1. parcels holds 1 to 20 parcels, each 1 to 262,144 characters [DCTRL-0006 Section 5.7].

Errors, checked in this order:

ConditionError
requester_did is a memberALREADY_A_MEMBER
No request from requester_did that has not lapsedNO_JOIN_REQUEST
key_version is not the group's current versionSTALE_GROUP_KEY
The group has 1000 membersGROUP_FULL

A due rotation does not block an approval.

Behavior (one transaction): adds requester_did as a member with joined_after_seq = the group's last seq; stores the parcels for it with the sender as from_did; deletes the request; appends an approve entry to the staff log.

Response:

{ "type": "SUCCESS" }

8.14.22 DELIVER_GROUP_KEY_PARCELS

Admin only. Leaves key parcels for a member [DCTRL-0006 Section 7.23].

Payload:

{
  "type": "DELIVER_GROUP_KEY_PARCELS",
  "member_did": "<DID>",
  "parcels": ["<base64 ephemeral X25519 key>.<base64 AES-GCM ciphertext>"]
}

parcels holds 1 to 20 parcels, each 1 to 262,144 characters.

Errors:

ConditionError
member_did is not a memberNOT_MEMBER
The member would have more than 200 parcels waitingTOO_MANY_PARCELS

Behavior: stores the parcels for member_did, with the sender as from_did.

Response:

{ "type": "SUCCESS" }

8.14.23 QUERY_GROUP_KEY_PARCELS

Admin or member. Read-only. Returns the parcels waiting for the sender in this group, in the order they were stored, parcels stored by one command included. id is an integer, assigned by the mediator.

Payload:

{ "type": "QUERY_GROUP_KEY_PARCELS" }

Response:

{
  "type": "SUCCESS",
  "parcels": [
    {
      "id": 311,
      "from_did": "<DID of the approver or admin that left it>",
      "payload": "<sealed parcel>",
      "created_at": "2026-09-23T10:00:00.000Z"
    }
  ]
}

8.14.24 ACK_GROUP_KEY_PARCELS

Admin or member. Deletes parcels the sender has applied.

Payload:

{ "type": "ACK_GROUP_KEY_PARCELS", "ids": [311, 312] }

ids holds 1 to 200 parcel IDs, as QUERY_GROUP_KEY_PARCELS returned them. IDs of parcels that do not wait for the sender in this group are ignored.

Response:

{ "type": "SUCCESS" }

8.14.25 QUERY_GROUP_COUNTS

Admin or member. Read-only. Counts the group's live events for each query [DCTRL-0006 Section 7.24]: deleted events, deletion notices and events at or below the sender's floor never count.

Payload:

{
  "type": "QUERY_GROUP_COUNTS",
  "queries": [
    { "id": "section", "tags": ["<tag value under v1>", "<tag value under v2>"] },
    { "id": "posts-by-bob", "tags": ["<tag value>"], "sender_did": "<bob's DID>" },
    { "id": "since", "after_seq": 4211 }
  ]
}

queries holds 1 to 32 counts with unique ids of 1 to 64 characters. tags (1 to 64 values, as in QUERY_GROUP_EVENTS), sender_did and after_seq are each OPTIONAL; an event matches a count when it carries any of its tags and meets the other filters.

Response:

{ "type": "SUCCESS", "counts": { "section": 57, "posts-by-bob": 12, "since": 3 } }

8.14.26 QUERY_GROUP_PRESENCE

Admin or member. Read-only. The members holding a live group subscription [Section 10] on this mediator, in membership order.

Payload:

{ "type": "QUERY_GROUP_PRESENCE" }

Response:

{ "type": "SUCCESS", "online": ["<member DID>", "<member DID>"] }

8.15 Data Retention

Acknowledged pending events and contract requests are deleted immediately [Sections 8.4, 8.9]. In addition, the mediator SHOULD run a cleanup job (recommended interval: 10 minutes) that deletes:

RecordDeleted when
Command and WebSocket noncesPast their expiration [Section 5.2]
Pending eventsOlder than 30 days (never acknowledged)
Pending contract requestsOlder than 30 days (never acknowledged)

Communication contracts do not expire and are not subject to cleanup: a contract is removed only when a newer one for the same pair replaces it [Section 8.5] or its owner revokes it [Section 8.7]. Revocation records and stored events (SAVE_EVENTS) are not subject to cleanup either.

Group records, members, group events and their tags are not subject to cleanup. Members are removed only by membership changes, leaves, removals and DELETE_GROUP, and events and tags only by DELETE_GROUP [Section 8.14]; a deleted group's record is kept. Grant log entries, delete scopes and staff log entries are kept until DELETE_GROUP; scopes also go when their grant ends or stops being scoped. Key parcels are deleted when acknowledged, when their member leaves or is removed, or by DELETE_GROUP. Join requests are deleted when cancelled, declined, approved, or answered by an addition; lapsed requests are deleted when approvers list them [Section 8.14.19] or their requester asks again. DELETE_GROUP_EVENT keeps the deleted event's row with an empty payload and removes its tags; the deletion notice is kept [Section 8.14.11].


9. Pagination

9.1 Request Format

All query commands that return lists MUST support pagination, except the GROUP channel's: QUERY_GROUP returns the whole member list (at most 1000), and QUERY_GROUP_EVENTS pages by seq cursor, forward with after_seq or backward with before_seq [Section 8.14.9].

Page-based pagination uses:

{
  "pagination": {
    "page": 0,
    "page_size": 10
  }
}
FieldTypeDefaultDescription
pagenumber0Zero-based page index
page_sizenumber10Number of records per page

The server computes: offset = page * page_size.

9.2 Response Format

{
  "pagination": {
    "page": 0,
    "page_size": 10,
    "total": 42
  }
}
FieldTypeDescription
pagenumberThe requested page index
page_sizenumberThe requested page size
totalnumberTotal number of records matching the filter

Clients determine if more pages exist: has_more = (page + 1) * page_size < total.

9.3 Ordering

Offset pagination is only stable over a deterministic order. Servers MUST order results as follows, breaking ties by server-generated id in the same direction:

CommandOrderRationale
QUERY_EVENTStimestamp ascendingClients page forward to replay history
QUERY_PENDING_EVENTSArrival (creation) ascendingDelivery in the order the mediator received events
QUERY_PENDING_COMMUNICATION_CONTRACT_REQUESTSArrival (creation) ascendingSame as pending events
QUERY_COMMUNICATION_CONTRACTSCreation descendingThe most recently stored contracts appear on the first page
GET /public/:didtimestamp descendingFeeds show the latest events first
QUERY_GROUP_EVENTSseq ascending, or descending for backward reads (cursor, not offset)Every member reads the group in the same order

9.4 Paging Acknowledgeable Queues

QUERY_PENDING_EVENTS and QUERY_PENDING_COMMUNICATION_CONTRACT_REQUESTS return only unacknowledged records, so acknowledging records removes them from the result set and shifts every later offset. Clients MUST NOT acknowledge between page requests of the same read: either read all pages first and acknowledge afterwards, or restart from page 0 after acknowledging.

Records a client cannot process yet (for example, an event encrypted under a contract it has not refreshed) stay unacknowledged and remain at the head of the queue. Clients MUST keep reading past them rather than stopping at the first page.


10. WebSocket Protocol

10.1 Connection

Clients connect to the mediator's WebSocket endpoint at GET /ws. After the HTTP upgrade, the client MUST authenticate within 10 seconds.

Clients only send logins, PONGs and group subscriptions, so the mediator closes any connection that sends a message longer than 16,384 characters (close code 1009).

A connection is one of two kinds, decided at authentication:

  • Registered: the identity is registered with this mediator. It receives its own pushes [Section 10.5] and may also subscribe to groups [Section 10.8].
  • Groups-only: a did:decentrl identity registered elsewhere, typically a member of a group this mediator hosts [Section 10.7]. It may only subscribe to groups.

10.2 Authentication

Client → Server:

{
  "type": "AUTHENTICATE",
  "did": "<DID>",
  "signing_key_id": "<DID>#signing",
  "timestamp": 1699123456000,
  "nonce": "<UUID>",
  "mediator_did": "<this mediator's DID>",
  "signature": "<base64 Ed25519 signature>"
}

The signature is computed over canonical JSON of { did, signing_key_id, timestamp, nonce, mediator_did } using the identity's signing private key [DCTRL-0002]. mediator_did binds the login to one mediator, so it cannot be replayed at another. It is REQUIRED for a groups-only connection and OPTIONAL for a registered identity; clients SHOULD always send it. A login without mediator_did is signed over { did, signing_key_id, timestamp, nonce }, as before it existed.

Server validation:

  1. Verify the message was received within 10 seconds of connection. Error: AUTH_TIMEOUT (close 4001)
  2. Validate message format. Error: INVALID_MESSAGE (close 4002)
  3. Verify timestamp drift is within TIMESTAMP_WINDOW_MS [Section 5.1]. Error: TIMESTAMP_OUT_OF_RANGE (close 4003)
  4. If mediator_did is present, verify that it is this mediator's DID. Error: WRONG_MEDIATOR (close 4009)
  5. Resolve the DID. Error: DID_NOT_FOUND (close 4004)
  6. Extract signing key from DID document. Error: SIGNING_KEY_NOT_FOUND (close 4005)
  7. Verify signature. Error: INVALID_SIGNATURE (close 4006)
  8. Record the nonce, deduplicated as in [Section 5.2] but in a namespace separate from command nonces. Error: DUPLICATE_NONCE (close 4008)
  9. If the identity is registered, the connection is registered: done.
  10. Otherwise, the connection can only be groups-only [Section 10.7]:
    • the DID must be a did:decentrl. Error: NOT_REGISTERED (close 4007)
    • mediator_did must be present. Error: MEDIATOR_DID_REQUIRED (close 4009)
    • a groups-only connection slot must be free. Error: TOO_MANY_CONNECTIONS (close 4029)

Server → Client (success):

{ "type": "AUTH_SUCCESS" }

A groups-only connection gets { "type": "AUTH_SUCCESS", "groups_only": true }.

Server → Client (failure):

{ "type": "AUTH_FAILED", "code": "<error code>" }

The server then closes the WebSocket with the corresponding close code.

10.3 Close Codes

CodeErrorDescription
1009—A client message longer than 16,384 characters
4001AUTH_TIMEOUTNo auth message within 10 seconds
4002INVALID_MESSAGEMalformed auth message
4003TIMESTAMP_OUT_OF_RANGETimestamp drift exceeds TIMESTAMP_WINDOW_MS (default 5 minutes)
4004DID_NOT_FOUNDCannot resolve DID
4005SIGNING_KEY_NOT_FOUNDKey ID not in DID document
4006INVALID_SIGNATURESignature verification failed
4007NOT_REGISTEREDNo registration contract with mediator, and not a did:decentrl that could connect groups-only
4008DUPLICATE_NONCEReplayed AUTHENTICATE message (nonce already used)
4009WRONG_MEDIATORmediator_did names another mediator
4009MEDIATOR_DID_REQUIREDA groups-only login without mediator_did
4010—No group subscriptions: a groups-only connection that subscribed to nothing within 10 seconds [Section 10.7]
4011—Group subscriptions ended: a groups-only connection whose last subscription was dropped [Section 10.9]
4029TOO_MANY_CONNECTIONSNo groups-only connection slot is free [Section 10.7]

10.4 Keepalive

After successful authentication, the server sends a PING message every 30 seconds:

Server → Client:

{ "type": "PING", "timestamp": 1699123456000 }

Client → Server:

{ "type": "PONG", "timestamp": 1699123456000 }

10.5 Push Notifications

After successful authentication, the server pushes notifications for events relevant to the authenticated DID. A groups-only connection never receives PENDING_EVENTS or CONTRACTS_UPDATED, only the group hints of its subscriptions [Section 10.9].

PENDING_EVENTS — pushed when a TWO_WAY_PRIVATE event arrives for this identity:

{
  "type": "PENDING_EVENTS",
  "events": [
    {
      "id": "<pending event ID>",
      "sender_did": "<DID>",
      "payload": "<opaque encrypted string>"
    }
  ]
}

CONTRACTS_UPDATED — pushed when a contract involving this identity is created, accepted, or saved:

{ "type": "CONTRACTS_UPDATED" }

This is a signal to refresh contracts via QUERY_COMMUNICATION_CONTRACTS. No contract data is included.

10.6 Notification Triggers

CommandNotificationRecipient
TWO_WAY_PRIVATE (success)PENDING_EVENTSThe event recipient
REQUEST_COMMUNICATION_CONTRACT (success, non-mediator)CONTRACTS_UPDATEDThe contract recipient
COMMUNICATION_CONTRACT_RESPONSE (success)CONTRACTS_UPDATEDThe other contract participant
SAVE_COMMUNICATION_CONTRACT (success)CONTRACTS_UPDATEDThe command sender
POST_GROUP_EVENT, DELETE_GROUP_EVENT (success, not a duplicate)GROUP_EVENTSThe group's subscribed members [Section 10.9]
UPDATE_GROUP_MEMBERS, LEAVE_GROUP, HAND_OVER_GROUP, ACCEPT_GROUP_ADMIN, DELETE_GROUP, SET_GROUP_GRANT, REMOVE_GROUP_MEMBER, APPROVE_GROUP_JOIN (success)GROUP_UPDATEDThe group's subscribed members, and those the command removed [Section 10.9]
REQUEST_GROUP_JOIN, CANCEL_GROUP_JOIN, DECLINE_GROUP_JOIN (success)GROUP_UPDATEDThe group's subscribed approvers only: the admin and members granted approve
DELIVER_GROUP_KEY_PARCELS (success)GROUP_UPDATEDThe parcels' recipient only, if subscribed

Query commands (QUERY_GROUP, QUERY_GROUP_EVENTS, QUERY_GROUP_JOIN_INFO, QUERY_GROUP_JOIN_REQUESTS, QUERY_GROUP_KEY_PARCELS, QUERY_GROUP_COUNTS, QUERY_GROUP_PRESENCE), SET_GROUP_JOIN_KEY, ACK_GROUP_KEY_PARCELS and refused commands trigger nothing. SET_GROUP_GRANT_SCOPE sends GROUP_UPDATED to its grantee only. CREATE_GROUP triggers nothing: nobody can be subscribed to a group before it exists.

10.7 Groups-Only Connections

Members of a group may be registered on any mediator [DCTRL-0006 Section 7.2], so a group's mediator also accepts WebSocket connections from did:decentrl identities registered elsewhere, for group hints only. Such a connection:

  • MUST name this mediator in its signed mediator_did [Section 10.2];
  • may only send SUBSCRIBE_GROUPS and PONG, and never receives PENDING_EVENTS or CONTRACTS_UPDATED;
  • is closed with 4010 (No group subscriptions) if it holds no subscription 10 seconds after authenticating, and with 4011 (Group subscriptions ended) as soon as its last subscription is dropped because its DID was removed, left, or the group was deleted [Section 10.9].

The mediator caps groups-only connections at 20 per IP address and 5,000 in total. A login over the cap is refused with TOO_MANY_CONNECTIONS (close 4029); a closed connection frees its slot. Registered connections are not counted. A connection that closes while its login or a subscription is still being checked gets nothing registered: it takes no slot and holds no subscription. A connection handles one login at a time: messages that arrive while its login is being checked are ignored. Its subscriptions are handled in the order they arrive.

10.8 Subscribing to Groups

Any authenticated connection may subscribe to groups this mediator hosts.

Client → Server:

{
  "type": "SUBSCRIBE_GROUPS",
  "group_dids": ["<group DID>"]
}

group_dids holds 1 to 50 DIDs (each at most 512 characters). A message that breaks these rules, or is otherwise malformed, is answered with { "type": "GROUPS_SUBSCRIBED", "group_dids": [], "refused": [], "last_seqs": {}, "code": "INVALID_MESSAGE" } and changes nothing.

Server → Client:

{
  "type": "GROUPS_SUBSCRIBED",
  "group_dids": ["<accepted group DID>"],
  "refused": ["<refused group DID>"],
  "last_seqs": { "<accepted group DID>": 57 }
}

The mediator accepts a group only if the connection's DID is currently a member of it (the admin is a member). A group that does not exist, a deleted group (it has no members), a group the DID does not belong to, and the group identity itself are all refused alike. Subscriptions add up across messages, up to 500 groups per connection. New groups are accepted only up to the room left, in the order given, and the rest is refused. A group the connection already follows is accepted again and uses no room.

last_seqs has an entry for each accepted group only: the highest seq of any of its events when subscribed, or 0. A client whose cursor is below it has something to read [DCTRL-0006 Section 9.2].

Subscribing is rate-limited to 30 SUBSCRIBE_GROUPS per DID per minute. Over the limit, every requested group is refused, and the reply carries "code": "RATE_LIMITED". code is present only on these two replies: RATE_LIMITED or INVALID_MESSAGE.

Subscriptions live on the connection. They are not persisted, and a client subscribes again after reconnecting.

10.9 Group Hints

GROUP_EVENTS — a group has a new event or deletion notice at seq:

{ "type": "GROUP_EVENTS", "group_did": "<group DID>", "seq": 58 }

GROUP_UPDATED — the group's membership, key version, admin or grants changed, it was deleted, a join request changed (approvers only), or parcels were left for the recipient (the recipient only):

{ "type": "GROUP_UPDATED", "group_did": "<group DID>" }

Hints are sent after the command commits [Section 10.6]. Delivery rules:

  • Membership is checked when the hint is sent, not when the command ran. GROUP_EVENTS reaches only subscribers who are members at that moment.
  • GROUP_UPDATED also reaches, once, the subscribed DIDs the command removed: those in UPDATE_GROUP_MEMBERS's remove, the sender of LEAVE_GROUP, the member_did of REMOVE_GROUP_MEMBER, and every subscriber of a group DELETE_GROUP deleted.
  • A hint of REQUEST_GROUP_JOIN, CANCEL_GROUP_JOIN or DECLINE_GROUP_JOIN reaches only subscribed members who are approvers when it is sent. A hint of DELIVER_GROUP_KEY_PARCELS reaches only its member_did. Requesters are not members, cannot subscribe, and receive nothing: they poll QUERY_GROUP_JOIN_INFO [Section 8.14.16].
  • After each hint, the subscriptions of every DID that is no longer a member are dropped. A groups-only connection left with none is closed with 4011 [Section 10.7]. Subscribing again is refused.

Hints are only hints. They carry no content and are not acknowledged; a connection that was down misses them. A client MUST read from its own cursor with QUERY_GROUP_EVENTS [Section 8.14.9], never treat a hint's seq as a cursor, and SHOULD keep polling as a fallback.

Subscriptions are held by the mediator process that accepted the connection. A mediator running as several instances pushes a hint only to connections of the instance that ran the command.

11. Error Codes

11.1 Error Response Format

All error responses MUST use this format:

{
  "type": "ERROR",
  "code": "<ERROR_CODE>"
}

11.2 HTTP Status Mapping

Error CodeHTTP StatusDescription
INVALID_COMMAND400Request body fails schema validation (including a missing header version) or business logic
UNSUPPORTED_VERSION400Header version is not the protocol version the mediator implements
INVALID_SIGNATURE401Command signature verification failed
INVALID_SIGNATURES401One or both contract signatures failed verification
UNAUTHORIZED_COMMAND401Sender not registered or wrong recipient
TIMESTAMP_OUT_OF_RANGE401Timestamp drift exceeds window
DUPLICATE_NONCE401Nonce already processed for this sender
SENDER_NOT_FOUND404Sender's DID cannot be resolved
SENDER_SIGNING_KEY_NOT_FOUND404Key ID not in sender's DID document
RECIPIENT_NOT_FOUND404Recipient's DID cannot be resolved
RECIPIENT_NOT_REGISTERED404Recipient has no registration contract with mediator
COMMUNICATION_CONTRACT_NOT_FOUND404Recipient holds no contract with the sender (never made, or revoked; indistinguishable)
PENDING_REQUESTS_LIMIT429Recipient already holds the maximum number of unacknowledged contact requests from others (50, or 1000 for a group) [Section 8.1]
RATE_LIMIT_EXCEEDED429Sender exceeded a rate limit [Section 5.5]
INVALID_ADMIN_LINK400A group admin link fails verification [Sections 8.13, 8.14.5]
NOT_A_GROUP_MEMBER403Sender is neither the group's admin nor a member
NOT_GROUP_ADMIN403A member sent an admin-only group command
NOT_EVENT_AUTHOR403DELETE_GROUP_EVENT from a member that is not the target's sender and holds no delete grant [Section 8.14.11]
NOT_PERMITTED403A member without the needed capability sent a remove or approve command, or a scoped grantee's deletion falls outside its scope [Sections 8.14.2, 8.14.11]
TARGET_IS_STAFF403A grantee tried to delete a staff member's event or remove a staff member [Sections 8.14.11, 8.14.14]
INVALID_GRANT400A grant is not the current admin's, or is from the future; or a scope is set for a grantee without a scoped delete [Sections 8.14.12, 8.14.13]
NOT_MEMBER404REMOVE_GROUP_MEMBER or DELIVER_GROUP_KEY_PARCELS names a non-member [Sections 8.14.14, 8.14.22]
NO_JOIN_REQUEST404No (live) join request to cancel, decline or approve [Sections 8.14.18, 8.14.20, 8.14.21]
GRANTEE_NOT_MEMBER409SET_GROUP_GRANT names a non-member [Section 8.14.12]
ALREADY_A_MEMBER409A join request from, or an approval of, a member [Sections 8.14.17, 8.14.21]
JOIN_REQUEST_EXISTS409The sender already has a live join request [Section 8.14.17]
JOIN_QUEUE_FULL429The group already holds 1000 live join requests [Section 8.14.17]
TOO_MANY_PARCELS429The member would have more than 200 parcels waiting [Section 8.14.22]
GROUP_NOT_FOUND404This mediator hosts no group with that DID
EVENT_NOT_FOUND404DELETE_GROUP_EVENT names no event of this group, or one below the sender's floor
GROUP_EXISTS409CREATE_GROUP for a group that exists or was deleted
GROUP_FULL409The group would exceed 1000 members, or has 1000 when a join is requested or approved
ADMIN_CANNOT_LEAVE409The admin sent LEAVE_GROUP
CANNOT_REMOVE_ADMIN409UPDATE_GROUP_MEMBERS or REMOVE_GROUP_MEMBER removes the admin
NEW_ADMIN_NOT_MEMBER409A handover link names a non-member
NOT_INVITED409The group owns no contract with a DID being added, or with the first admin
KEY_VERSION_CONFLICT409key_version is not current + 1, or is missing where a rotation is required; or a scope or join key names a version that is not allowed [Sections 8.14.13, 8.14.15]
STALE_GROUP_KEY409A group event posted, or a join approved, under a version other than the current one
STALE_HANDOVER409The handover link's version is no longer the group's current version
NO_PENDING_HANDOVER409ACCEPT_GROUP_ADMIN without a pending link naming the sender
EVENT_ALREADY_DELETED409DELETE_GROUP_EVENT names an event that was already deleted
NOT_DELETABLE409DELETE_GROUP_EVENT names a deletion notice
GROUP_DELETED410The group was deleted

11.3 Success Response

Successful responses use "type": "SUCCESS" with command-specific additional fields as documented in Section 8.


12. Security Considerations

12.1 Encryption Blindness

Mediators MUST NOT attempt to decrypt event payloads, contract requests, or any encrypted data. Mediators do not hold the key material necessary for decryption (root secrets and the epoch keys derived from them come from ephemeral keys held by the communicating parties; storage keys never leave client devices).

Encryption blindness covers content, not metadata. Signed communication contracts are stored and verified in the clear, so their terms are visible to the mediator [Section 12.5].

12.2 Replay Prevention

The combination of timestamp validation and nonce deduplication prevents replay attacks. The nonce store MUST be persistent across server restarts within the timestamp window to prevent replay during restarts. Nonces are recorded only for authenticated commands and kept until header.timestamp + TIMESTAMP_WINDOW_MS [Section 5.2].

WebSocket logins are protected the same way, with their own nonce namespace [Section 10.2]. A login that carries mediator_did is valid at that mediator only. A registered identity may still log in without it, for compatibility with clients that predate the field. Such a login, if captured, could be replayed within the timestamp window at another mediator where the same identity is also registered, and would receive that identity's pushes there: ciphertext and metadata, never content. Clients SHOULD always send mediator_did; a groups-only login requires it.

This protects commands, not the events inside them. Re-delivery of an event, whether a retry by its sender or a ciphertext re-queued by a mediator, is caught by its signed event ID [DCTRL-0005 Section 15.6] and by idempotent writes [Section 12.7].

12.3 Signature Verification

Mediators MUST verify Ed25519 signatures on every command before processing. The signature verification ensures command authenticity and integrity.

12.4 Contract Authorization

For TWO_WAY_PRIVATE events, mediators MUST verify that the recipient holds a communication contract with the sender. This prevents unauthorized message delivery and provides spam protection. Because a recipient can revoke at any time without telling the sender [Section 8.7], it can also stop unwanted traffic from a former contact.

12.5 Metadata Exposure

To route commands and to verify and enforce contracts, a mediator necessarily learns:

  • Participants: who holds a contract with whom and who sends to whom — the social graph of its registered identities
  • Timing and sizes of commands and events
  • Contract terms: participant DIDs, ephemeral public keys, timestamps, and signatures
  • Contract and epoch per event: the contract_id and epoch in each private payload [DCTRL-0005 Section 4]; since the mediator already knows the pair, this adds which contract and which day's key the event uses
  • Revocations: the revoker's own mediator knows whom its user revoked and when
  • Tag equality: which stored events share an encrypted tag [DCTRL-0002 Section 11]
  • Repeated saves: which SAVE_EVENTS entries repeat an event the owner already stores (equal dedupe_key), but not the event ID
  • Groups it hosts: each group's admin chain and handovers, its history policy, its members and their changes, who posts and deletes when and how much, each event's key version, when the key rotates, which events share a group tag or a tag prefix, which tags each member reads, its grants and staff actions, who asks to join and who let whom in, and who leaves key parcels for whom [DCTRL-0006 Section 15.8]
  • Presence: which identities are connected over WebSocket, from which IP address, and which groups they follow [Section 10.8]

A mediator never learns event contents, root secrets, chain or epoch keys, group keys, storage keys, or tag plaintext.

Hiding the social graph from the mediator would require a sealed-sender design, which is out of scope for this specification.

12.6 Server-Generated IDs

All record IDs (pending events, contracts, etc.) are opaque strings generated by the mediator. Clients MUST NOT assume any structure or predictability in these IDs. Key parcel IDs are the exception: integers that follow storage order [Section 8.14.23]; clients still use them only to acknowledge.

They are distinct from event IDs, which publishers choose and sign [DCTRL-0005 Section 3.2]. A mediator never sees the event ID of a private event; it sees the event ID of a public event, which is plaintext [DCTRL-0007 Section 5.1].

12.7 Idempotent Writes

Every write that stores an event is idempotent, so a client can retry after a lost reply without creating a second record:

CommandUnique onA repeat
SAVE_EVENTS [Section 8.10](owner_did, dedupe_key)DUPLICATE; the stored event is unchanged
PUBLISH_PUBLIC_EVENT [DCTRL-0007 Section 5.1](owner_did, event_id)SUCCESS with the existing public_event_id and duplicate: true; the first post wins
POST_GROUP_EVENT [Section 8.14.8](group, sender_did, dedupe_key)SUCCESS with the existing seq and duplicate: true, even if the key version has changed since; deleted: true if the event was deleted since
DELETE_GROUP_EVENT [Section 8.14.11](group, sender_did, dedupe_key), and target_seqSUCCESS with the notice's seq and duplicate: true; another sender deleting the same event gets EVENT_ALREADY_DELETED

GET /public/:did returns each post's event_id [DCTRL-0007 Section 6.3].

Mediators MUST enforce each with a unique constraint in the store, not a read-then-write check, so that concurrent repeats store exactly one record.

Upgrading an existing store. Records written before event IDs have no dedupe_key or event_id (null), and nulls never conflict under the unique constraints, so they stay as they are. With the reference mediator, the new unique indexes make prisma db push (which its container runs on start) refuse to apply them to an existing database until it is run once with --accept-data-loss. Nothing is actually lost: every existing row has a null key. Upgrading a mediator that hosts groups to DCTRL-0006 version 0.3 is described in DCTRL-0006 Section 14.3.


13. References

13.1 Normative References

  • [RFC 2119] Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997.
  • [RFC 6455] Fette, I. and A. Melnikov, "The WebSocket Protocol", RFC 6455, December 2011.
  • [DCTRL-0001] "The did:decentrl DID Method".
  • [DCTRL-0002] "Cryptographic Operations".
  • [DCTRL-0003] "Communication Contracts".

13.2 Informative References

  • [DCTRL-0005] "Event Encryption and Storage".
  • [DCTRL-0006] "Group Messaging".
  • [DCTRL-0007] "Public Channels".

On this page

AbstractStatus of This DocumentTable of Contents1. Introduction2. Terminology3. HTTP API3.1 Endpoints3.2 DID Document Endpoint3.3 Health Check3.4 CORS3.5 Cross-Mediator Routing4. Command Wire Format4.1 Universal Command Envelope4.2 Header Fields4.3 Payload4.4 Signature5. Replay Protection5.1 Timestamp Validation5.2 Nonce Deduplication5.3 Nonce Cleanup5.4 Processing Order5.5 Rate Limiting6. DIRECT_AUTHENTICATED Channel6.1 Purpose6.2 Authentication Flow7. TWO_WAY_PRIVATE Channel7.1 Purpose7.2 Authentication Flow7.3 Response8. Command Reference8.1 REQUEST_COMMUNICATION_CONTRACT8.2 COMMUNICATION_CONTRACT_RESPONSE8.3 QUERY_PENDING_COMMUNICATION_CONTRACT_REQUESTS8.4 ACKNOWLEDGE_PENDING_COMMUNICATION_CONTRACT_REQUESTS8.5 SAVE_COMMUNICATION_CONTRACT8.6 QUERY_COMMUNICATION_CONTRACTS8.7 REVOKE_COMMUNICATION_CONTRACT8.8 QUERY_PENDING_EVENTS8.9 ACKNOWLEDGE_PENDING_EVENTS8.10 SAVE_EVENTS8.11 QUERY_EVENTS8.12 UPDATE_EVENT_TAGS8.13 CREATE_GROUP8.14 GROUP Channel8.14.1 Addressing8.14.2 Processing Order8.14.3 UPDATE_GROUP_MEMBERS8.14.4 LEAVE_GROUP8.14.5 HAND_OVER_GROUP8.14.6 ACCEPT_GROUP_ADMIN8.14.7 QUERY_GROUP8.14.8 POST_GROUP_EVENT8.14.9 QUERY_GROUP_EVENTS8.14.10 DELETE_GROUP8.14.11 DELETE_GROUP_EVENT8.14.12 SET_GROUP_GRANT8.14.13 SET_GROUP_GRANT_SCOPE8.14.14 REMOVE_GROUP_MEMBER8.14.15 SET_GROUP_JOIN_KEY8.14.16 QUERY_GROUP_JOIN_INFO8.14.17 REQUEST_GROUP_JOIN8.14.18 CANCEL_GROUP_JOIN8.14.19 QUERY_GROUP_JOIN_REQUESTS8.14.20 DECLINE_GROUP_JOIN8.14.21 APPROVE_GROUP_JOIN8.14.22 DELIVER_GROUP_KEY_PARCELS8.14.23 QUERY_GROUP_KEY_PARCELS8.14.24 ACK_GROUP_KEY_PARCELS8.14.25 QUERY_GROUP_COUNTS8.14.26 QUERY_GROUP_PRESENCE8.15 Data Retention9. Pagination9.1 Request Format9.2 Response Format9.3 Ordering9.4 Paging Acknowledgeable Queues10. WebSocket Protocol10.1 Connection10.2 Authentication10.3 Close Codes10.4 Keepalive10.5 Push Notifications10.6 Notification Triggers10.7 Groups-Only Connections10.8 Subscribing to Groups10.9 Group Hints11. Error Codes11.1 Error Response Format11.2 HTTP Status Mapping11.3 Success Response12. Security Considerations12.1 Encryption Blindness12.2 Replay Prevention12.3 Signature Verification12.4 Contract Authorization12.5 Metadata Exposure12.6 Server-Generated IDs12.7 Idempotent Writes13. References13.1 Normative References13.2 Informative References