Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Group Chat Enclave

This document specifies the group-chat enclave application profile: a shared space for multi-party messaging where multiple members read and write to the same enclave, with end-to-end confidentiality provided by the mls-lazy plugin.


Table of Contents

  1. Overview
  2. Manifest
  3. State Transitions
  4. Event-Operator Matrix
  5. Content Events
  6. Confidentiality

Overview

A group chat enclave is a shared space for multi-party messaging. Multiple members read and write to the same enclave.

  • One owner (bootstrapped via init as MEMBER with owner+admin traits).
  • Members can send messages, react, and set their own profile.
  • Admins moderate: manage members, set topic, delete messages, post notices.
  • Muted members can read but not send.
  • BLOCKED state for bans — no read, no write.

Unlike DM (personal mailbox pattern), group chat is a shared enclave: all members see the same CT, and messages exist in one place.

Manifest

{
  "states": ["PENDING", "MEMBER", "BLOCKED"],
  "traits": ["owner(0)", "admin(1)", "muted(2)", "dataview(3)"],
  "readers": [
    { "type": "MEMBER", "reads": "*", "retention": "snapshot" }
  ],
 
  "moves": [
    { "event": "Move", "from": "OUTSIDER", "to": "PENDING", "operator": "Self", "ops": ["C"],
      "alias": "applications", "gate": { "operator": ["owner", "admin"] } },
    { "event": "Move", "from": "OUTSIDER", "to": "MEMBER", "operator": "Self", "ops": ["C"],
      "alias": "auto_join", "gate": { "operator": ["owner"] } },
    { "event": "Move", "from": "OUTSIDER", "to": "MEMBER", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "OUTSIDER", "to": "BLOCKED", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "PENDING", "to": "MEMBER", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "PENDING", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "MEMBER", "to": "OUTSIDER", "operator": "Self", "ops": ["C"] },
    { "event": "Move", "from": "MEMBER", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "MEMBER", "to": "BLOCKED", "operator": "admin", "ops": ["C"] },
    { "event": "Move", "from": "BLOCKED", "to": "OUTSIDER", "operator": "admin", "ops": ["C"] }
  ],
 
  "grants": [
    { "event": "Grant", "operator": ["admin"], "scope": ["MEMBER"], "trait": ["muted"] },
    { "event": "Grant", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] },
    { "event": "Grant", "operator": ["owner"], "scope": ["OUTSIDER", "MEMBER"], "trait": ["dataview"] },
    { "event": "Revoke", "operator": ["admin"], "scope": ["MEMBER"], "trait": ["muted"] },
    { "event": "Revoke", "operator": ["owner"], "scope": ["MEMBER"], "trait": ["admin"] },
    { "event": "Revoke", "operator": ["owner"], "scope": ["OUTSIDER", "MEMBER"], "trait": ["dataview"] },
    { "event": "Revoke", "operator": ["Self"], "scope": ["MEMBER"], "trait": ["admin"] }
  ],
 
  "transfers": [
    { "trait": "owner", "scope": ["MEMBER"] }
  ],
 
  "slots": [
    { "event": "Shared", "operator": "admin", "ops": ["C", "U"], "key": "topic" },
    { "event": "Shared", "operator": "dataview", "ops": ["P"], "key": "topic" },
 
    { "event": "Own", "operator": "MEMBER", "ops": ["C"], "key": "profile" },
    { "event": "Own", "operator": "Sender", "ops": ["U"], "key": "profile" }
  ],
 
  "lifecycle": [
    { "event": "Pause", "operator": "owner", "ops": ["C"] },
    { "event": "Resume", "operator": "owner", "ops": ["C"] },
    { "event": "Migrate", "operator": "owner", "ops": ["C"] },
    { "event": "Terminate", "operator": "owner", "ops": ["C"] }
  ],
 
  "customs": [
    { "event": "message", "operator": "MEMBER", "ops": ["C"] },
    { "event": "message", "operator": "admin", "ops": ["D"] },
    { "event": "message", "operator": "muted", "ops": ["_C", "_U"] },
    { "event": "message", "operator": "dataview", "ops": ["P"] },
    { "event": "message", "operator": "Sender", "ops": ["U", "D"] },
    { "event": "message", "operator": "BLOCKED", "ops": ["_U", "_D"] },
 
    { "event": "reaction", "operator": "MEMBER", "ops": ["C"] },
    { "event": "reaction", "operator": "Sender", "ops": ["D"] },
    { "event": "reaction", "operator": "muted", "ops": ["_C"] },
    { "event": "reaction", "operator": "BLOCKED", "ops": ["_D"] },
 
    { "event": "notice", "operator": "admin", "ops": ["C", "D"] },
 
    { "event": "rotate", "operator": "admin", "ops": ["C"] }
  ],
 
  "init": [
    { "identity": "<owner_pub>", "state": "MEMBER", "traits": ["owner", "admin"] }
  ]
}

