decentrl/docs
Specifications

DCTRL-0006Group Messaging

Group identities, the signed admin chain, group key certificates, history policy, staff grants, the GROUP mediator channel, group events with hierarchical tags and deletion, counts and presence, joining through the mediator, key parcels, admin handover, and their security properties.

FieldValue
RFCDCTRL-0006
TitleGroup Messaging
StatusImplemented
Created2026-03-14
Updated2026-09-30
Version0.6
RequiresDCTRL-0001, DCTRL-0002, DCTRL-0003, DCTRL-0004, DCTRL-0005

Abstract

This document specifies group messaging for the Decentrl Protocol. A group is a did:decentrl identity registered with the mediator that hosts the group. One admin at a time runs that identity. Authority over the group comes from a signed admin chain kept by the group's mediator, never from the group identity's keys. A random group key per version is signed by the admin as a key certificate and delivered to each member over a pairwise control channel between the group identity and that member. The group's mediator keeps the member list and the current key version, and gives group events one canonical order. The admin can delegate deleting, removing and approving joins to members as signed grants, which the mediator enforces and readers check again. This specification defines the group identity, the admin chain and its history policy, staff grants, key certificates and key parcels, invitations and join requests, the GROUP mediator channel, the group event format with its encrypted hierarchical tags, deletion of group events, how members read group events, control events, administration and recovery, admin handover, leaving and deleting, and the security properties of the design.

Status of This Document

This specification is implemented by the reference mediator and by the GroupMember and GroupAdmin classes of @decentrl/sdk. Section 16 lists what is not yet specified.

Version 0.6 stops trusting the mediator's member list with keys. Before, the admin's device delivered keys to every member the mediator listed, so a dishonest mediator could list a DID of its own and receive every key [Section 15.8]. Now:

  • an approver signs an admission for each member it lets in; the mediator checks it, keeps it, and shows it to the admin only [Sections 6.6, 7.11, 7.22];
  • the admin's device keeps a roster of the members it accounts for, and delivers keys to them alone [Sections 5.3, 11.7];
  • the roster travels in the handover bundle [Section 12.1].

APPROVE_GROUP_JOIN without a valid admission is refused (INVALID_ADMISSION), so approvers need a 0.6 client. Everything else is additive. Admin state from before 0.6 seeds its roster once from the member list [Section 15.13].

Version 0.5 added two read-only queries a group's pages need without reading every event:

  • QUERY_GROUP_COUNTS: how many live events carry a tag prefix, were sent by a member, or came after a seq [Section 7.24];
  • QUERY_GROUP_PRESENCE: which members are subscribed to the group's hints right now [Section 7.25].

Both are additive: a 0.4 client never sends them, and a 0.4 mediator refuses them as an invalid command.

Version 0.4 added delegated roles on top of 0.3, so that a group keeps running while its admin's device is offline:

  • hierarchical group tags: .-separated places, matched by every prefix [Sections 8.7, 9.7];
  • staff grants signed by the admin, kept in an append-only grant log, with delete scopes per key version [Sections 4.7 – 4.9];
  • authorization by command access class, deletion by grantees, and REMOVE_GROUP_MEMBER [Sections 7.3, 7.14, 7.17];
  • key parcels: control events sealed to a member's DID key and left on the group's mediator [Section 5.7];
  • joining through the group's mediator, with a signed join key, sealed notes, and approval by grantees [Section 6.6];
  • readers that honour grantees' deletions from the grant log [Section 9.6].

Everything 0.4 adds is additive on the wire. The value of a full tag is the value 0.3 computed, so exact reads of older events keep working; a group from before 0.4 reads with an empty grant log and no join key [Section 14.4]. Contract join requests to G [Section 6.4] are still answered by the admin's device, for clients from before 0.4.

Version 0.3 added general group capabilities on top of 0.2, whose core (the admin chain, key certificates, atomic rotation on removal, the canonical seq, dedupe and consent) is unchanged:

  • a history policy, fixed at creation by the genesis link [Section 4.5];
  • the seq at which each admin took effect, and who was admin at a point in the log [Section 4.6];
  • key certificates delivered in ordered batches [Sections 5.3, 10.2];
  • join requests [Section 6.4];
  • signed deletion of group events [Sections 7.14, 8.6, 9.6];
  • encrypted group tags, reads by tag and backward reads [Sections 8.7, 9.7, 9.8];
  • WebSocket hints for group events and changes [Section 9.9, DCTRL-0004 Section 10].

Everything 0.3 added is optional on the wire: a group created with the from_join policy has a genesis link signed exactly as in 0.2, an untagged envelope is exactly a 0.2 envelope, and ordinary event rows keep the 0.2 shape. Version 0.2 replaced the 0.1 draft: authority moved from the group identity's keys to a signed admin chain; keys are delivered as signed certificates; the mediator enforces the key version, so old and new versions no longer coexist [Section 8.5]; and admin handover was added.

Table of Contents

  1. Introduction
  2. Terminology
  3. Group Identity
  4. Authority: the Admin Chain
  5. Group Keys
  6. Invitations and Consent
  7. The GROUP Channel
  8. Posting Group Events
  9. Reading Group Events
  10. Control Events
  11. Administration
  12. Admin Handover
  13. Leaving and Deleting
  14. Operational Complexity
  15. Security Considerations
  16. Not Yet Specified
  17. 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].

Group messaging builds on the 1:1 primitives of [DCTRL-0003] and [DCTRL-0005]. There is no group key agreement protocol. A group key is 32 random bytes, and it reaches each member over an ordinary communication contract between the group identity and that member.

Three parties are involved:

  • The group identity (G): a did:decentrl identity that owns one control channel (a communication contract) with each member. The current admin's device runs it.
  • The admin: a member, identified by its personal DID, who holds authority according to the admin chain. It signs membership changes and key certificates with its personal key.
  • The group's mediator: the mediator named in G's DID. It keeps the group record (admin chain, members, current key version), refuses commands the sender may not send, and assigns each group event a sequence number (seq) that every member reads in the same order.

Members post group events to the group's mediator encrypted under the current group key and signed with their personal keys. Each member reads them from there, verifies them, and stores its own copy on its own mediator.

The admin may give members grants: the right to delete others' events, to remove members, or to let people in [Section 4.7]. These members, with the admin, are the group's staff. They act through the group's mediator while the admin's device is offline, but they never hold G's keys and never issue group keys.


2. Terminology

Group Identity (G) — The did:decentrl identity that represents the group. Its DID names the group's mediator, and its alias is the group's name. It is never a member.

Admin — The member whose personal DID the admin chain currently names. There is exactly one.

Admin Chain — The list of signed admin links from the genesis link onward [Section 4]. It records who administered the group and from which key version.

Admin Link — One entry of the admin chain: a signed statement naming an admin and the key version at which it took over.

Tenure — The range of key versions an admin may issue [Section 4.4].

History Policy — What a member may read from before it was added: nothing (from_join) or everything (full). Fixed by the genesis link [Section 4.5].

From Seq — For one admin link, the group's last seq when that link took effect [Section 4.6].

Floor — The highest seq a member may not read: its joined_after_seq in a from_join group, 0 in a full group [Section 7.12].

Group Key (GK_v) — A 32-byte AES-256-GCM key shared by the members. v is its version, starting at 1. Version 0 means that no key exists yet.

Key Certificate — A group key with its version, signed by the admin that issued it [Section 5.2].

Control Channel — The communication contract between G and one member, opened by G's invitation or by the member's contract join request. It carries invitations, key certificates and handover bundles, and nothing else.

Control Event — An event of a reserved type (decentrl.*) carrying keys or a handover bundle: a private event from G over a control channel, or the contents of a key parcel [Section 10].

Key Parcel — A control event sealed to one member's DID key-agreement key and left on the group's mediator, for a member without a control channel [Section 5.7].

Grant — A signed statement by the admin giving a member capabilities: delete, remove, approve [Section 4.7]. The member is its grantee.

Grant Log — The group mediator's append-only list of grants and their ends [Section 4.7].

Delete Scope — The group tags under which a grantee with a scoped delete may delete, kept per key version [Section 4.8].

Staff — The admin, every grantee holding a capability, and the member a pending handover names [Section 4.9].

Approver — The admin, or a grantee holding approve.

Group Event — An application event posted to the group [Section 8].

Group Tag — A plaintext label of .-separated segments a sender attaches to a group event, signed in its envelope and given to the mediator only as keyed hashes of its prefixes, per key version [Section 8.7].

Deletion Notice — A signed group event of the reserved type decentrl.group.deletion, stored on its own kind of row, that deletes an earlier group event [Section 8.6].

Join Request — A request to be let in: made on the group's mediator [Section 6.6], or, before version 0.4, as a contract request to G [Section 6.4].

Join Key — An X25519 key pair derived from one version of the group key, whose public key the admin signs; join notes are sealed to it [Section 6.6].

Seq — The group mediator's sequence number of a group event: the group's canonical order and the readers' cursor.

Handover Bundle — The admin state the current admin sends to its successor [Section 12.1].


3. Group Identity

3.1 Structure

A group is a did:decentrl identity [DCTRL-0001] with:

  • an Ed25519 signing key pair,
  • an X25519 pre-key pair (used to open control channels),
  • a storage key,
  • the group's mediator, named in the DID.

The alias in G's DID is the group's name. An invitee reads it from the DID [Section 6.1].

3.2 Registration and Hosting

The admin creates G the same way as any did:decentrl identity [DCTRL-0001 Section 6.1] and MUST register it with the group's mediator [DCTRL-0003 Section 9]. That mediator hosts the group: it stores the group record created by CREATE_GROUP [Section 7.5] and serves every GROUP command for G.

3.3 Who Runs the Group Identity

The current admin's device runs G as a second 1:1 client, next to the admin's personal identity. G requests the control channels (the invitations) or accepts them (approved join requests), holds their key states, and sends control events. G never posts group events and is never a member.

G's private keys travel to every new admin in the handover bundle [Section 12.1]. The admin state, including G's keys, SHOULD be stored as carefully as a personal identity's keys.

3.4 Authority Does Not Come From the Group Identity

did:decentrl keys cannot be rotated: the keys are the DID. Every admin, past and present, therefore holds G's private keys for good. If G's signature conferred authority, any former admin could keep acting for the group.

For this reason neither the mediator nor the members accept anything as authority because G signed it. G signs exactly one thing, the genesis admin link [Section 4.1]. From then on authority comes only from the admin chain [Section 4], and each admin signs with its personal key. Section 15.3 lists what a former admin can still do with G's keys.


4. Authority: the Admin Chain

{
  "type": "decentrl.group.admin",
  "group_did": "<G>",
  "admin_epoch": 1,
  "admin_did": "<DID of the admin this link names>",
  "key_version": 4,
  "signature": "<base64 Ed25519 signature>"
}
FieldTypeDescription
typestringMUST be "decentrl.group.admin". The type keeps a link from passing as any other signed object
group_didstringThe group
admin_epochinteger ≥ 0Position in the chain: 0 for the genesis link, then one more per handover
admin_didstringThe admin this link names
key_versioninteger ≥ 0The group's key version when this admin took over. 0 for the genesis link
historystring, OPTIONALGenesis link only: the group's history policy, "from_join" or "full" [Section 4.5]. Absent means "from_join"
signaturestringEd25519 signature over the canonical JSON [DCTRL-0002] of every other field

The genesis link (epoch 0) is signed with G's signing key. Link k (k ≥ 1) is signed with the personal signing key of the admin named by link k−1.

A creator SHOULD omit history for from_join, so that such a genesis link is signed exactly as in version 0.2. A full genesis link carries "history": "full", and G's signature covers it.

A link is valid as the successor of previous (null for the genesis link) only if every check passes, in this order:

CheckRejection
Well-formed, with type = "decentrl.group.admin" and history, if present, "from_join" or "full"MALFORMED
group_did is the groupGROUP_MISMATCH
admin_epoch is previous.admin_epoch + 1, or 0 without previousEPOCH_MISMATCH
admin_did is neither G nor previous.admin_didINVALID_ADMIN
key_version is 0 without previous, otherwise ≥ previous.key_versionKEY_VERSION_REGRESSED
history is absent unless there is no previousHISTORY_NOT_ON_GENESIS
The signer's key can be read from its DID: G for the genesis link, previous.admin_did otherwiseUNKNOWN_SIGNER_KEY
The signature verifiesINVALID_SIGNATURE

Signer keys are read directly from did:decentrl DIDs. Admins MUST therefore be did:decentrl identities [Section 15.9].

4.3 Verifying a Chain

A verifier checks every link from the genesis link onward with the rules of Section 4.2. An empty chain is rejected.

A member keeps the last chain it verified. It MUST NOT accept a newer chain unless that chain starts with exactly the links it holds, compared by signature. Otherwise the chain is rejected (NOT_AN_EXTENSION). A mediator cannot rewrite history a member has already seen. It can only append to it.

4.4 Tenure

The admin named by link k may issue key versions in the range

(link_k.key_version, link_{k+1}.key_version]

The newest admin's range has no upper bound. The genesis link has key_version 0, so the first admin issues versions from 1.

ChainTenure
Link 0: Alice, key_version 0Alice: v1 – v4
Link 1: Bob, key_version 4Bob: v5 onward

Keys an admin issued before handing over stay valid, and a later admin may deliver them again [Section 5.2]. A certificate the former admin signs for a version after its tenure is refused. The handover version itself (v4 above) belongs to the former admin, which is why a new admin MUST rotate right after taking over [Section 12.4, Section 15.4].

4.5 History Policy

The history policy decides what a member may read of the group's log from before it was added:

PolicyA member readsA member is owed keys
from_joinEvents posted after it was added (its joined_after_seq)From the version current when it was added
fullEvery event of the groupFrom v1

The policy is fixed at creation. Only the genesis link may carry it, and G signs that link [Section 4.1]. No later link may name a policy (HISTORY_NOT_ON_GENESIS), so no admin can change it:

history_policy_of(chain) = chain[0].history ?? "from_join"

The verified genesis link is the only source of the policy. The group's mediator copies it into the group record at CREATE_GROUP [Section 7.5] and applies it to reads [Section 7.12]. QUERY_GROUP also reports it [Section 7.11], but that value is informational: members and admins take the policy from the chain they verified. A mediator that reports full for a group whose genesis link says from_join therefore gains nothing: members are still owed only the keys from_join gives them.

4.6 Authority at a Point in the Log

Tenure says who may issue a key version. Some checks need instead who was admin when a given event was posted, for example whether a deletion notice was signed by the admin of the time [Section 9.6], or content that an application reserves to the admin.

The group's mediator records, for each admin link, its from_seq: the group's last seq when the link took effect. That is 0 for the genesis link (CREATE_GROUP), and the group's last seq when ACCEPT_GROUP_ADMIN committed for every later link [Section 7.9]. QUERY_GROUP returns the list as admin_from_seqs, one entry per link [Section 7.11].

Groups created before version 0.3 never recorded these values. Their links read as null ("not recorded"), except the genesis link, which reads as 0. A link accepted after the upgrade gets its value, so such a group may report [0, null, 57].

A list is valid for a chain only if it has one entry per link, its first entry is 0, and its numbers, skipping nulls, never decrease. A member pins the list it verified together with the chain [Section 4.3]. A newer list MUST keep every value the member pinned, null included; otherwise it is rejected (NOT_AN_EXTENSION). A mediator can add values for new links, but cannot change one it reported, not even turn a null into a number. An invalid list is rejected (INVALID_FROM_SEQS).

The admin at an event is found from the event's seq and key version v:

function admin_at_seq(chain, from_seqs, seq, v):
    if from_seqs is not valid for chain: return none
    for k from the last link down to the genesis link:
        if from_seqs[k] is null:
            return the admin whose tenure covers v             // [Section 4.4]
        if seq > from_seqs[k]:
            return chain[k].admin_did
    return none

The event at exactly from_seq_k belongs to the previous admin, and every later event to admin k. A member named by a pending link is never the admin at any seq: its link is not in the chain until it accepts. Where a boundary was not recorded, key-version tenure decides, with the same half-open ranges as Section 4.4.

4.7 Grants

The admin gives a member capabilities with a grant, signed with the admin's personal key:

