DCTRL-0004Mediator Protocol
HTTP API, command wire format, authentication, WebSocket real-time protocol, and error handling.
| Field | Value |
|---|---|
| RFC | DCTRL-0004 |
| Title | Mediator Protocol |
| Status | Draft |
| Created | 2026-03-14 |
| Version | 0.1 |
| Requires | DCTRL-0001, DCTRL-0002, DCTRL-0003 |
| Required By | DCTRL-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
- Introduction
- Terminology
- HTTP API
- Command Wire Format
- Replay Protection
- DIRECT_AUTHENTICATED Channel
- TWO_WAY_PRIVATE Channel
- Command Reference
- Pagination
- WebSocket Protocol
- Error Codes
- Security Considerations
- 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:
- Message routing: Accepting encrypted events from senders and making them available to recipients.
- 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:
| Method | Path | Content-Type | Description |
|---|---|---|---|
GET / | application/json | Returns the mediator's DID document | |
GET /.well-known/did.json | application/json | Returns the mediator's DID document (did:web resolution) | |
GET /health | application/json | Health check | |
POST / | application/json | All mediator commands | |
GET /public/:did | application/json | Public 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.
| Command | Sent to |
|---|---|
REQUEST_COMMUNICATION_CONTRACT (to an identity) | The recipient's mediator |
COMMUNICATION_CONTRACT_RESPONSE | The requestor's mediator |
TWO_WAY_PRIVATE events | The recipient's mediator |
GROUP commands | The group's mediator, named in the group's DID |
Registration, SAVE_COMMUNICATION_CONTRACT, ACKNOWLEDGE_*, QUERY_*, SAVE_EVENTS, UPDATE_EVENT_TAGS, CREATE_GROUP, public events, WebSocket | The sender's own mediator |
| WebSocket subscriptions to groups | The 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
| Field | Type | Required | Description |
|---|---|---|---|
version | integer | REQUIRED | Mediator protocol version (MEDIATOR_PROTOCOL_VERSION). MUST be 1 for this specification. |
channel | string | REQUIRED | "DIRECT_AUTHENTICATED", "TWO_WAY_PRIVATE", "ONE_WAY_PUBLIC" [DCTRL-0007], or "GROUP" [Section 8.14] |
sender_did | string | REQUIRED | DID of the command sender |
sender_signing_key_id | string | REQUIRED | Full key ID of the sender's signing key |
recipient_did | string | REQUIRED | DID of the intended recipient; for GROUP, the group's DID |
timestamp | number | REQUIRED | Unix timestamp in milliseconds (Date.now()) |
nonce | string | REQUIRED | UUID 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_RANGEThe 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_NONCEThe 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.
| Limit | Scope | Default | Configuration |
|---|---|---|---|
| All commands | Per sender DID, per minute | 600 | RATE_LIMIT_COMMANDS_PER_MINUTE |
PUBLISH_PUBLIC_EVENT | Per sender DID, per hour | 100 | RATE_LIMIT_PUBLIC_EVENTS_PER_HOUR [DCTRL-0007 Section 10] |
POST_GROUP_EVENT, DELETE_GROUP_EVENT and REQUEST_GROUP_JOIN | Per sender DID, per minute, across all groups, the three commands together | 120 | RATE_LIMIT_GROUP_EVENTS_PER_MINUTE [Sections 8.14.8, 8.14.11, 8.14.17] |
SUBSCRIBE_GROUPS (WebSocket) | Per authenticated DID, per minute | 30 | — [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):
-
Resolve the sender's DID to a DID document.
- If resolution fails → respond with
SENDER_NOT_FOUND
- If resolution fails → respond with
-
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
- If the key ID is not found → respond with
-
Verify the signature over canonical JSON of
{ header, payload }using the extracted public key.- If verification fails → respond with
INVALID_SIGNATURE
- If verification fails → respond with
-
Authorization check (depends on command type):
For
REQUEST_COMMUNICATION_CONTRACTandCOMMUNICATION_CONTRACT_RESPONSE:- If
recipient_didequals the mediator's own DID → skip recipient check - Otherwise → verify
recipient_didis registered with this mediator - If not registered → respond with
RECIPIENT_NOT_REGISTERED
For all other commands (including
CREATE_GROUP[Section 8.13]):- Verify
recipient_didequals the mediator's own DID - If not → respond with
UNAUTHORIZED_COMMAND - Verify
sender_didis registered with this mediator - If not → respond with
UNAUTHORIZED_COMMAND
- If
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):
- Resolve the sender's DID. Error:
SENDER_NOT_FOUND - Extract the signing public key. Error:
SENDER_SIGNING_KEY_NOT_FOUND - Verify the signature. Error:
INVALID_SIGNATURE - Resolve the recipient's DID. Error:
RECIPIENT_NOT_FOUND - Verify the recipient is registered with this mediator. Error:
RECIPIENT_NOT_REGISTERED - Verify a communication contract exists: Look up a
SignedCommunicationContractowned byrecipient_didwhere bothsender_didandrecipient_didappear 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 - 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):
- Decrypt the contract request using
X25519(mediator_prekey_private, requestor_ephemeral_public)[DCTRL-0003 Section 9] - Validate the decrypted request: it MUST be a well-formed contract request whose
requestor_didequals the command'ssender_did, whoserecipient_didequals the mediator's DID, and whosetimestampis at most 5 minutes in the future and at most 30 days in the past [DCTRL-0003 Section 5]. Otherwise (including when decryption fails) returnINVALID_COMMAND - Verify the requestor's signature; if it is invalid return
INVALID_SIGNATURES - Generate the mediator's ephemeral key pair
- Sign the contract as recipient
- Store the signed contract [Section 8.5]; a newer registration replaces the identity's earlier one. Registration does not expire
- Return the signed contract
If recipient_did is another identity:
- Verify the recipient is registered
- Unless the recipient already holds a contract with the sender here (an existing contact requesting again), refuse with
PENDING_REQUESTS_LIMITwhen 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 - 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:
- Verify the sender is a participant in the contract
- Verify the other participant is registered with this mediator
- Verify both signatures on the contract [DCTRL-0003 Section 8]
- 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:
- Verify both signatures [DCTRL-0003 Section 8]
- 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
timestampis 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]):
- Delete the sender's contracts with
participant_did, whichever of the two requested them - Delete pending events from
participant_didto the sender - Delete pending contract requests from
participant_didto the sender - Record a revocation for (sender,
participant_did) with the current time and the newest deleted contract'stimestampand 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 thisdedupe_keyexisted 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]):
- If a group record for
sender_didexists, deleted or not, returnGROUP_EXISTS - Verify
genesis_linkas link 0 of the group's admin chain [DCTRL-0006 Section 4.2]:group_didequalssender_did, epoch 0,key_version0,admin_didother than the group, signed by the group's signing key. Otherwise returnINVALID_ADMIN_LINK - Verify the group owns a stored contract with
admin_didon this mediator. Otherwise returnNOT_INVITED - Create the group record (admin =
admin_did, chain =[genesis_link],admin_from_seqs=[0],history= the link's policy,key_version0) withadmin_didas its only member (joined_after_seq0)
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.channelis"GROUP".header.recipient_didis the group's DID. The command is sent to the mediator named in that DID.header.sender_didis 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.
payloadis a JSON object with atypefield.
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]:
| Access | Commands |
|---|---|
| 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 only | UPDATE_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 remove | REMOVE_GROUP_MEMBER |
The admin, or a member granted approve | QUERY_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:
| Condition | Error |
|---|---|
A DID is in both add and remove, or add contains the group's DID | INVALID_COMMAND |
remove contains the admin | CANNOT_REMOVE_ADMIN |
key_version present and not the current version + 1 | KEY_VERSION_CONFLICT |
key_version absent, and a current member is removed or a rotation is due | KEY_VERSION_CONFLICT |
| More than 1000 members would result | GROUP_FULL |
| The group owns no stored contract with a DID being added | NOT_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:
| Condition | Error |
|---|---|
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 member | NEW_ADMIN_NOT_MEMBER |
link.key_version is not the group's current version | STALE_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:
| Condition | Error |
|---|---|
| No pending link, or it names another member | NO_PENDING_HANDOVER |
The pending link's key_version is no longer the group's current version | STALE_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:
- If the sender's
dedupe_keyin this group belongs to a deletion notice [Section 8.14.11], returnINVALID_COMMAND - If the sender already posted this
dedupe_keyto this group, return that event'sseqwithduplicate: trueand store nothing; adddeleted: trueif 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 - If
key_versionis not the group's current version, returnSTALE_GROUP_KEY - 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].
forwardreturns events withseqgreater than bothafter_seqand the floor, in ascendingseqorder.backwardreturns events withseqgreater than the floor and, ifbefore_seqis given, less thanbefore_seq, in descendingseqorder.after_seqis ignored.- With
tags, only events carrying at least one of the values are returned. - Deleted events are never returned, nor deletion notices whose
target_seqis 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:
| Condition | Error |
|---|---|
The sender's dedupe_key in this group belongs to an ordinary event | INVALID_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 admin | EVENT_NOT_FOUND |
| The target is a deletion notice | NOT_DELETABLE |
| The target was already deleted | EVENT_ALREADY_DELETED |
The sender is neither the target's sender nor the admin, and its grant holds no delete | NOT_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 names | TARGET_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 version | STALE_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:
| Condition | Error |
|---|---|
grant is not signed by the current admin for this group and the current admin_epoch, or names the admin or the group | INVALID_GRANT |
grant.grantee_did is not a member | GRANTEE_NOT_MEMBER |
grant.not_before_seq is above the group's last seq, or below the from_seq of the grantee's latest ended entry | INVALID_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:
| Condition | Error |
|---|---|
The grantee's current grant does not hold delete: "scoped" | INVALID_GRANT |
key_version is above the group's current version | KEY_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:
| Condition | Error |
|---|---|
member_did is the sender | INVALID_COMMAND |
member_did is the admin | CANNOT_REMOVE_ADMIN |
member_did is not a member | NOT_MEMBER |
The sender is not the admin and member_did is staff | TARGET_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:
| Condition | Error |
|---|---|
statement.key_version is not the group's current version | KEY_VERSION_CONFLICT |
statement is not for this group, or not signed by the admin whose tenure covers its key_version | INVALID_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:
| Condition | Error |
|---|---|
| The sender is a member or the admin | ALREADY_A_MEMBER |
| The sender has a join request that has not lapsed | JOIN_REQUEST_EXISTS |
| The group has 1000 members | GROUP_FULL |
| The group holds 1000 join requests that have not lapsed | JOIN_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_note | INVALID_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:
| Condition | Error |
|---|---|
requester_did is a member | ALREADY_A_MEMBER |
No request from requester_did that has not lapsed | NO_JOIN_REQUEST |
key_version is not the group's current version | STALE_GROUP_KEY |
| The group has 1000 members | GROUP_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:
| Condition | Error |
|---|---|
member_did is not a member | NOT_MEMBER |
| The member would have more than 200 parcels waiting | TOO_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:
| Record | Deleted when |
|---|---|
| Command and WebSocket nonces | Past their expiration [Section 5.2] |
| Pending events | Older than 30 days (never acknowledged) |
| Pending contract requests | Older 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
}
}| Field | Type | Default | Description |
|---|---|---|---|
page | number | 0 | Zero-based page index |
page_size | number | 10 | Number of records per page |
The server computes: offset = page * page_size.
9.2 Response Format
{
"pagination": {
"page": 0,
"page_size": 10,
"total": 42
}
}| Field | Type | Description |
|---|---|---|
page | number | The requested page index |
page_size | number | The requested page size |
total | number | Total 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:
| Command | Order | Rationale |
|---|---|---|
QUERY_EVENTS | timestamp ascending | Clients page forward to replay history |
QUERY_PENDING_EVENTS | Arrival (creation) ascending | Delivery in the order the mediator received events |
QUERY_PENDING_COMMUNICATION_CONTRACT_REQUESTS | Arrival (creation) ascending | Same as pending events |
QUERY_COMMUNICATION_CONTRACTS | Creation descending | The most recently stored contracts appear on the first page |
GET /public/:did | timestamp descending | Feeds show the latest events first |
QUERY_GROUP_EVENTS | seq 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:decentrlidentity 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:
- Verify the message was received within 10 seconds of connection. Error:
AUTH_TIMEOUT(close 4001) - Validate message format. Error:
INVALID_MESSAGE(close 4002) - Verify timestamp drift is within
TIMESTAMP_WINDOW_MS[Section 5.1]. Error:TIMESTAMP_OUT_OF_RANGE(close 4003) - If
mediator_didis present, verify that it is this mediator's DID. Error:WRONG_MEDIATOR(close 4009) - Resolve the DID. Error:
DID_NOT_FOUND(close 4004) - Extract signing key from DID document. Error:
SIGNING_KEY_NOT_FOUND(close 4005) - Verify signature. Error:
INVALID_SIGNATURE(close 4006) - Record the nonce, deduplicated as in [Section 5.2] but in a namespace separate from command nonces. Error:
DUPLICATE_NONCE(close 4008) - If the identity is registered, the connection is registered: done.
- Otherwise, the connection can only be groups-only [Section 10.7]:
- the DID must be a
did:decentrl. Error:NOT_REGISTERED(close 4007) mediator_didmust be present. Error:MEDIATOR_DID_REQUIRED(close 4009)- a groups-only connection slot must be free. Error:
TOO_MANY_CONNECTIONS(close 4029)
- the DID must be a
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
| Code | Error | Description |
|---|---|---|
| 1009 | — | A client message longer than 16,384 characters |
| 4001 | AUTH_TIMEOUT | No auth message within 10 seconds |
| 4002 | INVALID_MESSAGE | Malformed auth message |
| 4003 | TIMESTAMP_OUT_OF_RANGE | Timestamp drift exceeds TIMESTAMP_WINDOW_MS (default 5 minutes) |
| 4004 | DID_NOT_FOUND | Cannot resolve DID |
| 4005 | SIGNING_KEY_NOT_FOUND | Key ID not in DID document |
| 4006 | INVALID_SIGNATURE | Signature verification failed |
| 4007 | NOT_REGISTERED | No registration contract with mediator, and not a did:decentrl that could connect groups-only |
| 4008 | DUPLICATE_NONCE | Replayed AUTHENTICATE message (nonce already used) |
| 4009 | WRONG_MEDIATOR | mediator_did names another mediator |
| 4009 | MEDIATOR_DID_REQUIRED | A 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] |
| 4029 | TOO_MANY_CONNECTIONS | No 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
| Command | Notification | Recipient |
|---|---|---|
TWO_WAY_PRIVATE (success) | PENDING_EVENTS | The event recipient |
REQUEST_COMMUNICATION_CONTRACT (success, non-mediator) | CONTRACTS_UPDATED | The contract recipient |
COMMUNICATION_CONTRACT_RESPONSE (success) | CONTRACTS_UPDATED | The other contract participant |
SAVE_COMMUNICATION_CONTRACT (success) | CONTRACTS_UPDATED | The command sender |
POST_GROUP_EVENT, DELETE_GROUP_EVENT (success, not a duplicate) | GROUP_EVENTS | The 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_UPDATED | The group's subscribed members, and those the command removed [Section 10.9] |
REQUEST_GROUP_JOIN, CANCEL_GROUP_JOIN, DECLINE_GROUP_JOIN (success) | GROUP_UPDATED | The group's subscribed approvers only: the admin and members granted approve |
DELIVER_GROUP_KEY_PARCELS (success) | GROUP_UPDATED | The 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_GROUPSandPONG, and never receivesPENDING_EVENTSorCONTRACTS_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_EVENTSreaches only subscribers who are members at that moment. GROUP_UPDATEDalso reaches, once, the subscribed DIDs the command removed: those inUPDATE_GROUP_MEMBERS'sremove, the sender ofLEAVE_GROUP, themember_didofREMOVE_GROUP_MEMBER, and every subscriber of a groupDELETE_GROUPdeleted.- A hint of
REQUEST_GROUP_JOIN,CANCEL_GROUP_JOINorDECLINE_GROUP_JOINreaches only subscribed members who are approvers when it is sent. A hint ofDELIVER_GROUP_KEY_PARCELSreaches only itsmember_did. Requesters are not members, cannot subscribe, and receive nothing: they pollQUERY_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 Code | HTTP Status | Description |
|---|---|---|
INVALID_COMMAND | 400 | Request body fails schema validation (including a missing header version) or business logic |
UNSUPPORTED_VERSION | 400 | Header version is not the protocol version the mediator implements |
INVALID_SIGNATURE | 401 | Command signature verification failed |
INVALID_SIGNATURES | 401 | One or both contract signatures failed verification |
UNAUTHORIZED_COMMAND | 401 | Sender not registered or wrong recipient |
TIMESTAMP_OUT_OF_RANGE | 401 | Timestamp drift exceeds window |
DUPLICATE_NONCE | 401 | Nonce already processed for this sender |
SENDER_NOT_FOUND | 404 | Sender's DID cannot be resolved |
SENDER_SIGNING_KEY_NOT_FOUND | 404 | Key ID not in sender's DID document |
RECIPIENT_NOT_FOUND | 404 | Recipient's DID cannot be resolved |
RECIPIENT_NOT_REGISTERED | 404 | Recipient has no registration contract with mediator |
COMMUNICATION_CONTRACT_NOT_FOUND | 404 | Recipient holds no contract with the sender (never made, or revoked; indistinguishable) |
PENDING_REQUESTS_LIMIT | 429 | Recipient already holds the maximum number of unacknowledged contact requests from others (50, or 1000 for a group) [Section 8.1] |
RATE_LIMIT_EXCEEDED | 429 | Sender exceeded a rate limit [Section 5.5] |
INVALID_ADMIN_LINK | 400 | A group admin link fails verification [Sections 8.13, 8.14.5] |
NOT_A_GROUP_MEMBER | 403 | Sender is neither the group's admin nor a member |
NOT_GROUP_ADMIN | 403 | A member sent an admin-only group command |
NOT_EVENT_AUTHOR | 403 | DELETE_GROUP_EVENT from a member that is not the target's sender and holds no delete grant [Section 8.14.11] |
NOT_PERMITTED | 403 | A 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_STAFF | 403 | A grantee tried to delete a staff member's event or remove a staff member [Sections 8.14.11, 8.14.14] |
INVALID_GRANT | 400 | A 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_MEMBER | 404 | REMOVE_GROUP_MEMBER or DELIVER_GROUP_KEY_PARCELS names a non-member [Sections 8.14.14, 8.14.22] |
NO_JOIN_REQUEST | 404 | No (live) join request to cancel, decline or approve [Sections 8.14.18, 8.14.20, 8.14.21] |
GRANTEE_NOT_MEMBER | 409 | SET_GROUP_GRANT names a non-member [Section 8.14.12] |
ALREADY_A_MEMBER | 409 | A join request from, or an approval of, a member [Sections 8.14.17, 8.14.21] |
JOIN_REQUEST_EXISTS | 409 | The sender already has a live join request [Section 8.14.17] |
JOIN_QUEUE_FULL | 429 | The group already holds 1000 live join requests [Section 8.14.17] |
TOO_MANY_PARCELS | 429 | The member would have more than 200 parcels waiting [Section 8.14.22] |
GROUP_NOT_FOUND | 404 | This mediator hosts no group with that DID |
EVENT_NOT_FOUND | 404 | DELETE_GROUP_EVENT names no event of this group, or one below the sender's floor |
GROUP_EXISTS | 409 | CREATE_GROUP for a group that exists or was deleted |
GROUP_FULL | 409 | The group would exceed 1000 members, or has 1000 when a join is requested or approved |
ADMIN_CANNOT_LEAVE | 409 | The admin sent LEAVE_GROUP |
CANNOT_REMOVE_ADMIN | 409 | UPDATE_GROUP_MEMBERS or REMOVE_GROUP_MEMBER removes the admin |
NEW_ADMIN_NOT_MEMBER | 409 | A handover link names a non-member |
NOT_INVITED | 409 | The group owns no contract with a DID being added, or with the first admin |
KEY_VERSION_CONFLICT | 409 | key_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_KEY | 409 | A group event posted, or a join approved, under a version other than the current one |
STALE_HANDOVER | 409 | The handover link's version is no longer the group's current version |
NO_PENDING_HANDOVER | 409 | ACCEPT_GROUP_ADMIN without a pending link naming the sender |
EVENT_ALREADY_DELETED | 409 | DELETE_GROUP_EVENT names an event that was already deleted |
NOT_DELETABLE | 409 | DELETE_GROUP_EVENT names a deletion notice |
GROUP_DELETED | 410 | The 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_idandepochin 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_EVENTSentries repeat an event the owner already stores (equaldedupe_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:
| Command | Unique on | A 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_seq | SUCCESS 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:decentrlDID 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".
DCTRL-0003: Communication Contracts
Bilateral cryptographic agreements with ephemeral key exchange, mutual signatures, daily key epochs, and unilateral revocation.
DCTRL-0005: Event Encryption and Storage
Private payloads, signed envelopes, event IDs and idempotent delivery, daily epoch keys, dual delivery, encrypted tags, and the scope of forward secrecy.