Design Rationale

States: ["PENDING", "MEMBER", "BLOCKED"]

StateCodeMeaning
OUTSIDER0Not a member
PENDING1Applied, awaiting admin approval
MEMBER2Active member, can read and write
BLOCKED3Banned by admin

Traits: ["owner(0)", "admin(1)", "muted(2)", "dataview(3)"]

TraitRankMeaning
owner0Group owner, full control (lifecycle, transfer, top-level admin)
admin1Moderator (manage members, topic, delete messages, notice)
muted2Content deny (cannot create messages)
dataview3Push delivery for service accounts

Rank Rule protection (equational form: rankCanTarget(op, tgt) ≡ decide (op < tgt), where Trait.rank gives owner→0, admin→1, muted→2, dataview→3): rankCanTarget(0, 1) = true and rankCanTarget(1, 2) = rankCanTarget(1, 3) = true — owner can target admin, admin can target muted and dataview. rankCanTarget(1, 1) = false — admin cannot kick/demote other admins; only owner can. ∀ t, rankCanTarget(Trait.rank t, 0) = false — owner cannot be kicked by anyone (rank 0, no trait outranks it). If either party holds no traits, rank check is skipped — admin can kick regular members.

Readers: {type: "MEMBER", reads: "*", retention: "snapshot"} — all members can read all events. The retention: "snapshot" declaration provides transcript portability: a kicked / left / banned former member retains access to events finalized during their membership window.

Encryption forward-secrecy still holds at the plugin layer regardless of node-side retention: mls-lazy rotates the epoch on every membership change, so post-removal messages are cryptographically opaque to the kicked member even if the node served them. Apps that prefer audit-amnesia as the explicit target (kicked = lose all access including history once snapshot lands) MAY override to retention: "current"; see rbac.md §5.3.

Operators:

OperatorKindAuthority
MEMBERStatemessage
, reaction
, Own(profile)
— base write access
admintraitmessage
, notice
, Shared(topic)
, rotate
; manages members via Move/Grant/Revoke
ownertraitGrant/Revoke admin, Transfer(owner), lifecycle events; superset of admin (init bootstraps both)
mutedtraitmessage:_C_U — deny override prevents message creation and editing while retaining read access
dataviewtraitmessage
, Shared(topic)
— push delivery for service accounts
SelfContextMove(MEMBER, OUTSIDER)
(leave), Revoke(admin)
(step down) — self-targeting
SenderContextmessage
, reaction
, Own(profile)
— per-sender edit/delete

Join paths:

  • Application: Move(OUTSIDER→PENDING) via Self
    , gated by applications. Self ensures the actor targets themselves; Move step 4 verifies the actor is OUTSIDER. Admin approves via Move(PENDING→MEMBER) or rejects via Move(PENDING→OUTSIDER).
  • Auto-join: Move(OUTSIDER→MEMBER) via Self
    , gated by auto_join. Same Self + state verification. Owner controls the gate.
  • Direct invite: Move(OUTSIDER→MEMBER) via admin
    . No gate — admin decides directly. Admin clients SHOULD pair the Move with a notice event written into the invitee's Personal enclave (see personal.md) carrying kind: "group_invite", this group's enclave_id, the inviter pubkey, and the source group's 32-byte root_secret for the current epoch as handoff (sub-encrypted under 'enc:personal:notice:epoch' per plugins/ecdh-envelope.md §5; the receiver derives epoch_secret = HKDF(root_secret, "enc:mls:epoch", 32)). The required epoch_n field records the source epoch.n for Reconciliation. Without this companion write the invitee has MEMBER
    access but no signal pointing them at the enclave — they are silently a member of a group they cannot discover. The companion is a client-layer convention, not node-enforced: the Move is valid on its own, and clients that omit the notice produce a "silent invite" that only works if the invitee learns the enclave_id out of band.
  • Pre-emptive ban: Move(OUTSIDER→BLOCKED) via admin
    . Blocks an identity before they join or apply.

No OWNER state — unlike DM where OWNER is a permanent state, group chat uses MEMBER state with owner trait. The owner is a member who can Transfer(owner), step down, or leave. If the owner leaves without transferring, the group becomes ownerless — existing admins continue moderating (members, topic, messages, rotation) but no one can promote new admins, control lifecycle, or manage the auto_join gate. This is an accepted degradation: a decentralized group with no single authority.

State Transitions