{
  "type": "decentrl.group.grant",
  "group_did": "<G>",
  "grantee_did": "<the member>",
  "admin_epoch": 2,
  "not_before_seq": 118,
  "issued_at": 1790766603821,
  "capabilities": { "delete": "scoped", "remove": true, "approve": true },
  "signature": "<base64 Ed25519 signature>"
}
FieldTypeDescription
typestringMUST be "decentrl.group.grant"
group_didstringThe group
grantee_didstringThe member the grant is for. Never G, and never the admin that signs it
admin_epochinteger ≥ 0The epoch of the admin link whose admin signs the grant
not_before_seqinteger ≥ 0At or after the from_seq of the grantee's latest entry in the grant log, of any kind, and at most the group's last seq. The reference admin uses the highest from_seq in the grant log
issued_atinteger ≥ 0When the admin signed the grant, in Unix milliseconds. It makes every grant's signature new: a grant revoked and then given again with the same capabilities, even at the same not_before_seq, is signed anew. Required from version 0.6; a grant without it is MALFORMED
capabilitiesobjectAny of delete ("all" or "scoped"), remove (true) and approve (true); no other member
signaturestringEd25519 signature over the canonical JSON [DCTRL-0002] of every other field
CapabilityThe grantee may
delete: "all"Delete any event whose sender is not staff [Section 7.14]
delete: "scoped"The same, only for events under its delete scope [Section 4.8]
removeRemove members who are not staff [Section 7.17]
approveList, decline and approve join requests [Sections 7.21, 7.22]

A grant replaces any earlier grant of its grantee. A grant with empty capabilities is a revocation. A grant g is valid under an admin link issuer only if, in this order:

CheckRejection
Well-formed, with type = "decentrl.group.grant" and no unknown capabilityMALFORMED
group_did is the groupGROUP_MISMATCH
admin_epoch is issuer.admin_epochEPOCH_MISMATCH
grantee_did is neither issuer.admin_did nor GINVALID_GRANTEE
issuer.admin_did's signing key can be read from its DIDUNKNOWN_SIGNER_KEY
The signature verifiesINVALID_SIGNATURE

The mediator takes issuer to be the chain's last link [Section 7.15]. A reader takes the link of the grant's admin_epoch, and checks that its admin held office where the grant sits in the log [Section 9.6].

Replays. A signed grant can be presented again, by the mediator or by anyone who saw it, to bring back capabilities the admin has since taken away. Against the entries that precede it in the grant log, a grant is a replay when:

  • its signature already appears in an earlier grant entry; or
  • its not_before_seq is below the from_seq of its grantee's latest earlier entry of any kind: a grant, a revocation or an end.
function is_grant_replay(earlier_entries, g):
    if any grant entry in earlier_entries has signature g.signature: return true
    latest = max(e.from_seq for e in earlier_entries naming g.grantee_did, default 0)
    return g.not_before_seq < latest

The mediator refuses a replay [Section 7.15] and readers drop one [Section 9.6], with this same rule. An admin that signs not_before_seq as the highest from_seq in the grant log never makes a replay, and issued_at keeps a fresh grant's signature from matching an earlier one.

The grant log. The group's mediator keeps every grant it accepted, and every end of one, in an append-only log. Each entry records from_seq, the group's last seq when it took effect: it applies to events with a greater seq.

[
  { "kind": "grant", "from_seq": 118, "grant": { <signed grant> } },
  { "kind": "ended", "from_seq": 240, "grantee_did": "<DID>", "reason": "left" }
]

The mediator writes an ended entry when a grantee holding a capability stops being a member or becomes the admin:

reasonWritten by
leftLEAVE_GROUP [Section 7.7]
removedUPDATE_GROUP_MEMBERS removing it [Section 7.6], or REMOVE_GROUP_MEMBER [Section 7.17]
became_adminACCEPT_GROUP_ADMIN by the grantee [Section 7.9]: the admin needs no grant

An ended entry is not signed. It can only take authority away, so readers accept it as the mediator reports it. A grantee's current grant is its latest entry: a grant with capabilities, or nothing after a revocation or an end. A member who comes back after an end holds nothing until the admin grants again.

Grants survive a handover: a grant signed by a former admin stays in force until the current admin revokes it. QUERY_GROUP returns the whole log as grant_log [Section 7.11]. A member pins it as it pins the chain [Section 9.6].

4.8 Delete Scopes

A grant with delete: "scoped" reaches only events under the grantee's delete scope: a list of 1 to 64 plaintext tag prefixes, for example forum.news. The prefixes never appear in the grant. The admin gives the mediator one scope row per grantee and key version, with SET_GROUP_GRANT_SCOPE [Section 7.16]:

FieldDescription
grantee_didThe scoped grantee
key_versionThe version the row applies to: ≥ 1 and not above the group's current version
valuesAt most 64 values: group_tag_value(p, GK_v) of each prefix p itself [Section 8.7]
sealed_prefixesThe prefixes, JSON-serialized and sealed under that version's scope key
function derive_group_scope_key(GK_v):
    return HKDF_SHA256(
        ikm    = GK_v,
        salt   = empty,
        info   = UTF8_encode("decentrl/group-grant-scope/v1"),
        length = 32
    )

sealed_prefixes = AES_GCM_encrypt(JSON.stringify(prefixes), derive_group_scope_key(GK_v))   // [DCTRL-0002]

values holds the value of each prefix, never of its ancestors. Events carry the value of every prefix of their tags [Section 8.7], so the value of forum.news matches an event tagged forum.news.t42. The value of forum, which a scope of forum.news must not reach, is not in the row.

The mediator checks a scoped deletion against the row for the target's key version [Section 7.14]. Without a row for that version, the grantee may not delete the event. The admin therefore writes a row for each scoped grantee under every key version it holds: when it grants, and again after each rotation. The admin keeps the plaintext prefixes in its admin state, which travels in the handover bundle [Section 12.1]. An admin that lacks a grantee's prefixes, for example after a handover from an admin that did not keep them, reads them back from the newest row it can open. A grantee reads its own rows the same way.

Every new grant for the grantee, and every end, drops the grantee's rows; for a grant that is still scoped, the admin writes them again right after [Section 7.15].

4.9 Staff

The staff of a group are:

  • the admin;
  • every member whose current grant holds a capability, approve alone included;
  • the member a pending handover link names, who is about to become the admin.

Staff are protected from each other: a grantee may neither delete a staff member's event [Section 7.14] nor remove a staff member [Section 7.17]. Only the admin acts on staff. An author may always delete its own events.


5. Group Keys

5.1 Properties

A group key is:

  • 32 bytes from a CSPRNG,
  • used directly as the AES-256-GCM key for group event payloads [DCTRL-0002],
  • identified by its version. Each rotation creates version current + 1.

The group's mediator records the current version [Section 7.1]. Only UPDATE_GROUP_MEMBERS with key_version changes it [Section 7.6].

5.2 Key Certificate

{
  "group_did": "<G>",
  "key_version": 3,
  "group_key": "<base64 32-byte key>",
  "admin_did": "<the issuing admin's DID>",
  "admin_signature": "<base64 Ed25519 signature>"
}

The issuing admin signs the canonical JSON of:

{ type: "decentrl.group.key", group_did, key_version, group_key }

with its personal signing key. The signature does not name a recipient. The same certificate can be delivered to any member, and delivered again later, including by a later admin after a handover.

5.3 Delivery

G delivers certificates to a member as control events [Section 10]:

  • one certificate as decentrl.group.key, whose data is the certificate;
  • several as decentrl.group.keys, whose data is { "certificates": [ … ] }: 1 to 50 certificates in ascending version order.

A single certificate is always sent as decentrl.group.key, which a version 0.2 member also takes.

A member is owed every certificate from the first version it is owed up to the current one, in ascending order [Section 11.4]. The first version owed depends on the history policy [Section 4.5]:

first_version_owed(history, key_version) = history == "full" ? 1 : key_version

where key_version is the group's version when the member was added. The admin takes the policy from the genesis link of the chain it verified, never from the mediator's report.

The admin sends a member's certificates in ascending batches of at most 50. If a batch fails, delivery to that member stops there, and the next attempt resumes from the first version of that batch [Section 11.4]. A member therefore never receives a version while an earlier version it is owed is still undelivered.

The admin delivers only to members it accounts for [Section 11.7], never to every member the mediator lists [Section 15.8]. A listed member it cannot account for is owed nothing.

5.4 Acceptance

A member MUST accept a certificate only if all of the following hold:

  1. The control event's authenticated sender is G, or it came in a key parcel for G [Section 5.7], and the member accepted G's invitation or asked to join G [Section 6.2].
  2. The certificate's group_did is G.
  3. group_key decodes to exactly 32 bytes.
  4. The member holds no key for this version yet. If it holds the same key for this version, the certificate is a no-op. If it holds a different key, the certificate is rejected. The first key for a version wins, so nobody can replace a known version.
  5. For a version not yet held, the member reads the group with a fresh QUERY_GROUP [Section 7.11] and:
    • verifies the returned chain from genesis, and checks that it extends the chain the member holds [Section 4.3];
    • checks that the returned admin_from_seqs are valid for that chain and keep the values the member pinned [Section 4.6];
    • checks that the version is not above the mediator's key_version;
    • checks that admin_did has tenure over the version according to that chain [Section 4.4];
    • verifies admin_signature with admin_did's signing key.

The fresh read is REQUIRED for every version not yet held. A member that relied on an old chain could accept a certificate for v(n+1) from a former admin who handed over after the member's last read.

The member then stores the certificate, the extended chain and its admin_from_seqs. If the fresh read fails for a reason worth retrying (network error, rate limit, any mediator error other than the ones below), the control event MUST stay pending so that it is retried [Section 10.2]. If the mediator answers NOT_A_GROUP_MEMBER, GROUP_DELETED or GROUP_NOT_FOUND, or the chain or its admin_from_seqs do not verify, the certificate is rejected.

Batches. A decentrl.group.keys event is checked with the same rules and at most one fresh QUERY_GROUP, taken only if the batch holds a version not yet held. Each certificate is then decided on its own:

  • a version already held with the same key is a no-op;
  • a version already held with a different key is refused; the first key wins;
  • a certificate that fails any other rule is refused;
  • the valid new versions are stored together.

A refused certificate costs only itself. The whole batch is refused only when it is not a list of 1 to 50 well-formed certificates for G. A batch in which some certificate was accepted, or none was refused, counts as applied; one in which every certificate was refused counts as rejected [Section 10.2].

Order. A member MUST NOT apply a control event from G while an earlier event from G is still pending [Section 10.2]. With the admin's ordered delivery [Section 5.3], a member that holds version n and lacks a lower version will not receive that lower version any more: every version it was owed below n was delivered and applied before n, so the missing one was never owed to it, for example a version made while it was not a member. The reader rule can then skip such rows [Section 9.3] without ever skipping a key that is still on its way, under both history policies.

5.5 Key Storage

A member keeps per group: whether it joined or only asked to join, and how it asked [Sections 6.4, 6.6], the verified admin chain and its pinned admin_from_seqs [Section 4.6] and grant log [Section 9.6], the certificates it accepted (by version), and any handover bundles offered to it [Section 12.3]. State written before version 0.3 has no join status and counts as joined. This state MUST be persisted before the control event that changed it is acknowledged. If persisting fails, the event stays pending and is applied again on the next sync.

Members retain every version they received: an event under an older version may still be unread. Keys exist only in the member's local state. G sends control events as ephemeral [Section 10.1], and reserved events are never stored [DCTRL-0005 Section 9.6], so no mediator holds a copy. A key parcel waits on the group's mediator, sealed to the member's DID key, until the member acknowledges it [Section 5.7]. Recovering keys on a second device is not specified [Section 16].

5.6 Sending Key

A member posts under the highest version it holds. That version must be the group's current version [Section 8.5].

5.7 Key Parcels

A key parcel carries one control event to a member the group reaches without a control channel: a newcomer an approver let in [Section 6.6], or a member whose channel is gone. It is left on the group's mediator for that member, who fetches it with QUERY_GROUP_KEY_PARCELS [Section 7.23].

Sealed box. Parcels, and join notes [Section 6.6], are anonymous sealed boxes: anyone can seal to an X25519 public key, and only its holder opens. A label names the purpose, so a box made for one purpose never opens as another.

function seal_to(recipient_public_key, plaintext, label):
    (e_private, e_public) = X25519_generate()                  // fresh for every box
    key = HKDF_SHA256(
        ikm    = X25519(e_private, recipient_public_key),
        salt   = e_public || recipient_public_key,
        info   = UTF8_encode(label),
        length = 32
    )
    return base64(e_public) + "." + AES_GCM_encrypt(plaintext, key)   // [DCTRL-0002]

To open, the recipient splits at the first ., computes the same key from its private key and the ephemeral public key, and decrypts. A box that does not decrypt is dropped.

Contents. A parcel is seal_to(K, JSON.stringify({ type, data }), "decentrl/group-key-parcel/v1"), where K is the X25519 key-agreement key read from the recipient's did:decentrl DID [DCTRL-0001], and { type, data } is a control event [Section 10.1]:

typeParcels carry it from
decentrl.group.keyAny approver, or the admin
decentrl.group.keys (1 to 50 certificates)Any approver, or the admin
decentrl.group.handoverThe admin only

The key is read from the DID itself, so no mediator can substitute its own: the approver seals without asking anyone for a key, and a mediator that alters or swaps a parcel only makes it fail to open.

Certificates stay the admin's. An approver puts in parcels the key certificates it holds, as the admin signed them. It never signs a key. A newcomer checks each certificate with the acceptance rules of Section 5.4, tenure included, exactly as if G had delivered it. An approver can let someone in, but cannot make a key members would accept. It gives:

  • in a from_join group, the certificate of the current version;
  • in a full group, every certificate it holds, in ascending batches of at most 50, one batch per parcel [Section 5.3].

An approver that does not hold the current version MUST NOT approve: the newcomer would join a group it cannot read.

The admin's routing. When the admin's device sends a control event to a member [Sections 5.3, 12.1], it sends it over the member's control channel if G holds an active one, and otherwise as a parcel with DELIVER_GROUP_KEY_PARCELS [Section 7.23]. Delivery, rotation and handover are otherwise unchanged. A member without a channel is therefore served by parcels, never invited again [Section 11.5].

Receiving. A member fetches its parcels for groups it joined or asked to join and whose request has not lapsed [Section 6.2]. It handles them in the order the mediator stored them, each as follows:

  1. Open the parcel with its DID key-agreement key. One that does not open is dropped.
  2. Drop a parcel whose type parcels do not carry, and a decentrl.group.handover whose from_did is not the current admin, read with QUERY_GROUP.
  3. Apply the event exactly as a control event from G [Section 10.2]: the certificates pass Section 5.4, a bundle Section 12.3. A requester becomes joined.

The member acknowledges a parcel with ACK_GROUP_KEY_PARCELS only after it was applied and the state it changed was persisted, or after it was dropped. If applying one fails for a reason worth retrying, that parcel and every later one stay on the mediator, and the next fetch applies them again; parcels already applied are no-ops the second time.

A member SHOULD fetch its parcels while it waits to be let in, while it holds no key of a group it joined, after a GROUP_UPDATED hint [Section 9.9], and periodically.


6.1 Invitation

An invitation is a communication contract request from G to the invitee [DCTRL-0003 Section 4]. The request carries an invitation field [DCTRL-0003 Section 3.4]:

{
  "admin_did": "<inviting admin's DID>",
  "admin_signature": "<base64 Ed25519 signature>"
}

The admin signs the canonical JSON of { type: "decentrl.group.invitation", communication_contract } with its personal key, where communication_contract is the requested contract. The signature is bound to this request's contract, so it cannot be moved to another request.

The invitee verifies it after decrypting the request. The invitation holds only if admin_did is a did:decentrl other than the requestor and the signature verifies. The invitee can then show "admin_did invites you to G", with the group's name taken from G's alias. The invitation proves that admin_did signed it. It does not prove that admin_did is still G's admin [Section 15.3].

The invitee joins by accepting the contract request. That contract becomes its control channel. A member MUST take control events, over a control channel or in parcels [Section 5.7], only from groups whose invitation it accepted or to which it sent a join request that has not lapsed [Sections 6.4, 6.6], and MUST refuse (acknowledge and drop) control events from any other sender, including a group that sent a plain contract request without a verified invitation. A client SHOULD refuse to treat a request as a group invitation unless the verified invitation's group is the requestor.

Accepting a group's contract request as a plain contact therefore does not join the group: the member never takes that group's keys.

6.3 What the Mediator Checks

With UPDATE_GROUP_MEMBERS, the group's mediator refuses to add a member unless G owns a stored contract with that DID on it (NOT_INVITED) [Section 7.6]. APPROVE_GROUP_JOIN adds a member without one: the member's own request, which it signed, is its consent [Section 7.22]. CREATE_GROUP refuses unless G holds one with the first admin [Section 7.5].

This check proves that a channel exists. It does not prove that the member meant to join a group: the mediator cannot tell an accepted invitation from any other contract G holds. The member-side rule of Section 6.2 is what enforces consent. An admin who adds someone who did not accept an invitation changes the member list but gives that person nothing, because it refuses the keys.

6.4 Contract Join Requests

This section specifies join requests as version 0.3 made them. A version 0.4 requester asks through the group's mediator instead [Section 6.6]. The reference SDK no longer makes or answers contract join requests: the admin's device dismisses every contract request to G when it reconciles [Section 11.5].

Someone who wants to join a group asks G for a contract. The contract request [DCTRL-0003 Section 4] carries a join_request field:

{
  "note": "<optional text for the admin, at most 280 characters>"
}

join_request travels inside the encrypted request, like invitation, so the mediators see only a contract request to G, never the note. A request MUST NOT carry both invitation and join_request: an invitation comes from a group, a join request goes to one. A request carrying both is malformed, and its recipient reads it as neither.

The requester records the group as requested, with the time it asked. While the request has not lapsed, the requester takes control events from G [Section 6.2]: G can only send them over a contract it accepted, so the first control event from G shows that the admin let the requester in, and the group becomes joined. A requester that asks again while its request is still pending sends nothing: the admin never sees the same request twice. A request lapses 30 days after it was sent. If G lets the requester in after that, the requester withdraws consent: it revokes its contract with G [DCTRL-0003 Section 12], records the request as withdrawn, and refuses the control event. A revocation that fails leaves the control event pending, so it is retried. The admin's next key delivery then finds the channel revoked and removes it [Section 11.4], so no member is left without keys. A requester that withdraws its request first revokes its contract with G, then records the request as withdrawn. A revocation succeeds before any contract exists, and refuses an acceptance made more than the contract clock skew (5 minutes) before it [DCTRL-0004 Section 8]. Inside that window an acceptance can still arrive, so the requester keeps its request's key: it reads G's first control event, refuses it as withdrawn, and revokes again, now against the contract, so G's next delivery removes it.

The admin reads the pending contract requests to G on G's mediator:

  • A request carrying join_request waits for the admin's decision. To approve, the admin records the requester as invited [Section 11.1] before G accepts the request, then reconciles, which adds the requester and delivers its keys [Section 11.5]. An approval interrupted after G accepted is completed by the next reconcile, never undone: the requester is invited, and reconcile never revokes an invitee's channel. One interrupted before G accepted leaves the request pending, to be approved again. To decline, the admin acknowledges the request without accepting it. The requester is not told.
  • Every other request to G is dismissed on reconcile. G never accepts a plain contact request, so strangers cannot fill its queue with them.

To G's mediator a join request is an ordinary contract request to G. It keeps at most 1000 unacknowledged requests from other senders for a group it hosts, instead of 50 for other recipients [DCTRL-0004 Section 8.1].

A join completes only when an admin device runs a reconcile [Section 11.1].

6.5 The Admin's Own Channel

When creating a group, G sends an invitation to the admin's personal DID, and the admin accepts it on the same device [Section 11.2]. The admin then receives keys over a control channel like every other member, and keeps receiving them after it hands the group over.

6.6 Joining Through the Mediator

A version 0.4 requester asks on the group's mediator, where any approver can let it in, whether or not the admin's device is running. Its keys come as key parcels [Section 5.7]; it never gets a control channel.

The join key. For each key version, the members share an X25519 key pair derived from the group key:

function derive_group_join_key(GK_v):
    private_key = HKDF_SHA256(
        ikm    = GK_v,
        salt   = empty,
        info   = UTF8_encode("decentrl/group-join-key/v1"),
        length = 32
    )
    return (private_key, X25519_public_key(private_key))

The admin publishes the public key of the current version with SET_GROUP_JOIN_KEY [Section 7.18], in a statement:

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

signature is over the canonical JSON of every other field, by the personal key of the admin whose tenure covers key_version [Section 4.4]. The admin's device publishes a new statement after every rotation. Every member holding GK_v can derive the private key, so every member, not only staff, can open notes sealed to it. The mediator cannot.

Asking. The requester reads the group with QUERY_GROUP_JOIN_INFO [Section 7.19]. To send a note, it MUST first verify admin_chain from the genesis link [Section 4.3], which G signed, and check that the admin whose tenure covers the statement's key_version in that chain signed join_key. It then signs the note with its personal key:

{
  "type": "decentrl.group.join-note",
  "group_did": "<G>",
  "requester_did": "<the requester>",
  "text": "<at most 280 characters>",
  "signature": "<base64 Ed25519 signature over every other field>"
}

and sends REQUEST_GROUP_JOIN [Section 7.20] with

sealed_note      = seal_to(join public key, JSON.stringify(note), "decentrl/group-join-note/v1")   // [Section 5.7]
note_key_version = join_key.key_version

A requester that cannot verify a join key sends no note. It records the group as requested, through the mediator, with the time it asked.

Deciding. Approvers read the live requests with QUERY_GROUP_JOIN_REQUESTS [Section 7.21]. An approver opens a note with the join key of its note_key_version, if it holds that version, and shows it only if its group_did is G, its requester_did is the request's requester, and the requester's signature verifies. A mediator therefore cannot move a note to another request or group.

  • To decline, the approver sends DECLINE_GROUP_JOIN [Section 7.21]. The requester is not told; it only sees that its request is no longer pending.
  • To approve, the approver seals the requester's keys in parcels [Section 5.7], signs an admission, and sends both with APPROVE_GROUP_JOIN [Section 7.22] and the group's current key_version. In one step the requester becomes a member, with joined_after_seq = the group's last seq, its parcels and its admission are stored, and its request is deleted.

Admission. Version 0.6. The approver signs, with its personal key:

{
  "type": "decentrl.group.admission",
  "group_did": "<G>",
  "member_did": "<the requester>",
  "approver_did": "<the approver>",
  "key_version": 5,
  "signature": "<base64 Ed25519 signature>"
}

signature is over the canonical JSON [DCTRL-0002] of every other field, by the signing key approver_did carries: approvers are did:decentrl identities [Section 15.9]. key_version is the version the approval carries. An admission is valid for G when it is well-formed, its group_did is G, and its signature verifies. Whether its approver may let anyone in is for whoever relies on it to check: the mediator checks the sender [Section 7.22]; the admin checks the approver's grant [Section 11.7]. The admission is the admin's evidence that a member it did not add was let in by someone it trusts; the mediator cannot sign one.

An approval is allowed while rotation_due is set, so approvers keep letting people in while the admin is away: the newcomer gets the version a leaver or a removed member still holds, and the admin's next rotation reaches it by parcel. An approval never lands across a rotation: it carries the version its parcels hold, and the mediator refuses a version that is no longer current (STALE_GROUP_KEY), so no newcomer is added after a rotation whose deliveries it would miss.

When the admin adds a requester by invitation, UPDATE_GROUP_MEMBERS also deletes its request [Section 7.6].

Being let in. Non-members receive no hints [Section 9.9]. The requester polls QUERY_GROUP_JOIN_INFO:

  • is_member true: it was let in. It fetches its parcels [Section 5.7], and the first one it applies makes the group joined.
  • is_member false and my_request "none": its request was declined or lapsed. It records the request as withdrawn.

To withdraw, the requester sends CANCEL_GROUP_JOIN and records the request as withdrawn. If the answer is NO_JOIN_REQUEST and the requester is already a member (let in just before), consent is withdrawn all the same: it sends LEAVE_GROUP [Section 7.7].

Lapse. A request lapses 30 days after it was made, as in Section 6.4. A lapsed request cannot be approved (NO_JOIN_REQUEST), is dropped when approvers list the requests, and is replaced if its requester asks again.

Staff log. The mediator records each approval, decline and removal [Section 7.17] with its actor, its target and the group's last seq at the time. QUERY_GROUP returns the newest 200 entries to the admin and to grantees, so an admin coming back online sees what its staff did [Section 7.11]. Other members get none.


7. The GROUP Channel

Group commands use the GROUP mediator channel [DCTRL-0004 Section 8.14]. This section specifies their rules. DCTRL-0004 specifies their payloads, responses and HTTP statuses.

7.1 Group Record

The group's mediator keeps, per group:

FieldDescription
admin_didThe current admin
admin_chainThe verified admin links, genesis first
admin_from_seqsPer link, its from_seq [Section 4.6]; unset for groups created before version 0.3, read as [0, null, …]
historyThe history policy, copied from the genesis link at CREATE_GROUP [Section 4.5]; from_join for groups created before version 0.3
pending_linkThe next link, set by HAND_OVER_GROUP until its admin accepts; or none
key_versionThe version members must post under; 0 until the first key
rotation_dueSet by LEAVE_GROUP and ACCEPT_GROUP_ADMIN: the next membership change must rotate
deleted_atSet by DELETE_GROUP: the record stays as a tombstone
join_keyThe join key statement the admin last published [Section 6.6]; or none
MembersEach with joined_after_seq: the group's last seq when the member was added
Grant logGrants and their ends, in order, each with its from_seq [Section 4.7]
Delete scopesOne row per scoped grantee and key version [Section 4.8]. Unique per (group, grantee, key_version)
Join requestsEach with its requester, sealed_note, note_key_version and creation time [Section 6.6]. Unique per (group, requester)
Key parcelsEach with an ID, its recipient member, from_did (the approver or admin that left it), its sealed payload and creation time [Section 5.7]
Staff logApprovals, declines and removals, each with actor, target and seq [Section 6.6]
EventsEach with seq, sender_did, key_version, payload, dedupe_key, creation time, its tags [Section 8.7], kind (event, or deletion for a deletion notice), and, on a deletion notice, target_seq: the event it deleted; on a deleted event, deleted_at. Unique per (group, sender_did, dedupe_key); target_seq is unique

The group's last seq is the highest seq of any of its events, deleted events and deletion notices included, or 0 if it has none.

7.2 Addressing and Authentication

A GROUP command has header.channel = "GROUP" and header.recipient_did = G, and is sent to the group's mediator. The sender is a member's personal DID, authenticated by the normal signature check [DCTRL-0004 Section 5.4].

There is no registered-sender rule. Members may be registered on any mediator. What a sender may do is decided only by the group record:

  • Admin: sender_did is admin_did.
  • Member: sender_did is in the member list. A member may also hold a grant [Section 4.7].
  • None: anyone else. G itself is never a member, and may send nothing on this channel.

7.3 Authorization

Each command has an access class:

AccessWho may send it
outsiderAnyone but G: non-members, members and the admin
memberThe admin and members
adminThe admin only
removeThe admin, and members whose grant holds remove
approveThe admin, and members whose grant holds approve

Before any command-specific rule, the mediator MUST refuse, in this order:

ConditionError
No group record for GGROUP_NOT_FOUND
The group was deletedGROUP_DELETED
The sender is G, or is neither admin nor member and the command's access is not outsiderNOT_A_GROUP_MEMBER
The admin sends LEAVE_GROUPADMIN_CANNOT_LEAVE
A member sends an admin commandNOT_GROUP_ADMIN
A member without the capability sends a remove or approve commandNOT_PERMITTED

A member or the admin sending an outsider command passes this check; the command's own rules then answer it [Section 7.20]. What a member may delete is decided per target [Section 7.14]. A mediator SHOULD keep the access classes in one table that every command must appear in, so that no command ships without a rule.

Every command that writes (all except QUERY_GROUP, QUERY_GROUP_EVENTS, QUERY_GROUP_JOIN_INFO, QUERY_GROUP_KEY_PARCELS, QUERY_GROUP_COUNTS and QUERY_GROUP_PRESENCE) MUST be processed one at a time per group, from loading the record, the grant log and the sender's role to committing, so that neither the sender's role nor its grant can change in between and seq follows commit order [Section 9.1].

7.4 Commands

CommandAccessEffect
CREATE_GROUPG, over DIRECT_AUTHENTICATEDCreates the group with its genesis link [Section 7.5]
UPDATE_GROUP_MEMBERSadminAdds and removes members; with key_version, rotates [Section 7.6]
LEAVE_GROUPmember (not the admin)Leaves; a rotation becomes due [Section 7.7]
HAND_OVER_GROUPadminNames the next admin [Section 7.8]
ACCEPT_GROUP_ADMINmember (the named one)Takes over; a rotation becomes due [Section 7.9]
QUERY_GROUPmemberReads the group record [Section 7.11]
POST_GROUP_EVENTmemberAppends a group event [Section 7.10]
QUERY_GROUP_EVENTSmemberReads events after a cursor, before one, or by tag [Section 7.12]
DELETE_GROUP_EVENTmemberDeletes a group event and appends its signed notice [Section 7.14]
DELETE_GROUPadminDeletes the group, leaving a tombstone [Section 7.13]
SET_GROUP_GRANTadminGrants, changes or revokes a member's capabilities [Section 7.15]
SET_GROUP_GRANT_SCOPEadminSets a scoped grantee's delete scope under one key version [Section 7.16]
REMOVE_GROUP_MEMBERremoveRemoves a member; a rotation becomes due [Section 7.17]
SET_GROUP_JOIN_KEYadminPublishes the current version's join key [Section 7.18]
QUERY_GROUP_JOIN_INFOoutsiderReads what a requester needs [Section 7.19]
REQUEST_GROUP_JOINoutsiderAsks to join [Section 7.20]
CANCEL_GROUP_JOINoutsiderWithdraws the sender's request [Section 7.20]
QUERY_GROUP_JOIN_REQUESTSapproveReads the live join requests [Section 7.21]
DECLINE_GROUP_JOINapproveDeclines a join request [Section 7.21]
APPROVE_GROUP_JOINapproveLets a requester in, with its key parcels [Section 7.22]
DELIVER_GROUP_KEY_PARCELSadminLeaves parcels for a member [Section 7.23]
QUERY_GROUP_KEY_PARCELSmemberReads the sender's own parcels [Section 7.23]
ACK_GROUP_KEY_PARCELSmemberDrops the sender's own parcels [Section 7.23]
QUERY_GROUP_COUNTSmemberCounts live events by tag, sender and seq [Section 7.24]
QUERY_GROUP_PRESENCEmemberReads which members are subscribed right now [Section 7.25]

7.5 CREATE_GROUP

Sent by G itself on the DIRECT_AUTHENTICATED channel to its own mediator, with the genesis link [DCTRL-0004 Section 8.13]. The usual DIRECT_AUTHENTICATED rules apply: G must be registered there. The mediator MUST refuse:

  • with GROUP_EXISTS if a record for G exists, deleted or not;
  • with INVALID_ADMIN_LINK if the link is not a valid genesis link for G [Section 4.2] (this covers a link naming G itself as admin, and one not signed by G);
  • with NOT_INVITED if G owns no stored contract with the link's admin_did.

On success the record holds the genesis link, admin_from_seqs is [0], history is the genesis link's policy [Section 4.5], admin_did is the link's admin, key_version is 0, and the admin is the only member, with joined_after_seq 0.

7.6 UPDATE_GROUP_MEMBERS

Payload: add (DIDs), remove (DIDs), and optionally key_version. Admin only. The mediator MUST refuse:

  1. with INVALID_COMMAND if a DID is in both add and remove, or add contains G;
  2. with CANNOT_REMOVE_ADMIN if remove contains the admin;
  3. with KEY_VERSION_CONFLICT if key_version is present and is not the current version + 1;
  4. with KEY_VERSION_CONFLICT if key_version is absent and either someone is actually removed or rotation_due is set;
  5. with GROUP_FULL if the resulting group would have more than 1000 members;
  6. with NOT_INVITED if G owns no stored contract with some DID actually added. Nobody is added.

Adding a DID that is already a member, and removing one that is not, are no-ops. Removal and rotation are therefore one atomic step: nobody removed is a member at a version made after the removal. Two concurrent rotations cannot both succeed: the second gets KEY_VERSION_CONFLICT.