TransitionOperatorMeaning
OUTSIDER → PENDINGSelfApply to join (gated)
OUTSIDER → MEMBERSelfAuto-join (gated)
OUTSIDER → MEMBERadminDirect invite
OUTSIDER → BLOCKEDadminPre-emptive ban
PENDING → MEMBERadminApprove application
PENDING → OUTSIDERadminReject application
MEMBER → OUTSIDERSelfLeave group
MEMBER → OUTSIDERadminKick member
MEMBER → BLOCKEDadminBan member
BLOCKED → OUTSIDERadminUnban (can rejoin via normal paths)

Move clears all traits by default. A kicked admin loses admin. An unbanned member MUST be re-invited and re-granted traits.

Event-Operator Matrix

EventMEMBEROUTSIDERPENDINGBLOCKEDowner(0)admin(1)muted(2)dataview(3)SelfSender
messageCR_U_DD_C_UPUD
reactionCR_D_CD
noticeRCD
rotateRC
Shared(topic)RCUP
Own(profile)CRU
Move(OUTSIDER, PENDING)RC
Gate(applications)RCC
Move(OUTSIDER, MEMBER)RCC
Gate(auto_join)RC
Move(OUTSIDER, BLOCKED)RC
Move(PENDING, MEMBER)RC
Move(PENDING, OUTSIDER)RC
Move(MEMBER, OUTSIDER)RCC
Move(MEMBER, BLOCKED)RC
Move(BLOCKED, OUTSIDER)RC
Grant(muted)RC
Grant(admin)RC
Grant(dataview)RC
Revoke(muted)RC
Revoke(admin)RCC
Revoke(dataview)RC
Transfer(owner)RC
PauseRC
ResumeRC
MigrateRC
TerminateRC

Columns: States (MEMBER, OUTSIDER, PENDING, BLOCKED) → traits (owner, admin, muted, dataview) → Contexts (Self, Sender).

Content Events

message

Group message. Created by a MEMBER. Admins can delete any message (admin

). Muted members are denied creation and editing (muted:_C_U). Sender
allows the sender to edit or retract their own messages.

The content is a mls-lazy per-message envelope (per-sender ratchet within the current epoch).

reaction

Emoji reaction to a message. Created by a MEMBER. Sender

allows the reactor to remove their own reaction. No admin
— admins cannot remove others' reactions.

  • content (sealed): ref (event hash of the target message), emoji (reaction emoji) — same mls-lazy ratchet envelope as message.

notice

Admin-created group-level notice. Created by admin (admin

). Removed by admin (admin
). Members read via readers wildcard.

Content is app-defined. Examples: a chat app puts { "ref": "<event_hash>" } for a pinned message; an announcement channel puts { "text": "..." } for a text notice. Sealed under the same mls-lazy ratchet envelope (sender = the admin).

Shared(topic)

The group's display name / description. KV Shared singleton (Shared("topic")); admin

, dataview
, MEMBER
(via the wildcard reader). Plaintext — visible to anyone with read access including dataview push subscribers. Application-defined shape; the protocol does not constrain it. A common shape is { "name": "<utf8>", "description": "<utf8>" }.

Own(profile)

Per-member profile slot (Own("profile"), scoped to MEMBER); MEMBER

, Sender
. Plaintext. Each member writes their own profile entry visible to all members. Application-defined shape; the convention from personal.md (display_name, bio, avatar) is RECOMMENDED for cross-app interoperability, but apps MAY override. Note this is distinct from the personal-enclave Shared(profile) singleton — the group Own(profile) is per-member and scoped to this group.

rotate

Epoch rotation event. Created by admin (admin

). Distributes a new epoch secret to current MEMBERs (see Confidentiality §When new epochs are issued below). Used in any of these protocol situations:

  • Initial epoch establishment (group creation — no Move involved).
  • After voluntary leave (Self Move carries no epoch).
  • Epoch delivery to auto-join members (Self Move carries no epoch).
  • Standalone rotation (no membership change — security hygiene).

The content is a mls-lazy commit object (tree copath wraps + additive OR-wraps).

Confidentiality

Group events are sealed end-to-end with a shared per-epoch secret distributed via an MLS-style binary ratchet tree, then ratcheted per-sender for each message. The node sees only opaque ciphertext.

Canonical plugin: plugins/mls-lazy.

App-payload contract

The plugin's wire envelopes appear on these event content / tag fields:

EventFieldPlugin envelope
messagecontentper-message envelope (per-sender ratchet within current epoch)
reactioncontentper-message envelope (same ratchet schedule as message)
noticecontentper-message envelope (sender = admin)
rotatecontent.epoch + content.epoch_or_wrapscommit object — tree copath wraps + epoch_or_wraps OR-wrap fallback
Move(OUTSIDER→MEMBER) (admin)content.epoch + content.epoch_or_wrapscommit object piggy-backed on the Move (see §When new epochs are issued)
Move(PENDING→MEMBER) (admin)content.epoch + content.epoch_or_wrapscommit object piggy-backed on the Move
Move(MEMBER→OUTSIDER|BLOCKED) (admin, removal)content.epoch + content.epoch_or_wrapscommit object piggy-backed on the Move