On success, in one transaction:

  • removed members are deleted;
  • each added member gets joined_after_seq = the group's current last seq (a re-added member gets a new one);
  • with key_version, the group's key_version becomes that version and rotation_due is cleared;
  • if the member named by pending_link is removed, pending_link is cleared;
  • removed members' waiting key parcels are deleted [Section 5.7];
  • each removed grantee holding a capability gets an ended entry with reason removed, and its delete scopes are dropped [Section 4.7];
  • an added member's pending join request is deleted: the addition answers it [Section 6.6];
  • each member actually removed gets a remove entry in the staff log, with the admin as actor, at the group's current last seq (since version 0.5: before, only removals by grantees were logged). A member who left with LEAVE_GROUP is no longer a member and is not logged. A member the admin removes after finding its control channel withdrawn is logged like any removal: the member's side of that channel may be held by another mediator, so the group's mediator cannot tell the two apart.

The response lists all members after the change and the resulting key_version: exactly the members who must receive that version.

7.7 LEAVE_GROUP

A member other than the admin leaves. The mediator deletes its membership and its waiting key parcels, sets rotation_due, and clears pending_link if it named the leaver. A grantee holding a capability gets an ended entry with reason left, and its delete scopes are dropped [Section 4.7]. An admin must hand the group over before leaving.

7.8 HAND_OVER_GROUP

Payload: link, the next admin link. Admin only. The mediator MUST refuse:

  • with INVALID_ADMIN_LINK if link is not a valid successor of the chain's last link [Section 4.2]. This requires the current admin's signature and a new admin other than G and the current admin;
  • with NEW_ADMIN_NOT_MEMBER if link.admin_did is not a member;
  • with STALE_HANDOVER if link.key_version is not the current key_version.

On success pending_link is set, replacing any earlier one. Nothing else changes. The current admin keeps authority until the named member accepts.

7.9 ACCEPT_GROUP_ADMIN

Sent by the member pending_link names. The mediator MUST refuse:

  • with NO_PENDING_HANDOVER if there is no pending_link or it names someone else (for example, a nominee whose link a later HAND_OVER_GROUP replaced);
  • with STALE_HANDOVER if pending_link.key_version is no longer the current key_version (the old admin rotated after naming the successor).

On success the link is appended to admin_chain, the group's last seq is appended to admin_from_seqs as the link's from_seq [Section 4.6], admin_did becomes the sender, pending_link is cleared, and rotation_due is set. If the sender held a grant, it gets an ended entry with reason became_admin [Section 4.7]. The new admin's first membership change must therefore rotate [Section 15.4]. For a group created before version 0.3, the earlier links' unrecorded values are kept as null.

7.10 POST_GROUP_EVENT

Payload: key_version, payload, dedupe_key, and optionally tags [Section 8.3]. Admin or member.

  1. If the sender's dedupe_key in this group belongs to a deletion notice, the mediator MUST refuse with INVALID_COMMAND.
  2. If the sender already posted an event with this dedupe_key to this group, the mediator MUST answer SUCCESS with that event's seq and duplicate: true, and store nothing. If that event has since been deleted, the response also carries deleted: true, and the sender stores no own copy [Section 8.4]. This check comes before the version check, so a retry after a lost reply still finds the original even if the key rotated in between.
  3. Otherwise, if key_version is not the group's current key_version, the mediator MUST refuse with STALE_GROUP_KEY.
  4. Otherwise the event is appended with the next seq and its tags, and the response carries the seq with duplicate: false.

payload is at most 65,536 characters. tags holds at most 32 distinct values [Section 8.7], each the base64 encoding of a 32-byte HMAC (44 characters); a list that breaks these rules is refused with INVALID_COMMAND. The mediator cannot see plaintext tags, so the plaintext limits of Section 8.7 are the sender's to keep. The mediator stores tags as opaque values and SHOULD NOT log them. Uniqueness MUST be enforced by a unique constraint on (group, sender_did, dedupe_key).

7.11 QUERY_GROUP

Returns admin_did, admin_chain, admin_from_seqs [Section 4.6], history [Section 4.5], pending_link, the member list, key_version and rotation_due, and:

FieldContents
grant_logThe whole grant log, oldest first [Section 4.7]
grant_scopesDelete scope rows [Section 4.8]: all of them for the admin, a scoped grantee's own for that grantee, none for anyone else
join_keyThe join key statement last published [Section 6.6], or null
staff_logThe newest 200 staff log entries, oldest first [Section 6.6]: for the admin and grantees; empty for other members
admissionsVersion 0.6, for the admin only: member → the admission it was let in with [Section 6.6], for each member an approver let in. Absent for everyone else

Members use it to verify the chain [Section 5.4], to pin the grant log [Section 9.6] and to find a pending handover [Section 12.4]. The admin uses it to reconcile [Section 11.5]. history is informational: clients take the policy from the genesis link. A mediator from before version 0.4 omits the four fields; clients read them as empty. A mediator from before 0.6 omits admissions; its admin accounts only for members it added or vouches for [Section 11.7].

7.12 QUERY_GROUP_EVENTS

Payload: limit (1 to 100), and optionally after_seq (≥ 0, default 0), direction ("forward", the default, or "backward"), before_seq (≥ 1) and tags (1 to 64 distinct values).

The sender's floor is the highest seq it may not read:

floor = history == "full" ? 0 : sender.joined_after_seq
DirectionReturnsOrder
forwardEvents with seq greater than both after_seq and the floorOldest first
backwardEvents with seq greater than the floor and, if before_seq is given, below before_seq. after_seq is ignoredNewest first

With tags, only events carrying at least one of the given values are returned. Every read leaves out deleted events, and deletion notices whose target_seq is at or below the floor [Section 7.14]. At most limit events are returned, with has_more when more exist.

Each event carries seq, sender_did, key_version, payload and created_at. A deletion notice also carries kind: "deletion" and its target_seq. Ordinary events carry neither, so their rows keep the version 0.2 shape.

A mediator MAY number the events of all the groups it hosts from one sequence, as the reference mediator does. A group's seq values then increase but are not consecutive, and the mediator MUST look events up by (group, seq), never by seq alone. In a from_join group a member never receives an event from before it was added [Section 15.5].

7.13 DELETE_GROUP

Admin only. The mediator 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 pending_link, and sets deleted_at. The record stays as a tombstone: every later GROUP command gets GROUP_DELETED, and CREATE_GROUP gets GROUP_EXISTS. Whoever still holds G's keys cannot create the group again.

7.14 DELETE_GROUP_EVENT

Payload: target_seq (≥ 1), key_version, payload (the notice [Section 8.6]), dedupe_key, and optionally tags, with the same limits as POST_GROUP_EVENT. Admin or member. The mediator MUST, in this order:

  1. If the sender's dedupe_key in this group belongs to an ordinary event, refuse with INVALID_COMMAND. If it belongs to one of the sender's deletion notices, answer SUCCESS with that notice's seq and duplicate: true, and change nothing.
  2. Look up the target by (group, target_seq), and refuse:
    • with EVENT_NOT_FOUND if there is no such event in this group, or if it is at or below the sender's floor [Section 7.12] and the sender is not the admin;
    • with NOT_DELETABLE if the target is a deletion notice;
    • with EVENT_ALREADY_DELETED if the target was already deleted. A client treats this as success.
  3. Allow the deletion if the sender is the target's sender_did or the current admin. Otherwise refuse:
    • with NOT_EVENT_AUTHOR if the sender's grant holds no delete (the answer a member without a grant got before version 0.4);
    • with TARGET_IS_STAFF if the target's sender_did is staff [Section 4.9];
    • with NOT_PERMITTED if the grant holds delete: "scoped" and none of the target's stored tag values is among the sender's scope values for the target's key version [Section 4.8], or the sender has no scope row for that version.
  4. Refuse with STALE_GROUP_KEY if key_version is not the group's current version.

A sender that is not a member was already refused with NOT_A_GROUP_MEMBER [Section 7.3]: an author may delete its events only while it is a member. The mediator reads the target's tag values before it erases them. An event from before version 0.4 carries only the value of each full tag, so a scope reaches it only through a tag that equals one of the scope's prefixes.

On success, in one transaction:

  • the target's payload becomes empty and its deleted_at is set; the row itself is kept;
  • the target's tags are removed;
  • the notice is appended with the next seq, kind deletion, target_seq, and its own tags.

The response carries the notice's seq with duplicate: false. target_seq is unique over all notices, so concurrent deletions of one event leave exactly one notice. DELETE_GROUP_EVENT counts toward the same rate limit as POST_GROUP_EVENT [Section 7.26].

The notice row is kept, unlike a deleted public event [DCTRL-0007]: readers following the log by seq learn of the deletion from it [Section 9.6].

7.15 SET_GROUP_GRANT

Payload: grant, a signed grant [Section 4.7]. Admin only. The mediator MUST refuse with:

  • INVALID_GRANT if grant is not valid under the chain's last link [Section 4.7]: it must be signed by the current admin, name the current admin_epoch, and name neither the admin nor G;
  • GRANTEE_NOT_MEMBER if grant.grantee_did is not a member;
  • INVALID_GRANT if grant.not_before_seq is above the group's last seq;
  • INVALID_GRANT if grant is a replay against the whole grant log [Section 4.7]: its signature is already logged, or its not_before_seq is below the from_seq of the grantee's latest entry of any kind. Readers would drop it [Section 9.6].

On success, in one transaction, the mediator appends { kind: "grant", grant, from_seq } to the grant log, with from_seq = the group's last seq, and drops the grantee's delete scopes. A grant with delete: "scoped" gets its rows written anew by the admin [Section 4.8], so a narrowed scope never keeps its old reach. The response carries from_seq. Empty capabilities revoke.

7.16 SET_GROUP_GRANT_SCOPE

Payload: scope, a delete scope row [Section 4.8]: grantee_did, key_version (≥ 1), values (at most 64 tag values) and sealed_prefixes. Admin only. The mediator MUST refuse with:

  • INVALID_GRANT if the grantee's current grant does not hold delete: "scoped";
  • KEY_VERSION_CONFLICT if key_version is above the group's current version.

On success the row for (grantee_did, key_version) is created or replaced. The mediator cannot check values against sealed_prefixes: both come from the admin, and a grantee reading its own row back sees the prefixes the admin sealed.

7.17 REMOVE_GROUP_MEMBER

Payload: member_did. Sent by the admin or a grantee holding remove (NOT_PERMITTED for other members). The mediator MUST refuse, in this order, with:

  • INVALID_COMMAND if member_did is the sender;
  • CANNOT_REMOVE_ADMIN if member_did is the admin;
  • NOT_MEMBER if member_did is not a member;
  • TARGET_IS_STAFF if the sender is not the admin and member_did is staff [Section 4.9].

On success, in one transaction, it does what LEAVE_GROUP does for the removed member [Section 7.7], with reason removed for an ended entry: deletes the membership and the member's waiting parcels, sets rotation_due, clears pending_link if it named the member, and ends its grant. It also appends a remove entry to the staff log.

The removed member reads nothing more from then on [Section 7.12], but it still holds the current version until the admin's next rotation, which the admin's device makes on its next reconcile [Section 11.5].

7.18 SET_GROUP_JOIN_KEY

Payload: statement, a join key statement [Section 6.6]. Admin only. The mediator MUST refuse with:

  • KEY_VERSION_CONFLICT if statement.key_version is not the group's current version;
  • INVALID_COMMAND if statement is not for G or is not signed by the admin whose tenure covers statement.key_version in the chain [Section 4.4].

On success it replaces the stored statement. Right after a handover, the current version still belongs to the former admin's tenure, so the new admin publishes only after its forced rotation [Section 12.4]; the former admin's statement stays valid until then.

7.19 QUERY_GROUP_JOIN_INFO

No payload fields. Open to anyone but G, and read-only. The response carries:

FieldContents
join_keyThe stored join key statement, or null until the admin publishes one
admin_chainThe admin chain, for the requester to verify from genesis [Section 6.6]
historyThe history policy, informational [Section 4.5]
member_countThe number of members
my_request"pending" if the sender has a request that has not lapsed, else "none"
is_memberWhether the sender is a member, the admin included

7.20 REQUEST_GROUP_JOIN and CANCEL_GROUP_JOIN

REQUEST_GROUP_JOIN payload: optionally sealed_note (at most 4096 characters) and note_key_version (≥ 1) [Section 6.6]. Open to anyone but G. It counts toward the same rate limit as POST_GROUP_EVENT [Section 7.26]. The mediator MUST refuse, in this order, with:

  1. ALREADY_A_MEMBER if the sender is a member or the admin;
  2. JOIN_REQUEST_EXISTS if the sender has a request that has not lapsed;
  3. GROUP_FULL if the group has 1000 members;
  4. JOIN_QUEUE_FULL if the group already holds 1000 requests that have not lapsed;
  5. INVALID_COMMAND if sealed_note is present and no join key is published, note_key_version is absent, or note_key_version is above the published statement's key_version; or if note_key_version is present without sealed_note.

On success the request is stored with its note and the current time. A lapsed request of the sender's own is replaced. The mediator cannot open the note.

CANCEL_GROUP_JOIN has no payload fields. Open to anyone but G. It deletes the sender's request, lapsed or not, and answers NO_JOIN_REQUEST if there is none, for example because an approver let the sender in first [Section 6.6].

7.21 QUERY_GROUP_JOIN_REQUESTS and DECLINE_GROUP_JOIN

Both are for approvers: the admin and grantees holding approve.

QUERY_GROUP_JOIN_REQUESTS has no payload fields. It returns the requests that have not lapsed, oldest first (a request renewed after lapsing counts from its renewal), each with requester_did, sealed_note and note_key_version (both null without a note), and created_at. It deletes lapsed requests on the way, so it is processed as a write [Section 7.3].

DECLINE_GROUP_JOIN payload: requester_did. The mediator refuses with NO_JOIN_REQUEST if the group holds no request from requester_did. On success, in one transaction, it deletes the request and appends a decline entry to the staff log.

7.22 APPROVE_GROUP_JOIN

Payload: requester_did, key_version (≥ 1), parcels: 1 to 20 key parcels sealed to the requester [Section 5.7], and admission (version 0.6): an admission signed by the sender [Section 6.6]. For approvers. The mediator MUST refuse, in this order, with:

  1. INVALID_ADMISSION if admission is missing or not valid for G, or its approver_did is not the sender, its member_did is not requester_did, or its key_version is not the payload's key_version;
  2. ALREADY_A_MEMBER if requester_did is a member;
  3. NO_JOIN_REQUEST if the group holds no request from requester_did that has not lapsed;
  4. STALE_GROUP_KEY if key_version is not the group's current version;
  5. GROUP_FULL if the group has 1000 members.

With steps 1 and 4, the admission names the group's current version.

rotation_due does not block an approval [Section 6.6]. On success, in one transaction, the mediator:

  • adds requester_did as a member, with joined_after_seq = the group's last seq, and keeps admission with the membership, until it ends;
  • stores each parcel for it, with the sender as from_did;
  • deletes the request;
  • appends an approve entry to the staff log, at that seq.

The mediator cannot open the parcels. That they hold the current key is the approver's duty [Section 5.7]; a newcomer whose parcels do not open, or hold no acceptable key, is a member that cannot read until the admin's next delivery.

7.23 Key Parcel Commands

DELIVER_GROUP_KEY_PARCELS payload: member_did and parcels, 1 to 20 key parcels [Section 5.7]. Admin only. The mediator MUST refuse with NOT_MEMBER if member_did is not a member, and with TOO_MANY_PARCELS if the member would have more than 200 parcels waiting. On success each parcel is stored for member_did, with the admin as from_did.

QUERY_GROUP_KEY_PARCELS has no payload fields. Admin or member. It returns the parcels waiting for the sender, in the order they were stored, each with id, from_did, payload and created_at. A mediator MUST keep that order, parcels stored by one command included: keys come before the handover that needs them [Section 10.2]. The reference mediator numbers parcels from one increasing sequence and uses it as id.

ACK_GROUP_KEY_PARCELS payload: ids, 1 to 200 parcel IDs. Admin or member. The mediator deletes the listed parcels that wait for the sender in this group, ignores every other ID, and answers SUCCESS.

Parcels do not expire. A member's parcels are deleted when it acknowledges them, leaves, or is removed (by either command), and when the group is deleted.

7.24 QUERY_GROUP_COUNTS

Payload: queries, 1 to 32 counts, each with:

  • id: 1 to 64 characters, unique in the command; the count's key in the answer;
  • optionally tags: 1 to 64 tag values; an event counts if it carries any of them;
  • optionally sender_did: only events that member posted;
  • optionally after_seq: only events with a higher seq.

Admin or member. The mediator answers counts, the number of events matching each query by its id. Only live events count: a deleted event, a deletion notice (which carries its target's tags [Section 8.6]), and any event at or below the sender's floor [Section 4.5] never do, so a count covers exactly what the sender could read.

A reader counts a plaintext tag as it reads one [Section 9.7]: it asks for the tag's value under every key version it holds, in one query, and since an event carries the value of every prefix of its tags [Section 8.7], the count is of every event at or below that tag. A reader holding more than 64 versions splits a count into several queries and adds up the answers. A count is a statement by the mediator about rows the reader has not checked; it is fit for display, not for decisions.

7.25 QUERY_GROUP_PRESENCE

No payload fields. Admin or member. The mediator answers online: the members that hold a live subscription to the group's hints on this mediator [DCTRL-0004 Section 10], in membership order, each once. Subscriptions are the mediator's own connection state, so a mediator running as several instances answers for the instance that received the command, or shares that state. A member's presence is what it shows by subscribing; a member that wants to be invisible does not subscribe and polls instead [Section 9.9].

7.26 Limits

LimitValue
Members per group1000
DIDs in add or remove of one command1000 each
POST_GROUP_EVENT and DELETE_GROUP_EVENT payload65,536 characters
Plaintext tags on one event8, each of at most 4 segments [Section 8.7]
Tag values on one event or notice32
Tag values in one QUERY_GROUP_EVENTS64
QUERY_GROUP_EVENTS page100 events
POST_GROUP_EVENT, DELETE_GROUP_EVENT and REQUEST_GROUP_JOIN rate120 per sender DID per minute, the three commands together, across all groups (RATE_LIMIT_GROUP_EVENTS_PER_MINUTE) [DCTRL-0004 Section 5.5]
Certificates in one decentrl.group.keys event50
Join request note280 characters; 4096 characters sealed
Pending join requests per group1000
Delete scope prefixes, and values in one scope row64
Parcels in one APPROVE_GROUP_JOIN or DELIVER_GROUP_KEY_PARCELS20
One parcel262,144 characters
Parcels waiting for one member200
Staff log entries in QUERY_GROUPThe newest 200
Counts in one QUERY_GROUP_COUNTS, and tag values in one count32, and 64
Unacknowledged contract requests to a group from other senders1000 [DCTRL-0004 Section 8.1]

8. Posting Group Events

8.1 Group Envelope

The sender builds a signed envelope:

{
  "type": "decentrl.group.event",
  "version": 1,
  "group_did": "<G>",
  "key_version": 3,
  "event_id": "<event ID>",
  "event": "<the event, JSON-serialized>",
  "timestamp": 1699123456,
  "tags": ["<plaintext group tag>"],
  "signature": "<base64 Ed25519 signature>"
}
FieldTypeDescription
typestringMUST be "decentrl.group.event". Keeps the envelope from passing as a 1:1 envelope [DCTRL-0005 Section 4.2], a key certificate, an admin link or a public post
versionintegerGroup envelope version. MUST be 1
group_didstringThe group
key_versionintegerThe version whose key encrypts the envelope
event_idstringThe sender's event ID [DCTRL-0005 Section 3.2]
eventstringThe application event, serialized
timestampnumberUnix seconds
tagsstring[], OPTIONALThe event's plaintext group tags [Section 8.7]. Present only when the event has at least one
signaturestringEd25519 signature by the sender's personal signing key over the canonical JSON of every other field

Before serializing the event, the sender sets its meta.senderDid to its own DID, meta.eventId to event_id, and meta.groupDid to G.

An envelope without tags is exactly a version 0.2 envelope. A version 0.2 reader verifies the signature over every field it finds, so it also opens a tagged envelope.

8.2 Payload

payload = AES_GCM_encrypt(JSON.stringify(envelope), GK_v)       // [DCTRL-0002]

8.3 Command

The sender sends POST_GROUP_EVENT to the group's mediator:

{
    type:        "POST_GROUP_EVENT",
    key_version: v,
    payload:     payload,
    dedupe_key:  generate_encrypted_tag("group|" + G + "|" + event_id,
                                        derive_tag_key(sender.storage_key)),
    tags:        group_tag_values(envelope.tags, GK_v)             // omitted when empty [Section 8.7]
}

key_version is also a plain field, so the mediator can enforce it and a reader can pick the key without decrypting. dedupe_key is a keyed hash of the same kind as an encrypted tag [DCTRL-0002 Section 11]. It is the same for every retry of the event, including retries across a rotation, and it reveals nothing about the event ID.

8.4 Own Copy

Only after the mediator accepted the post (SUCCESS, new or duplicate) does the sender store its own copy on its own mediator with SAVE_EVENTS [DCTRL-0004 Section 8.10]:

  • payload: the bound event, encrypted with the sender's storage key, with meta.groupSeq and meta.groupTags set as a reader sets them [Section 9.4];
  • tags: the application's tags plus group.<G>;
  • dedupe key over the label group|<G>|<sender DID>|<event ID> [Section 9.5].

A refused post stores nothing. If storing the own copy fails after the post succeeded, the sender learns the event ID and seq. A retry with the same event ID is a duplicate on the group's mediator and stores the own copy, unless the mediator reports that the event was deleted in the meantime (deleted: true): then nothing is stored.

8.5 Posting Under the Current Version

A member MUST post under the group's current key version. The mediator refuses any other version with STALE_GROUP_KEY [Section 7.10]. On STALE_GROUP_KEY the sender SHOULD process its pending 1:1 events, which delivers the newer key, and then retry with the same event ID.

Version 0.1 of this specification let old and new versions coexist after a rotation (0.1 Section 10.3). This version deliberately drops that: key versions only rise along seq. A member who was removed cannot keep posting under a version it still holds. Readers can also tell a key that is still on its way from one they will never get [Section 9.3].

8.6 Deleting an Event

An event may be deleted by its author while the author is a member, by the current admin, or by a grantee whose delete reaches it [Section 7.14]. The deleter posts a signed deletion notice with DELETE_GROUP_EVENT [Section 7.14]. The notice is an ordinary group envelope [Section 8.1], sealed under the current version, whose event is:

{
  "type": "decentrl.group.deletion",
  "data": {
    "target_seq": 42,
    "target_sender_did": "<the deleted event's sender>",
    "target_event_id": "<the deleted event's event ID>"
  },
  "meta": { "senderDid": "<deleter>", "eventId": "<the notice's own event ID>", "groupDid": "<G>", "timestamp": 1699123456000 }
}

target_event_id is OPTIONAL. A deleter that cannot read the target, for example an admin that holds no key for its version, leaves it out. The mediator still erases the target, but readers hide nothing on their side [Section 9.6].

The envelope's tags are the target's plaintext tags, and the command's tags their prefix values under the current version [Section 8.7], so that readers by those tags also see the notice. A target an older client tagged against today's rules [Section 8.7] can still be deleted: the notice carries only those of its tags that are valid, at most 8, and a reader by an invalid tag does not see it.

{
    type:        "DELETE_GROUP_EVENT",
    target_seq:  target.seq,
    key_version: v,
    payload:     AES_GCM_encrypt(JSON.stringify(notice_envelope), GK_v),
    dedupe_key:  generate_encrypted_tag("group-delete|" + G + "|" + target.seq,
                                        derive_tag_key(sender.storage_key)),
    tags:        group_tag_values(target.tags, GK_v)                // omitted when empty
}

The dedupe key names the target, so every retry of one deletion by one sender is a duplicate of the first. EVENT_ALREADY_DELETED means someone else deleted the target first; the deleter treats it as success. No member stores an own copy of a notice.

A deletion erases the event from the group's mediator only. Copies members already stored on their own mediators are not erased [Section 15.11].

8.7 Group Tags

A sender may attach plaintext tags to an event, for example to place it in a forum and a thread. Members can then read the group by tag [Section 9.7]. Tags are signed plaintext inside the encrypted envelope [Section 8.1].

Format. A tag is one or more segments separated by ., naming a place from the general to the specific: forum.news.t42. When publishing, the sender MUST keep to these rules:

  • no segment is empty (forum..t42 is refused);
  • a tag has at most 4 segments;
  • an event carries at most 8 distinct tags.

The rules apply only when publishing. A reader opens older events whose tags break them, and they match their own prefixes. An application that builds a tag from data MUST NOT let a value add a segment: the reference SDK refuses a template value containing ..

Values. The mediator receives only keyed hashes, under the key version of the event, of every cumulative prefix of every tag:

function derive_group_tag_key(GK_v):
    return HKDF_SHA256(
        ikm    = GK_v,
        salt   = empty,
        info   = UTF8_encode("decentrl/group-tag-key/v1"),
        length = 32
    )

function group_tag_value(tag_string, GK_v):
    return generate_encrypted_tag(tag_string, derive_group_tag_key(GK_v))   // [DCTRL-0002 Section 11.2]

function prefixes(tag):                                          // "a.b.c" → "a", "a.b", "a.b.c"
    segments = tag.split(".")
    return [segments[0 .. i].join(".") for i in 0 .. length(segments) - 1]

function group_tag_values(tags, GK_v):
    return [group_tag_value(p, GK_v) for p in unique(flatten(prefixes(t) for t in tags))]

The prefixes are deduplicated across tags, so an event holds at most 32 values. A value is 44 base64 characters. The value of a full tag is exactly the value version 0.3 computed for it, so exact reads of older events keep working.

For example, an event tagged forum.news.t42 and forum.pinned is sent with four values: those of forum, forum.news, forum.news.t42 and forum.pinned. A read of forum.news asks for one value and finds the event. So does a read of forum. A read of forum.news.t7 does not.

The tag key is separate from the group key and from personal tag keys [DCTRL-0002 Section 11]. Members holding version v can compute and match tag values of version v. A member removed before v can compute nothing for it.

The same tag has a different value under each version. The mediator can see which events of one version share a tag value, and, since every prefix has its value, which share a category, but not the tags themselves [Section 15.8].


9. Reading Group Events

9.1 Canonical Order

The group's mediator assigns every stored event, deletion notices included, a seq. Because every write to a group is processed one at a time [Section 7.3], seq strictly increases in commit order. Every member reads the same events in the same order. The reader's cursor is the seq of the last row it has finished with.

9.2 Reading a Page

The reader sends QUERY_GROUP_EVENTS with its cursor as after_seq [Section 7.12] and handles the returned rows in seq order with the rule of Section 9.3. A new member starts from cursor 0: the mediator applies the member's floor, so it receives the whole log in a full group and the events after it was added in a from_join group.

If the mediator answers NOT_A_GROUP_MEMBER, the reader has been removed. If it answers GROUP_DELETED or GROUP_NOT_FOUND, the group is gone.

Rows in order. Before the reader rule, the reader checks each row against the last row of the page it kept, in the page's direction (oldest first here, newest first for a backward read [Section 9.8]). A row is dropped and logged when:

  • its seq does not strictly follow the last kept row's: a repeated or a going-back seq;
  • forward, its seq is at or below after_seq; backward, it is at or above before_seq, when one was sent;
  • its key_version falls as seq rises [Section 8.5]: forward, below the last kept row's; backward, above it. An equal version is fine.

Such a row can only be a replay or a mediator's error. Dropping it never moves the cursor back: it moves past the row only if the row lies beyond it in the page's direction. A page on which the cursor did not move at all reports that there is nothing more to read, even if the mediator's has_more was true, so a mediator cannot keep a reader asking for the same page.

9.3 Reader Rule

For each row kept in order [Section 9.2], by its plain key_version:

The reader holdsActionCursor
The key of this versionOpen the row [Section 9.4]Moves past it
No key at allStop the pageStays before this row
Keys, but this version is above the highest heldStop the page: the key is on its wayStays before this row
Keys, and this version is below the highest held but not heldSkip the row and log it: a rotation this member missedMoves past it

Opening a row:

  1. Decrypt payload with the version's key.
  2. Parse the envelope. type MUST be "decentrl.group.event", version MUST be 1, event MUST be a string, event_id a valid event ID, and tags, if present, a list of strings.
  3. Verify signature with the signing key of the row's sender_did: the sender the group's mediator authenticated.
  4. Check that group_did is G and key_version equals the row's key_version.
  5. Parse event. On a row of kind deletion, handle it as a notice [Section 9.6]. On an ordinary row, drop an event whose type starts with decentrl.: reserved types are read only from their own row kind and never reach the application.
  6. Bind the event's metadata [Section 9.4].

A row that fails any step is dropped and logged, and the cursor moves past it: it can never become readable. A row stopped on by the reader rule is read again on the next sync.

A page that stopped reports that there is nothing more to read now, even if the mediator's has_more was true.

9.4 Metadata Binding

Before an opened event is stored or handed on, the reader MUST set:

  • meta.senderDid to the row's sender_did,
  • meta.eventId to the envelope's event_id,
  • meta.groupDid to G,
  • meta.groupSeq to the row's seq: where the group's mediator placed the event, and what a deletion names [Section 8.6],
  • meta.groupTags to the envelope's signed tags, when it has any.

A differing claimed value is overwritten, and implementations SHOULD log it. An event that is not a JSON object with a meta object is dropped.

meta.groupDid is set only here. A 1:1 event never carries it: the 1:1 path binds it to nothing and removes any claimed value [DCTRL-0005 Section 9.4]. A former member therefore cannot slip a 1:1 message into a group's view by naming the group.

9.5 Own Copies

Each event opened by a forward read without tags (a sync) is stored on the reader's own mediator with SAVE_EVENTS, encrypted with the reader's storage key, with the tag group.<G> and the dedupe key over the label:

group|<G>|<sender DID>|<event ID>

The label is kept apart from the 1:1 label event|<sender DID>|<event ID> [DCTRL-0005 Section 9.5], so a group event and a 1:1 event with the same ID never collide. The group's history then lives on the member's own mediator as well, and stays readable if the group's mediator goes away.

No member keeps a copy of an event marked meta.ephemeral: true, the sender included [DCTRL-0005 Section 12]; the group's mediator still holds its ciphertext, as for every group event. If storing fails, the page stops before that event, so it is read again.

Both STORED and DUPLICATE events are handed to the application, which dedupes on (meta.senderDid, meta.eventId) as for 1:1 events [DCTRL-0005 Section 9.5].

A sender controls its own dedupe_key, so it could post two different events with the same event ID. Every member sees both in the same seq order, and each member's own store keeps the first.

Reads by tag [Section 9.7] and backward reads [Section 9.8] store nothing. Deletion notices are never stored.

9.6 Deletions

A reader acts on a deletion notice only if all of the following hold:

  1. It arrived on a row of kind deletion, kept in order [Section 9.2]. A decentrl.group.deletion event on an ordinary row is dropped [Section 9.3].
  2. Its event is { type: "decentrl.group.deletion", data: { target_seq, target_sender_did, target_event_id? } }, with target_seq an integer, target_sender_did a string and target_event_id, if present, a valid event ID.
  3. Its signed target_seq equals the row's target_seq. A notice copied onto another row deletes nothing.
  4. Its signer (the row's sender_did) is one of:
    • target_sender_did;
    • the admin at the notice's own seq and key version, admin_at_seq(chain, from_seqs, notice.seq, notice.key_version) [Section 4.6], when notice.key_version is one that admin could post under: within its tenure [Section 4.4], or the version current at its handover (link_k.key_version), which it posts under until it rotates. Admin k's notices therefore carry a version in [link_k.key_version, link_{k+1}.key_version], with no upper bound for the newest admin. The admin is taken by its epoch, not its DID alone, since one DID may hold several epochs;
    • a grantee whose grant at the notice's seq holds delete, when target_sender_did was not staff at that seq: grant_at_seq(log, signer, notice.seq).delete is set and target_sender_did is not in staff_at_seq(log, chain, from_seqs, notice.seq, notice.key_version).

A notice that fails steps 1 to 3 is dropped. One that fails step 4 is ignored and logged. Because the admin or a grant at the notice may be newer than what the reader holds, a reader SHOULD read the group once more (QUERY_GROUP, verified and pinned as in Section 5.4 and below) before ignoring a notice for step 4.

When a notice holds, the reader hides the event (target_seq, target_sender_did, target_event_id): a row is hidden only when its meta.groupSeq, meta.senderDid and meta.eventId all match. The pair (target_sender_did, target_event_id) is the one the application dedupes on [Section 9.5]; the seq pins it to one place in the log. It never hides by seq alone: another event at that seq, from a replayed or misplaced row, stays. A notice without target_event_id hides nothing on the reader's side. A member without a grant naming another member's event in its own notice fails step 4, so it cannot hide that event.

The grant log on the reader's side. A reader pins the grant log it read, like the chain [Section 4.3]. A newer log MUST start with exactly the entries the reader holds, compared by kind, from_seq and the grant's signature (or, for an end, the grantee and reason); otherwise it is rejected (NOT_AN_EXTENSION). A mediator can append to the log, but cannot drop a revocation or an end it reported, which would keep a grantee's power alive.

Before using the log, the reader verifies it. It keeps every ended entry, since an end only takes power away, and keeps a grant entry only if:

  1. the link chain[grant.admin_epoch] exists;
  2. that admin was in office where the entry sits: from_seqs[epoch] ≤ entry.from_seq ≤ from_seqs[epoch + 1], where a missing or null bound passes [Section 4.6];
  3. the grant is not a replay against the entries kept before it, is_grant_replay(kept_so_far, grant) [Section 4.7]: its signature was not kept already, and its not_before_seq is not below the from_seq of the grantee's latest kept entry of any kind. A grant replayed after it was revoked or replaced, or after the grantee left or was removed, is dropped. The mediator refuses the same grants [Section 7.15];
  4. the grant is valid under that link [Section 4.7].
function grant_at_seq(log, grantee, seq):            // the verified log, oldest first
    current = none
    for entry in log while entry.from_seq < seq:
        if entry names grantee:
            current = entry.kind == "grant" and entry.grant.capabilities is not empty
                          ? entry.grant.capabilities : none
    return current

function staff_at_seq(log, chain, from_seqs, seq, v):
    return { admin_at_seq(chain, from_seqs, seq, v) }
         ∪ { g : g is named in log and grant_at_seq(log, g, seq) is not none }

staff_at_seq cannot include a member named by a pending handover link, since readers do not see pending links in the past. The mediator, which does, refuses a grantee's deletion of that member's events [Section 7.14].

Readers do not check scope. Whether a scoped grant reaches the target is checked by the mediator only [Section 7.14]. A reader cannot check it without the grantee's prefixes, and has little reason to: the notice only hides an event the mediator already stopped serving, a reader that never saw the target has nothing to hide, and a mediator colluding with a grantee to break a scope could as well withhold the event from everyone. Readers do check the capability and staff protection, which bind a grantee even to a dishonest mediator.

The mediator checks the deleter's role and grant when the notice is posted [Section 7.14]; readers check them again, because they do not trust the mediator for authority. The mediator itself already stops serving the deleted event. In a from_join group it also withholds notices whose target is at or below the reader's floor, since those events are not the reader's to see [Section 15.11].

The cursor moves past a notice like past any row.

9.7 Reading by Tag

A reader asks for the events carrying any of a set of plaintext tags. A tag has one value per key version [Section 8.7], so the reader expands each tag under each version it holds, from the version its tag cursor is at upward. The values go to QUERY_GROUP_EVENTS in chunks of at most 64 [Section 7.12]. A chunk holds whole versions: it is split only between versions.

Chunks are read in version order. An event's key version never decreases as its seq increases [Section 8.5], so reading chunk by chunk reads the log in seq order. A read by tag keeps its own cursor, the seq and the key version it has reached, and never moves the sync cursor [Section 9.2].

Every read by tag is a prefix read: a tag's value finds every event carrying it or a tag below it [Section 8.7]. The reader SHOULD refuse a query tag that breaks the format of Section 8.7.

Rows are handled with the reader rule [Section 9.3], with one more check: a row is dropped unless one of its signed tags falls under one of the requested tags, that is, equals it or starts with it followed by .. The mediator matched the values, the reader matches what the sender signed, by whole segments: forum.newsletter does not fall under forum.news. Deletion notices carry the target's tags [Section 8.6], so readers by tag see them too.

An event from before version 0.4 carries only the values of its full tags, so a read of a shorter prefix does not find it; a read of the exact tag does.

A read by tag MAY also go backward [Section 9.8]: the chunks are then read from the highest version down.

9.8 Reading Backward

A backward read (direction: "backward") returns events newest first, below before_seq or from the newest [Section 7.12]. It is for viewing what came before, such as older messages, and:

  • it never moves the sync cursor and stores no own copies;
  • it stops at the reader's floor: has_more is false once the floor is reached;
  • rows whose key the reader does not hold are left out instead of stopping the page;
  • deletion notices on the page are honoured as in Section 9.6.

The next page continues below the lowest seq on the page, including rows left out.

9.9 Push Hints

A group's mediator can tell subscribed members over WebSocket that something happened [DCTRL-0004 Section 10]:

  • GROUP_EVENTS { group_did, seq } after a new event or deletion notice;
  • GROUP_UPDATED { group_did } to every member after a membership change or rotation, a leave, a handover, its acceptance, the group's deletion, a grant, a removal, or an approval;
  • GROUP_UPDATED { group_did } to approvers only (the admin and grantees holding approve) after a join request is made, withdrawn or declined;
  • GROUP_UPDATED { group_did } to the recipient only after the admin delivers it parcels;
  • GROUP_UPDATED { group_did } to the grantee only after the admin sets its delete scope (since version 0.5), so it reads back a scope written after the grant it already heard of.

Hints carry no content. The mediator checks membership when it sends a hint, so only current members receive hints. Members removed by a command, including every member when the group is deleted, receive that command's GROUP_UPDATED once, and their subscriptions are then dropped. Requesters are not members and receive no hints: they poll QUERY_GROUP_JOIN_INFO [Section 6.6]. SET_GROUP_JOIN_KEY and ACK_GROUP_KEY_PARCELS send no hint. A member that receives a hint reads from its own cursor as in Section 9.2: a hint is never a cursor. Polling QUERY_GROUP_EVENTS remains the fallback.


10. Control Events

10.1 Reserved Types

TypedataSent to
decentrl.group.keyA key certificate [Section 5.2]A member that needs that version
decentrl.group.keys{ "certificates": [ … ] }: 1 to 50 key certificates, ascending [Section 5.3]A member that needs those versions
decentrl.group.handoverA handover bundle [Section 12.1]The member the handover names

G sends control events as ordinary private events over the control channel [DCTRL-0005 Section 8], marked ephemeral (meta.ephemeral: true) and without self-storage. G's own mediator therefore never holds a copy of a key or a bundle in the clear. To a member without a control channel, the same events go as key parcels, sealed to the member's DID key [Section 5.7].

decentrl.group.deletion is also reserved, but it is not a control event: it travels only inside a group envelope, on a deletion row [Section 8.6].

10.2 Receiving

The receiver's event store passes every event whose type starts with decentrl. to a reserved-event handler instead of storing it or returning it to the application [DCTRL-0005 Section 9.6]. The pending event is acknowledged only after the handler returns:

Handler resultOutcome
appliedAcknowledged
rejected (wrong sender, a group neither joined nor asked to join, unknown reserved type, bad certificate or bundle)Acknowledged and dropped, with a log line
Throws (persisting failed, the fresh QUERY_GROUP failed)Left pending, with its epoch key kept, and retried on the next sync
No handler attachedAcknowledged and dropped, with a warning

A handler MUST throw only for failures worth retrying. Otherwise any contact could jam the receiver's inbox with reserved events that never go away.

A member applies control events one at a time, in the order the sender sent them. Key parcels are applied in the order the mediator stored them, and follow the same outcomes: a parcel is acknowledged when applied or rejected, and stays when its handler throws [Section 5.7]. A reserved event from a sender MUST stay pending while an earlier event from the same sender stays pending, whether that event could not be opened yet, could not be stored, or its handler threw. The receiver keeps this hold-back across events pushed over WebSocket until its next complete drain, which sees every pending event again, oldest first. Together with the admin's ordered delivery [Section 5.3], a member never takes version n while an earlier version it is owed is still on its way [Section 5.4].

10.3 Attaching the Handler

The member's handler MUST be attached before its 1:1 client first processes pending events, whether by a drain or by events pushed over WebSocket. A key certificate processed without a handler is dropped, and the member would have to wait for the admin to deliver it again. The reference SDK refuses to attach a handler after the first sync (RESERVED_HANDLER_TOO_LATE).


11. Administration

11.1 Admin State

The admin's device persists:

FieldDescription
groupDidG
identityG's identity, with its private keys
keyStatesKey state of each control channel, by contract ID [DCTRL-0003 Section 10.2]
ephemeralKeysEphemeral keys of control-channel requests not yet answered
invitesDIDs invited or approved to join, and not yet added, each with the time it was invited ({ did, at }). State written before version 0.3 (and the admin state of a handover bundle from such an admin) holds bare DIDs, read as invited when loaded, by the admin's clock
pendingDeliveryMember → the first key version it still needs; advanced after each delivered batch
grantPrefixesEach scoped grantee's delete scope prefixes, in plaintext [Section 4.8]
pendingRotationA rotation persisted before the mediator committed it: its certificate and the DIDs it removes
rosterVersion 0.6: the members the admin accounts for, never the admin itself [Section 11.7]. Keys go to them alone
removedAtVersion 0.6: member → the key version this admin, or one before it, removed it with. An admission naming an older version no longer accounts for it

The admin's own member state holds the certificates [Section 5.5]. The handover bundle carries the same admin state without pendingRotation [Section 12.1].

An admin device runs one operation at a time. While no admin device is running, members can still post, read and delete, and grantees can delete, remove members and answer join requests made through the mediator [Section 6.6]. But invitees are not added, contract join requests [Section 6.4] are not answered, grants do not change, keys do not rotate, and the admin's deliveries wait.

11.2 Creating a Group

function create_group(admin, alias, group_mediator_did, history = "from_join"):
    G = create_decentrl_identity(alias, group_mediator_did)
    register_with_mediator(G)                                  // [DCTRL-0003 Section 9]

    // G's control channel to the admin, accepted on the same device [Section 6.5]
    request_contract(from: G, to: admin.did, invitation: sign_invitation(admin))
    accept_invitation(admin, request)

    genesis = sign_admin_link(G, epoch: 0, admin: admin.did, key_version: 0,
                              history: history == "full" ? "full" : absent,   // [Section 4.5]
                              key: G.signing_private_key)
    CREATE_GROUP(from: G, genesis_link: genesis)               // [Section 7.5]

    rotate(remove: [])                                          // issues v1 [Section 11.4]

11.3 Inviting

The admin makes G send the invitee a contract request carrying an invitation [Section 6.1], and adds the DID to invites with the current time. The invitee joins by accepting. The admin adds it on its next reconcile [Section 11.5]. An invitation not accepted within 30 days expires [Section 11.5].

Approving a contract join request [Section 6.4] also adds the requester to invites, before G accepts the request. A join request made through the mediator is approved like any approver does it [Section 6.6], and the requester never enters invites.

11.4 Rotating and Removing

function rotate(remove):
    view = QUERY_GROUP()
    require view.admin_did == admin.did                        // else GROUP_HANDED_OVER
    v = view.key_version + 1
    certificate = sign_certificate(G, v, secure_random_bytes(32), admin)

    account(view)                                              // [Section 11.7]
    persist pendingRotation = { certificate, remove },         // before the mediator hears of it
            roster -= remove, removedAt[d] = v for d in remove // in the same write
    reply = UPDATE_GROUP_MEMBERS({ remove, key_version: v })
    if reply is ERROR:
        clear pendingRotation, restore roster and removedAt    // nothing was committed
        fail with reply.code
    // A network failure or a lost reply keeps pendingRotation: reconcile settles it

    accounted = account(reply.members)                         // a removed DID still listed gets nothing
    complete_rotation(certificate, accounted)
    deliver_pending(accounted)
    for did in remove: revoke G's control channel with did    // hidden [DCTRL-0003 Section 12]

function complete_rotation(certificate, members):
    admin's member side takes certificate                      // [Section 5.4]; a repeat is a no-op
    for m in members, m != admin:
        pendingDelivery[m] = min(pendingDelivery[m], certificate.key_version)
    clear pendingRotation

function deliver_pending(members):                             // members = those accounted for
    drop pendingDelivery entries of anyone else
    for (m, from) in pendingDelivery:
        for batch in ascending batches of at most 50 held certificates
                     with key_version >= from:                 // [Section 5.3]
            send batch to m                                    // one certificate: decentrl.group.key;
                                                               // over m's channel, else as a parcel [Section 5.7]
            pendingDelivery[m] = last version of batch + 1     // persisted
        on success:  remove m from pendingDelivery
        if m's mediator answers COMMUNICATION_CONTRACT_NOT_FOUND:
            m has left (it revoked G): remove m with a new rotation
        on any other failure: stop delivering to m; the entry resumes at the failed batch

Removing a member is rotate(remove: [member]). Keeping the lowest version a member still needs means that a member added at v(n) and rotated past before delivery receives both v(n) and v(n+1). A member added to a full group needs every version from v1 [Section 5.3].

An admin MAY set a member's pendingDelivery back to an earlier version to deliver its keys again, for example when a successor refused a handover bundle for a missing version [Section 12.3].

11.5 Reconcile

Reconcile brings the admin in line with the group's mediator and resumes any step a crash interrupted. It is safe to run at any time. It reads the group (QUERY_GROUP) and G's current control channels, then:

StepWhenAction
Handed overadmin_did is not this adminErase the admin state; report handed-over
DeletedQUERY_GROUP answers GROUP_DELETED or GROUP_NOT_FOUNDErase the admin state; report deleted
AccountAlways, before any key movesAccount for the members listed [Section 11.7]; warn about those it cannot account for, and report them. They are never removed for it
Finish rotationpendingRotation's version equals the mediator's key_versioncomplete_rotation with the members accounted for: the commit happened but its reply was lost
Discard rotationpendingRotation exists with any other versionForget it: it was never committed
Rotaterotation_due, or key_version is 0, or key_version is above the highest certificate the admin holdsrotate([])
AddInvited, with an active control channel, not yet a member (even if the invitation expired)UPDATE_GROUP_MEMBERS { add }; each added member needs first_version_owed [Section 5.3]; it leaves invites and joins roster
SettleInvited and already a member with an active control channelLeaves invites
ExpireInvited more than 30 days ago, with no active control channel, not a memberLeaves invites
DismissA pending contract request to G without join_request; with the reference SDK, every pending contract request to GAcknowledge it without accepting [Section 6.4]
DeliverpendingDeliverydeliver_pending to the roster, by channel or by parcel [Section 5.7]
RevokeA control channel with someone who is neither a member nor invitedRevoke it (hidden)
LeftA delivery answered COMMUNICATION_CONTRACT_NOT_FOUNDRemove with a new rotation
Join keyThe mediator's join_key is not for the current version, and the admin holds that versionPublish the current version's join key [Section 7.18]
ScopesA scoped grantee lacks a scope row for a version the admin holdsWrite it [Section 4.8]

The join key and scope steps also run after every rotation. They are idempotent, and their failure is not fatal: the next reconcile retries them.

Reconcile never removes a member because G's side of its control channel is gone. Anyone holding G's keys, including a former admin, can revoke G's channels. If a missing channel counted as leaving, a former admin could make the current admin empty the group. Such a member, like a member an approver let in, receives its keys as parcels instead [Section 5.7]. Version 0.3 invited it again; version 0.4 does not.

11.6 Periodic Rotation

An admin SHOULD rotate periodically (for example daily) to bound what one version protects [Section 15.6]. The reference SDK does not schedule this: the admin calls rotate().

11.7 Accounting for Members

Version 0.6. The member list is the mediator's word, and the mediator can add to it [Section 15.8]. Before any key moves, the admin accounts for the listed members: with members as listed and admissions from QUERY_GROUP [Section 7.11],

function account(members):
    approvers = { admin } ∪ { holders of approve in the current grants
                              of the grant log as verified [Section 9.6] }
    roster = roster ∩ members                                  // members gone leave it
    for m in members, m != admin, m not in roster:
        if m is invited and has an active control channel:     // the admin's own add
            roster += m
        else if admissions[m] is valid for G [Section 6.6]
                and admissions[m].member_did == m
                and admissions[m].approver_did in approvers
                and admissions[m].key_version >= removedAt[m] (when set):
            roster += m
            pendingDelivery[m] = min(pendingDelivery[m], admissions[m].key_version + 1)
                                                               // versions it missed, if any
    persist roster
    return roster                                              // accounted for; the rest are not

A member once on the roster stays on it while listed: a later revocation of its approver does not undo it. Every other listed member is unaccounted for. It gets no key, is reported to the admin, and is never removed for it: the admin removes it [Section 11.4], or vouches for it after checking out of band, which puts it on the roster, owed from the version its own admission names, else as a member added now [Section 5.3]. Admissions are checked only when a member joins the roster, against the grants and removals the admin holds then.

State from before version 0.6 has no roster: the first account seeds it with every member listed then, other than the admin [Section 15.13]. A new group starts with an empty roster.


12. Admin Handover

Handover takes two steps. The current admin first gives its successor everything needed to run the group, then names it on the mediator. The successor takes over by accepting.

12.1 Step 1: the Bundle

The successor must be on the roster [Section 11.7]: the bundle holds G's keys and every key the admin holds. The current admin first delivers every key still pending [Section 11.4]. If keys owed to the successor could not be delivered, the handover stops there: the bundle relies on them, and control events apply in order [Section 10.2], so they reach the successor before the bundle.

The admin then signs the next link: admin_epoch = the chain's length, admin_did = the successor (a member), key_version = the current version. The link never carries history [Section 4.2]. It then sends the successor a decentrl.group.handover control event [Section 10.1], over the successor's control channel or, without one, as a key parcel [Section 5.7], with:

{
  "group_did": "<G>",
  "link": { <the next admin link> },
  "admin": { <the admin state without pendingRotation> },
  "certificates": [ <key certificates> ]
}
  • admin.identity: G's identity with its private keys.
  • admin.keyStates: the key states of the control channels G's mediator currently lists, advanced to today's epoch with no retained older epoch keys. Nothing addressed to G is in flight, since control events only go out from G, and older epoch keys would only widen what the bundle exposes.
  • admin.ephemeralKeys, admin.invites, admin.pendingDelivery, admin.grantPrefixes, admin.removedAt: as held.
  • admin.roster (version 0.6): the roster with the handing-over admin added, since it stays a member. The successor accounts for members as its predecessor did [Section 11.7]; a bundle without it seeds one [Section 15.13].
  • certificates: every certificate from a start version up to the current version. Older history is not included. The start version depends on the history policy [Section 4.5]:
    • from_join: the lowest version any pending delivery needs, or the current version if none is pending;
    • full: the lowest version the successor itself is still owed (pendingDelivery[successor]), or the current version if none. Every member of a full group, the successor included, holds all of history once its keys are delivered, so only what the successor still lacks travels.

The admin then sends HAND_OVER_GROUP { link } [Section 7.8]. If it fails, the admin is still in charge and can retry. The successor keeps every bundle it received, keyed by link epoch, so a retry does not lose anything.

12.3 Receiving the Bundle

The successor MUST verify the bundle before keeping it:

  1. It is well-formed.

  2. The control event came from G over a control channel, or in a parcel whose from_did is the current admin [Section 5.7]; and the bundle's group_did and admin.groupDid are both G.

  3. admin.identity really is G: both key pairs are valid pairs, the storage key is 32 bytes, and G's DID rebuilt from the keys, alias and mediator equals G.

  4. link.admin_did is the successor itself.

  5. link is a valid successor of the chain's last link, read with a fresh QUERY_GROUP [Section 4.2].

  6. Every certificate passes the acceptance rules of Section 5.4, with link.key_version as the mediator's version.

  7. The certificates the successor already holds, together with the bundle's, cover every version up to link.key_version from:

    • from_join: the lowest pending delivery in admin.pendingDelivery, or link.key_version;
    • full: v1. The successor will have to serve every version to members added later [Section 5.3].

    A gap is refused as MISSING_CERTIFICATE. The admin can then deliver the successor's keys again [Section 11.4] and hand over again.

A bundle that fails is rejected. The successor keeps valid bundles keyed by link epoch, and discards bundles for epochs the chain has already filled. A stale bundle from an earlier admin cannot replace a newer one.

12.4 Accepting

function accept_handover(member, G):
    view = QUERY_GROUP()                        // chain verified and extending the held one
    link = view.admin_did == member.did ? last link of view.admin_chain   // a retry
                                        : view.pending_link
    bundle = held bundle for link.admin_epoch
    if link is none or bundle is none or bundle.link.signature != link.signature:
        fail NO_MATCHING_HANDOVER
    if view.admin_did != member.did:
        ACCEPT_GROUP_ADMIN()                     // [Section 7.9]; sets rotation_due
    persist the admin state from bundle.admin
    view = QUERY_GROUP()                        // the chain now ends with link, its from_seq recorded
    add the bundle's certificates the member does not hold
    hold view's chain and admin_from_seqs       // verified and pinned [Sections 4.3, 4.6]
    reconcile()                                 // rotates first: rotation_due is set

Only the bundle whose link equals the mediator's pending link is used. If the successor fails after ACCEPT_GROUP_ADMIN, a retry finds itself already admin and adopts the bundle again.

The forced rotation replaces the handover version, over which the former admin still has tenure [Section 4.4], before any new member needs a key.

12.5 The Former Admin

The former admin stays in charge until the successor accepts. Its next reconcile then finds that admin_did is someone else, erases its admin state (including G's keys), and reports handed-over. It stays a member and keeps receiving keys over its own control channel [Section 6.5], until someone removes it.


13. Leaving and Deleting

13.1 Leaving

A member leaves by:

  1. LEAVE_GROUP [Section 7.7]. If the group already answers NOT_A_GROUP_MEMBER, GROUP_DELETED or GROUP_NOT_FOUND, the member has nothing left to leave and goes on;
  2. revoking its control channel with G on its own mediator (hidden) [DCTRL-0003 Section 12];
  3. forgetting the group's state.

The leave sets rotation_due, so the admin's next reconcile rotates and revokes G's side of the channel. The admin cannot leave. It hands over first.

An author can delete its events only while it is a member [Section 7.14]. A member that wants its events gone SHOULD delete them before it leaves.

13.2 Removal

The admin removes a member with a rotation [Section 11.4]. The admin, or a grantee holding remove, may instead remove a member at once with REMOVE_GROUP_MEMBER [Section 7.17]: the member is out immediately, and the admin's device rotates on its next reconcile, since the removal sets rotation_due. Until then the removed member still holds the current version [Section 15.13]. Nothing is sent to the removed member over its control channel. If it is subscribed to the group over WebSocket, it receives one last GROUP_UPDATED for the command that removed it, and its subscription is then dropped [Section 9.9]. Its next read gets NOT_A_GROUP_MEMBER, and it receives no further keys.

13.3 Deleting

The admin deletes the group with DELETE_GROUP [Section 7.13] and erases its admin state. The group's events, tags and member list are gone from the group's mediator. Copies members stored on their own mediators [Section 9.5] remain. Subscribed members receive one last GROUP_UPDATED [Section 9.9]. Members' next reads get GROUP_DELETED.


14. Operational Complexity

14.1 Cost Analysis

OperationAdmin costPer-member cost
Post / read an event—O(1): one AES-GCM operation and one signature
Invite and add a memberOne contract request, one UPDATE_GROUP_MEMBERS, the certificates it needsAccept one request; receive its certificates
Add a member to a full groupAs above, with every version from v1: one control event per 50 versionsOne control event and one QUERY_GROUP per batch
Remove a member, or rotateOne UPDATE_GROUP_MEMBERS and N control eventsOne control event, plus one QUERY_GROUP to check the certificate
HandoverOne bundle, O(N) in size (one key state per control channel), and one HAND_OVER_GROUP— (the successor: verify, accept, rotate)
Delete an event—One DELETE_GROUP_EVENT; readers: one notice row, and at most one extra QUERY_GROUP
Read by tag—One QUERY_GROUP_EVENTS per chunk of 64 tag values, that is per 64 / (number of tags) key versions held
Grant, or revokeOne SET_GROUP_GRANT; for a scoped delete, one SET_GROUP_GRANT_SCOPE per key version heldReaders: at most one extra QUERY_GROUP
Rotate with scoped granteesOne SET_GROUP_GRANT_SCOPE per scoped grantee, and one SET_GROUP_JOIN_KEY—
Approve a join request (an approver)—One QUERY_GROUP, one APPROVE_GROUP_JOIN with one parcel per 50 versions given; the newcomer: one QUERY_GROUP_KEY_PARCELS and one ACK_GROUP_KEY_PARCELS

14.2 Scalability

Keys go out as N small pairwise control events or parcels, not as one group event. Each member receives only its own. The group's mediator takes part in key distribution only by holding parcels it cannot open. It stores the member list, the version, and the group events. A group holds at most 1000 members [Section 7.26].

The mediator finds tagged events through an index on (group, tag, seq), so a read by tag does not walk the whole group.

14.3 Upgrading a Version 0.2 Mediator

Every change of version 0.3 to the group record is additive:

  • history defaults to from_join, which is what every existing group's genesis link says;
  • admin_from_seqs stays unset for existing groups and is read as [0, null, …] [Section 4.6]; the next ACCEPT_GROUP_ADMIN records its own value;
  • existing events get kind event, no target_seq, no deleted_at and no tags.

Readers' cursors are seq values, so the sequence must never go back below a seq a reader may hold. The reference mediator ships a migration that adds the columns in place and keeps the event table's AUTOINCREMENT counter. Its container instead runs prisma db push, which rebuilds the event table and resets the counter to the highest remaining seq. That is safe: only DELETE_GROUP removes event rows (deleted events keep their row), so every seq above the highest remaining one belonged to a deleted group, and no live cursor points past a live row.

14.4 Upgrading to Version 0.4

Version 0.4 adds tables (grant log, delete scopes, join requests, key parcels, staff log) and one column (the group's join key); it changes no existing row, and the reference mediator's migrations only create tables and add a column.

Mediators MUST be upgraded before clients:

  • A version 0.4 client sends every prefix value of its tags [Section 8.7]. A version 0.3 mediator refuses more than 8 values with INVALID_COMMAND, so an event with deep or many tags cannot be posted there.
  • Every command version 0.4 adds fails schema validation on a version 0.3 mediator (INVALID_COMMAND), and a version 0.4 requester asks only through the mediator [Section 6.6].
  • QUERY_GROUP on a version 0.3 mediator lacks grant_log, grant_scopes, join_key and staff_log; clients read them as empty.

A group from before version 0.4 reads with an empty grant log and no join key. Requests without a note work at once. A note needs a join key, which the admin's app publishes on its next reconcile or rotation [Section 11.5]; until then a requester sends no note.

Clients from before version 0.4:

  • still send contract join requests, which only the admin's device answers [Section 6.4];
  • never fetch key parcels. A version 0.3 member whose control channel is gone, which the 0.3 admin invited again, now receives its keys only as parcels it does not read, until it upgrades;
  • honour only authors' and the admin's deletion notices [Section 9.6]. The mediator stops serving an event a grantee deleted all the same, but a version 0.3 reader keeps showing a copy it already stored;
  • read exact tags only; they still find version 0.4 events by their full tags.

15. Security Considerations

15.1 Group Key vs Contract Epoch Keys

Both 1:1 and group events use AES-256-GCM with a symmetric key. They differ in how the key is established:

1:1Group
Key sourceX25519 ephemeral key agreement, then a daily HKDF chainRandom, issued by the admin as a signed certificate
Key rotationDaily key epoch, derived locally by both partiesNew version on removal, leave, handover, or when the admin rotates; delivered over pairwise control channels
Rotation costO(1)O(N)
Old keysDeleted after pruning [DCTRL-0005 Section 9.3]Retained by members [Section 5.5]

15.2 Admin Trust

The current admin controls membership and keys. A malicious or compromised admin can:

  • add anyone who accepted G's invitation, and give a key to anyone at all by other means;
  • leave members out of a delivery, or give different members different keys for the same version, so that some members cannot read each other;
  • delete any member's event [Section 7.14];
  • give grants to any member, identities it controls included, extending deletion, removal and admission to them [Section 15.13];
  • delete the group.

The mediator enforces the member list and the version, but not that every member received the same key. A key hash per version on the mediator would make a split key detectable. It is not specified [Section 16].

15.3 What a Former Admin Can Still Do

A former admin keeps G's private keys forever [Section 3.4]. With them it can:

  • revoke G's control channels on G's mediator. The current admin delivers those members' keys as parcels instead of removing them [Section 11.5], so this is at most a delay, not a takeover;
  • query G's contracts, and so see who accepts invitations (metadata);
  • send control events as G over channels that still exist. Members refuse its certificates for any version outside its tenure [Section 5.4], and a new successor only uses the bundle matching the mediator's pending link [Section 12.4];
  • send invitations as G that it signs itself. They prove only that it signed them [Section 6.1]. A channel accepted this way is not added by the current admin and is revoked on its next reconcile;
  • read the pending contract join requests to G, notes included, and accept or dismiss them [Section 6.4]. Join requests made through the mediator [Section 6.6] are not addressed to G, and G's keys give no access to them. A request it accepts is not added by the current admin, which revokes that channel on its next reconcile; a request it dismisses is lost, and the requester has to ask again;
  • read 1:1 traffic addressed to G. There is none by design: control events only go out from G, and they are ephemeral, so G's mediator keeps no copy.

It cannot:

  • change membership, hand over, or delete the group: the mediator accepts those only from the admin the chain names;
  • grant or revoke: SET_GROUP_GRANT is the current admin's, and readers keep a grant only if its signer was in office where the grant sits in the log [Section 9.6]. Grants it signed while in office stay in force until the current admin revokes them [Section 4.7], so a new admin SHOULD review them;
  • delete other members' events: the mediator allows that only to the current admin and to grantees [Section 7.14], and readers honour an admin's notice only from the admin at the notice's seq [Section 9.6];
  • issue a key members accept for any version after its tenure;
  • create the group again: CREATE_GROUP answers GROUP_EXISTS even after deletion;
  • read the group once it has been removed and the key has rotated. While it is still a member it reads like any member.

The full remedy is rotating G's keys, which needs did:web group identities [Section 16].

15.4 Residual Risk After a Handover

Between ACCEPT_GROUP_ADMIN and the new admin's forced rotation, the former admin still has tenure over the handover version [Section 4.4]. It can give a member that has not yet received that version a different key for it. The first key a member receives for a version wins [Section 5.4], so that member's posts would be unreadable to the others until the forced rotation, which the mediator requires before any further membership change [Section 7.9]. A key hash per version on the mediator would close this gap. It is not specified.

15.5 History Policy

In a from_join group, a new member reads only events posted after it was added. The mediator never serves rows at or below its joined_after_seq [Section 7.12], so it sees neither their senders, their timing, nor their ciphertext. It also receives only the keys from the version it was added at onward [Section 11.4]. A re-added member gets a new joined_after_seq, so events posted while it was away are never served to it.

In a full group, every member reads the whole log and receives every key version from v1 [Section 5.3]. As a result:

  • one compromised member device exposes the group's entire history, not only what was posted while that member belonged to it;
  • a member removed and later added again receives the versions made while it was away, and reads the events posted then;
  • rotation still protects against removed members: a member receives no version made after its removal while it stays removed.

The policy is signed by G in the genesis link and cannot be changed [Section 4.5]. A mediator that reports full for a from_join group could serve a member rows from before it joined, exposing their senders, timing and ciphertext, but members are still owed only the keys from_join gives them. The reference version 0.2 verifier drops fields it does not know before it checks a link's signature, so it fails to verify a full genesis link (INVALID_SIGNATURE): a full group fails closed for members that have not been upgraded.

15.6 Forward Secrecy

Rotation bounds what one key version protects. Beyond that there is no forward secrecy:

  • whoever holds GK_v can read every event encrypted under version v that it can obtain;
  • members retain every version [Section 5.5], so a compromised member device exposes every version it held; in a full group, every member holds every version from v1;
  • a removed member keeps the versions it held, and the ciphertext it already read. The mediator no longer serves it anything;
  • deleting an event erases it on the group's mediator only [Section 15.11];
  • key parcels are sealed to members' DID keys, which never rotate [Section 15.14].

15.7 Member Authentication

Each group event is signed with its sender's personal key, and the reader verifies it against the sender_did the group's mediator authenticated [Section 9.3]. As a result:

  • a member cannot post as another member;
  • a member cannot re-post another member's envelope under its own name: the signature does not match;
  • the admin cannot forge members' events;
  • someone who holds a key but is not a member cannot post: the mediator requires membership and the current version.

15.8 Mediator Visibility

The group's mediator learns: the admin chain and every handover with the seq it took effect at, the history policy, the member list and every change to it, who posts when and how much, the key version of each event, when rotations happen, and which of G's control channels exist. It never learns group keys, event contents, or event IDs: the dedupe key is a keyed hash [Section 8.3]. Invitations and join requests travel inside encrypted contract requests [DCTRL-0003 Section 4], so the mediators see neither the invitation nor the note. G's mediator does see who sends contract requests to G, and so who asks to join.

Version 0.3 adds:

  • Tag equality. Tag values are keyed hashes [Section 8.7], so the mediator sees which events of one key version share a tag, though not the tag. An event and the notice that deleted it share the target's tags.
  • Linkage across versions. A read by tag asks for one tag's values under several versions at once [Section 9.7], which tells the mediator that those values are the same tag.
  • Per-reader tag interest. The mediator sees which member reads which tag values, and when.
  • Deletions. The mediator sees who deleted which event, and when, and whether the deleter was its author or the admin.
  • Presence. A member subscribed over WebSocket shows the mediator when it is online, from which IP address, and which of its groups it follows [DCTRL-0004 Section 10].
  • Volume. A mediator that numbers all its groups' events from one sequence [Section 7.12] lets any member see, from the gaps between its group's seq values, how many events the mediator stored for other groups in between.

Version 0.4 adds:

  • Tag hierarchy. An event carries the value of every prefix of its tags [Section 8.7]. The mediator sees which events share a category (a common prefix) as well as a full tag, how many values each event carries, and so, roughly, its tags' depth and the kinds of events a group posts. This is accepted: it is what makes prefix reads and scoped deletion possible.
  • Staff. The grant log is plaintext to the mediator: it sees who moderates, with which capabilities, since when, and every revocation. It sees how many prefixes each scope holds, which events a scoped grantee may delete, and, from the staff log, who approved, declined or removed whom, and when.
  • Joining. The mediator sees who asks to join through it, when, whether with a note (not the note), and who let whom in. QUERY_GROUP_JOIN_INFO is open to anyone who knows G's DID: it tells them the admin chain (the admins' DIDs), the history policy and the member count.
  • Parcels. The mediator sees who leaves keys for whom, when, and their sizes, and so which members have no control channel.
  • To members. Every member reads the grant log [Section 7.11], and so knows who the staff are. The admin and grantees also read the staff log: who approved, declined or removed whom.

Version 0.6 adds:

  • The member list is not trusted with keys. The mediator can list any DID as a member, and before 0.6 the admin delivered keys to it: the mediator could read the group. The admin now delivers only to members it accounts for [Section 11.7]: added by itself, admitted by an approver it trusts, or vouched for. The mediator cannot sign an admission. It can still list anyone: an unaccounted member it lists gets no key, but the mediator serves it the group's ciphertext, which it cannot read.
  • Admissions. The mediator keeps each approver's admission, and so a signed record of who let whom in, under which key version.

Version 0.5 adds:

  • Counting interest. The mediator sees which tag values a member counts, and how often, as it sees reads by tag. It learns nothing it did not hold: counts are over rows it already stores.
  • Presence to members. Every member can ask who of the group is subscribed right now. What the mediator already saw [Section 15.8, 0.3 Presence] is now visible to the other members too.

15.9 Identity Methods

Envelopes, key certificates, admin links and invitations are verified with signing keys read directly from did:decentrl DIDs. A did:web identity can send GROUP commands, because the mediator resolves its DID document, but readers drop its group events, and it cannot sign a link, certificate, invitation or admission that others accept. Groups with did:web members or admins are not supported.

15.10 Comparison with Signal's Sender Keys

PropertySignal Sender KeysDecentrl Group
Key typePer-sender symmetric chainOne shared key per group version
Rotation triggerMembership changeRemoval, leave, admin handover, or the admin's choice
Key distributionEach sender, over pairwise sessionsThe admin (as G), over pairwise control channels or as parcels, as signed certificates; approvers re-deliver the admin's certificates as parcels
Forward secrecyPer message (chain ratchet)Per rotation
Multi-deviceComplex (every device needs every sender key)One key per version; recovery on another device not yet specified
Message orderingServer delivery orderOne canonical order per group (seq on the group's mediator)

15.11 Deletion

Deletion removes an event from the group's mediator and tells readers to hide it. It does not erase what others already hold:

  • copies members stored on their own mediators [Section 9.5], and anything a member already read, stay where they are. A reader hides a deleted event by (target_sender_did, target_event_id) [Section 9.6]; erasing stored own copies is not specified [Section 16];
  • the mediator empties the event's payload, but a dishonest mediator could keep a copy, as it could of any ciphertext it stored;
  • an author can delete its events only while it is a member [Section 7.14]. Once it has left or been removed, only the admin and grantees holding delete can delete them;
  • a member removed and added again to a from_join group gets a new floor. The mediator withholds notices whose target is at or below that floor [Section 7.12], so the member does not learn of deletions of events it read during its earlier membership, and keeps showing them;
  • a notice without target_event_id hides nothing on readers' side [Section 8.6];
  • the deletion notice is a group event: it reveals to the mediator who deleted which event, and it counts toward the sender's group-event rate limit [Section 7.26].

Readers do not rely on the mediator's role check. A notice counts only on a deletion row, for exactly that row's target, from the target's author, the admin at the notice's seq, or a grantee holding delete at that seq whose target's sender was not staff then [Section 9.6]. A member without a grant cannot hide another member's event by naming it, a grantee cannot hide staff's events, and a former admin cannot hide events after its handover. Readers do not check a scoped grantee's scope; the mediator does [Section 9.6]. One exception: where a handover's from_seq was not recorded (a group created before version 0.3), key-version tenure decides [Section 4.6], and readers would attribute a notice under the handover version to the former admin, as in Section 15.4. The mediator still refuses such a notice from a former admin; readers accept it only if a dishonest mediator stored it.

15.12 Join Requests

  • Queue flooding. Anyone can send contract requests to G. G's mediator keeps up to 1000 unacknowledged requests from other senders for a group [Section 6.4], so that real join requests fit, and reconcile dismisses everything that is not a join request (the reference SDK dismisses join requests too). Requests through the mediator are capped at 1000 per group and count toward each sender's group-event rate limit [Section 7.20]. Someone who controls many identities (a Sybil attack) can still fill either queue until approvers decline the requests. This risk is accepted at the 1000 cap.
  • Availability. A contract join request completes only when an admin device is online and reconciles [Section 11.1]. A request through the mediator completes as soon as any approver approves it [Section 6.6]. Until then the requester stays requested, and its request lapses after 30 days.
  • Former admins. Anyone holding G's keys can read, accept or dismiss contract join requests [Section 15.3]. Requests through the mediator are answered only by the current approvers.
  • Consent. A requester takes control events from G, and parcels, only while its request has not lapsed [Section 6.2]. Let in after its contract request lapsed, or after it withdrew it, it refuses G's control events and revokes its contract with G, and the admin removes it on its next delivery [Section 6.4]. Let in through the mediator just as it withdraws, it leaves [Section 6.6].
  • Who reads notes. A note is sealed to the join key of one version, so every member holding that version can read it, staff or not, and so can a member removed after that version until the next rotation [Section 15.13]. The mediator can neither read a note nor move it to another requester or group: the requester signs it with the group and its own DID inside [Section 6.6]. It cannot swap in a key of its own either, since the admin signs the join key. It can hand a requester an older statement, whose version members removed since still hold. By withholding the later links of the chain, together with a former admin, it could also present a statement that former admin signed for a version after its tenure, exposing the note to it. Notes are short messages to staff; these risks are accepted.

15.13 Staff

  • The admin offline. Grantees act while the admin's device is not running; that is their purpose. Approvers can let in anyone, identities they control included, up to the member cap, and each newcomer receives the current key and, in a full group, every key the approver holds [Section 5.7]. Removers can remove any member who is not staff. The admin sees what they did in the staff log [Section 6.6] and can revoke them, but cannot undo what newcomers already read.
  • Removal before rotation. REMOVE_GROUP_MEMBER takes a member out at once, but only the admin's device rotates [Section 7.17]. Until it does, the removed member keeps the current version: the mediator serves it nothing more, but it can read ciphertext of that version it gets elsewhere, and open join notes sealed to that version. Members approved meanwhile receive that same version.
  • Dodging a scope. A scope is checked against the tag values the poster sent, and nobody checks those against the tags it signed. A poster that leaves out prefix values (or all values) puts its event out of reach of scoped grantees; the event is then also missing from reads by those prefixes. The admin and grantees holding delete: "all" can still delete it. This is accepted rather than binding values to tags with chained hashes.
  • Staff protection is momentary. A grantee cannot act on staff, but a member whose grant ends becomes an ordinary member at once, whose events grantees may delete.
  • Grants outlive their signer. A grant stays in force across handovers until revoked [Section 4.7].
  • Readers and the mediator differ slightly. Readers do not check scope, and do not count a member named by a pending handover as staff [Section 9.6]. A mediator colluding with a grantee could therefore store a notice readers honour for an event outside the grantee's scope, or one of that pending admin's events.
  • What admissions do not stop. An admission accounts for a member until the admin removes it with a rotation (removedAt). A mediator can therefore list again, with its old admission, a member a grantee removed [Section 7.17] or one that left, and the admin's next rotation reaches it. It can also withhold a member's admission, so that the admin cannot account for it and delivers it no new key until the admin vouches for it.
  • Pre-0.6 admin state is trusted on first use. An admin state or handover bundle from before version 0.6 has no roster; the first account seeds one from the member list the mediator gives then. A DID the mediator listed before that moment is accounted for.
  • Vouching is the admin's word. A member the admin vouches for gets keys on nothing more. Apps should ask the admin to check who it is out of band.
  • Losing the admin's device. G's keys, the admin state and the scope prefixes live only on the admin's devices. Losing them loses grants and revocations, rotation, join keys for later versions, and delivery to invitees, until the group has several admins or a did:web identity [Section 16]. Grantees keep working under the current version, and members they remove keep that version for good.

15.14 Key Parcels

  • No forward secrecy beyond the DID key. A parcel is sealed to the recipient's DID key-agreement key, which cannot rotate [Section 3.4]. Whoever later obtains that private key and a copy of the parcel opens it: the certificates inside, or, for a handover bundle, G's private keys. An honest mediator deletes a parcel once it is acknowledged [Section 7.23]; a dishonest one can keep it.
  • Authenticity. Anyone can seal to a member, and a parcel's from_did is only as honest as the mediator. Nothing is trusted for either: certificates are checked against the chain and tenure [Section 5.4], a bundle against the chain and G's identity [Section 12.3]. An approver cannot make a key members accept.
  • Withholding. The mediator can withhold or drop parcels. A member then lacks keys, as when G's control events are withheld.
  • Size. A handover bundle sent as a parcel must fit in one parcel [Section 7.26]. It holds a key state per control channel of G, so a group with many channels (members who joined by invitation) can outgrow it; a successor without a channel then cannot be handed the group until this is lifted. Members let in through the mediator add no channels.
  • A malicious approver. An approver can let someone in and hand them nothing usable. The newcomer, a member without keys, gets the admin's next key by parcel [Section 5.7]. In a full group the earlier keys follow only when the admin sends them again.

16. Not Yet Specified

  • Groups with several admins, or threshold administration. An application can reserve content to the admin with admin_at_seq [Section 4.6].
  • Invitations by staff: only the admin invites [Section 11.3]; grantees let people in only by approving join requests [Section 6.6].
  • Rotation by anyone but the admin, which would close the gap after a removal by a grantee [Section 15.13].
  • Rotating G's keys. This needs did:web group identities.
  • Group keys on several devices of the same member, and recovering them on another device.
  • Attachments: group events carry only their serialized event, within the payload limit [Section 7.26].
  • Deletion by authors who have left the group [Section 15.11].
  • A command that erases a member's stored own copies of a deleted event [Section 15.11].
  • Scheduled rotation. The reference SDK rotates only when the admin asks [Section 11.6].
  • A key hash per version on the mediator, which would detect split keys and close the handover gap [Section 15.2, 15.4].
  • Admissions for members added by invitation, and signed exits, which would let a successor, and every member, check the member list itself rather than trust the admin's roster [Section 15.13].
  • did:web members and admins [Section 15.9].

17. References

17.1 Normative References

  • [RFC 2119] Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997.
  • [DCTRL-0001] "The did:decentrl DID Method".
  • [DCTRL-0002] "Cryptographic Operations".
  • [DCTRL-0003] "Communication Contracts".
  • [DCTRL-0004] "Mediator Protocol".
  • [DCTRL-0005] "Event Encryption and Storage".

On this page

AbstractStatus of This DocumentTable of Contents1. Introduction2. Terminology3. Group Identity3.1 Structure3.2 Registration and Hosting3.3 Who Runs the Group Identity3.4 Authority Does Not Come From the Group Identity4. Authority: the Admin Chain4.1 Admin Link4.2 Link Rules4.3 Verifying a Chain4.4 Tenure4.5 History Policy4.6 Authority at a Point in the Log4.7 Grants4.8 Delete Scopes4.9 Staff5. Group Keys5.1 Properties5.2 Key Certificate5.3 Delivery5.4 Acceptance5.5 Key Storage5.6 Sending Key5.7 Key Parcels6. Invitations and Consent6.1 Invitation6.2 Accepting Is Consent6.3 What the Mediator Checks6.4 Contract Join Requests6.5 The Admin's Own Channel6.6 Joining Through the Mediator7. The GROUP Channel7.1 Group Record7.2 Addressing and Authentication7.3 Authorization7.4 Commands7.5 CREATE_GROUP7.6 UPDATE_GROUP_MEMBERS7.7 LEAVE_GROUP7.8 HAND_OVER_GROUP7.9 ACCEPT_GROUP_ADMIN7.10 POST_GROUP_EVENT7.11 QUERY_GROUP7.12 QUERY_GROUP_EVENTS7.13 DELETE_GROUP7.14 DELETE_GROUP_EVENT7.15 SET_GROUP_GRANT7.16 SET_GROUP_GRANT_SCOPE7.17 REMOVE_GROUP_MEMBER7.18 SET_GROUP_JOIN_KEY7.19 QUERY_GROUP_JOIN_INFO7.20 REQUEST_GROUP_JOIN and CANCEL_GROUP_JOIN7.21 QUERY_GROUP_JOIN_REQUESTS and DECLINE_GROUP_JOIN7.22 APPROVE_GROUP_JOIN7.23 Key Parcel Commands7.24 QUERY_GROUP_COUNTS7.25 QUERY_GROUP_PRESENCE7.26 Limits8. Posting Group Events8.1 Group Envelope8.2 Payload8.3 Command8.4 Own Copy8.5 Posting Under the Current Version8.6 Deleting an Event8.7 Group Tags9. Reading Group Events9.1 Canonical Order9.2 Reading a Page9.3 Reader Rule9.4 Metadata Binding9.5 Own Copies9.6 Deletions9.7 Reading by Tag9.8 Reading Backward9.9 Push Hints10. Control Events10.1 Reserved Types10.2 Receiving10.3 Attaching the Handler11. Administration11.1 Admin State11.2 Creating a Group11.3 Inviting11.4 Rotating and Removing11.5 Reconcile11.6 Periodic Rotation11.7 Accounting for Members12. Admin Handover12.1 Step 1: the Bundle12.2 Step 2: the Pending Link12.3 Receiving the Bundle12.4 Accepting12.5 The Former Admin13. Leaving and Deleting13.1 Leaving13.2 Removal13.3 Deleting14. Operational Complexity14.1 Cost Analysis14.2 Scalability14.3 Upgrading a Version 0.2 Mediator14.4 Upgrading to Version 0.415. Security Considerations15.1 Group Key vs Contract Epoch Keys15.2 Admin Trust15.3 What a Former Admin Can Still Do15.4 Residual Risk After a Handover15.5 History Policy15.6 Forward Secrecy15.7 Member Authentication15.8 Mediator Visibility15.9 Identity Methods15.10 Comparison with Signal's Sender Keys15.11 Deletion15.12 Join Requests15.13 Staff15.14 Key Parcels16. Not Yet Specified17. References17.1 Normative References