Shared(topic) and Own(profile) are plaintext (visible to all readers per the manifest); they do NOT use this plugin.

When new epochs are issued

Group epochs rotate on membership change (additions, removals, approvals) and on demand. The protocol-level rules are:

Membership changeRBAC eventCarries epoch payload?
Admin inviteMove(OUTSIDER→MEMBER) by adminYES — piggybacked on the Move's content
Approve applicationMove(PENDING→MEMBER) by adminYES — piggybacked on the Move's content
Kick / BanMove(MEMBER→OUTSIDER|BLOCKED) by adminYES — piggybacked on the Move's content
Voluntary leaveMove(MEMBER→OUTSIDER) by SelfNO — a separate rotate MUST follow
Auto-joinMove(OUTSIDER→MEMBER) by SelfNO — a separate rotate MUST follow
Standalone rotationrotate by adminYES

Self-Moves carry no epoch because the actor either does not hold the current secret (auto-join) or cannot distribute it without read access to other members' keying material (leave). In both Self cases an admin SHOULD follow up with a rotate event to close the gap. Members MUST treat the window between a Self-Move and the next admin rotate as: a forward-secrecy gap after a leave (the leaver retains the current epoch_secret until rotate lands), or as undeliverable for the new member after an auto-join (they have MEMBER

but no key). Receivers MUST tolerate the follow-up rotate landing arbitrarily late or never; messages sent in this window remain decryptable to anyone with the current epoch but are NOT to the auto-joined member.

Trait Grant/Revoke (admin, muted, dataview) does NOT trigger an epoch. Traits modify permissions, not membership; the identity remains MEMBER and already holds the current epoch.

Backward and forward secrecy ride on this rule: every join adds the new member to the next epoch (the previous epoch remains opaque to them); every removal cuts the removed member out of the next epoch (the next epoch's messages are opaque to them).

Monotonicity (client-validated): epoch.n MUST be strictly greater than the highest epoch.n previously seen in the CT — replay defense (see plugin §8).

Multi-key members (sub-key wallets)

The tree path encrypts to each member's parent identity pub. Members that operate from a deterministic HKDF sub-key (notably MetaMask, which cannot perform ECDH from the parent) MUST be reached via the plugin's additive epoch_or_wraps OR-wrap field. The plugin (see plugins/mls-lazy.md §6.1) mandates epoch_or_wraps carry at minimum:

  • An entry for the committer's own operating key (so the committer's fresh device can recover via CT replay — encrypted_path_secrets never targets the committer).
  • An entry for each member whose operating key differs from their tree-keyed parent id_pub (sub-key wallets).

Parent-keyed members (dev / passkey / Nostr / NFC, where parent == sub) recover via the tree and do NOT require an OR-wrap entry. The app spec's contract is to recognize that every rotate and every membership-changing admin Move carries content.epoch AND content.epoch_or_wraps, that epoch_or_wraps is never empty, and that both fields MUST be replicated unchanged.

Epoch recovery (multi-device join / fresh login)

On sync, the client replays the group's CT and rebuilds the epoch map:

  1. Scan admin-created Move events (OUTSIDER→MEMBER, PENDING→MEMBER, MEMBER→OUTSIDER|BLOCKED) with content.epoch.
  2. Scan rotate events with content.epoch.
  3. For each commit, try the tree copath path first; if no copath entry yields a secret (sub-key wallet, or this device has not yet been added to the tree), fall back to the OR-wrap entry addressed to the operating key (recipient == my_op_pub).
  4. Validate strict epoch.n monotonicity; reject regressions.

(Crypto details for steps 1–3 live in the plugin spec.)

Security guarantees the group app relies on

From the plugin (see mls-lazy §11):

Guarantee
Per-message key isolation.
Per-sender chain isolation.
Per-epoch forward secrecy (independent random epoch roots).
Forward secrecy on removal (post-removal epochs opaque to removed member).
Backward secrecy on join (pre-join epochs opaque to new member).
Stateless decryption (no persistent ratchet state).
Multi-device support via identity_priv + CT replay.
Sub-key wallet support via OR-wrap fallback.

Group does NOT require post-compromise security against identity_priv compromise — the CT-replay tradeoff is accepted (same constraint as DM and every CT-based ENC enclave). Apps requiring PCS-grade groups MUST swap to a future ratchet plugin that ships keying material outside the CT.