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
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"]
| State | Code | Meaning |
|---|---|---|
| OUTSIDER | 0 | Not a member |
| PENDING | 1 | Applied, awaiting admin approval |
| MEMBER | 2 | Active member, can read and write |
| BLOCKED | 3 | Banned by admin |
Traits: ["owner(0)", "admin(1)", "muted(2)", "dataview(3)"]
| Trait | Rank | Meaning |
|---|---|---|
| owner | 0 | Group owner, full control (lifecycle, transfer, top-level admin) |
| admin | 1 | Moderator (manage members, topic, delete messages, notice) |
| muted | 2 | Content deny (cannot create messages) |
| dataview | 3 | Push 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:
| Operator | Kind | Authority |
|---|---|---|
MEMBER | State | message, reaction, Own(profile) — base write access |
admin | trait | message, notice, Shared(topic), rotate; manages members via Move/Grant/Revoke |
owner | trait | Grant/Revoke admin, Transfer(owner), lifecycle events; superset of admin (init bootstraps both) |
muted | trait | message:_C_U — deny override prevents message creation and editing while retaining read access |
dataview | trait | message, Shared(topic) — push delivery for service accounts |
Self | Context | Move(MEMBER, OUTSIDER) (leave), Revoke(admin) (step down) — self-targeting |
Sender | Context | message, 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
noticeevent written into the invitee's Personal enclave (see personal.md) carryingkind: "group_invite", this group'senclave_id, theinviterpubkey, and the source group's 32-byteroot_secretfor the current epoch ashandoff(sub-encrypted under'enc:personal:notice:epoch'perplugins/ecdh-envelope.md§5; the receiver derivesepoch_secret = HKDF(root_secret, "enc:mls:epoch", 32)). The requiredepoch_nfield records the sourceepoch.nfor 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 theenclave_idout 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
| Transition | Operator | Meaning |
|---|---|---|
| OUTSIDER → PENDING | Self | Apply to join (gated) |
| OUTSIDER → MEMBER | Self | Auto-join (gated) |
| OUTSIDER → MEMBER | admin | Direct invite |
| OUTSIDER → BLOCKED | admin | Pre-emptive ban |
| PENDING → MEMBER | admin | Approve application |
| PENDING → OUTSIDER | admin | Reject application |
| MEMBER → OUTSIDER | Self | Leave group |
| MEMBER → OUTSIDER | admin | Kick member |
| MEMBER → BLOCKED | admin | Ban member |
| BLOCKED → OUTSIDER | admin | Unban (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
| Event | MEMBER | OUTSIDER | PENDING | BLOCKED | owner(0) | admin(1) | muted(2) | dataview(3) | Self | Sender |
|---|---|---|---|---|---|---|---|---|---|---|
| message | CR | _U_D | D | _C_U | P | UD | ||||
| reaction | CR | _D | _C | D | ||||||
| notice | R | CD | ||||||||
| rotate | R | C | ||||||||
| Shared(topic) | R | CU | P | |||||||
| Own(profile) | CR | U | ||||||||
| Move(OUTSIDER, PENDING) | R | C | ||||||||
| Gate(applications) | R | C | C | |||||||
| Move(OUTSIDER, MEMBER) | R | C | C | |||||||
| Gate(auto_join) | R | C | ||||||||
| Move(OUTSIDER, BLOCKED) | R | C | ||||||||
| Move(PENDING, MEMBER) | R | C | ||||||||
| Move(PENDING, OUTSIDER) | R | C | ||||||||
| Move(MEMBER, OUTSIDER) | R | C | C | |||||||
| Move(MEMBER, BLOCKED) | R | C | ||||||||
| Move(BLOCKED, OUTSIDER) | R | C | ||||||||
| Grant(muted) | R | C | ||||||||
| Grant(admin) | R | C | ||||||||
| Grant(dataview) | R | C | ||||||||
| Revoke(muted) | R | C | ||||||||
| Revoke(admin) | R | C | C | |||||||
| Revoke(dataview) | R | C | ||||||||
| Transfer(owner) | R | C | ||||||||
| Pause | R | C | ||||||||
| Resume | R | C | ||||||||
| Migrate | R | C | ||||||||
| Terminate | R | C |
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) — samemls-lazyratchet envelope asmessage.
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
{ "name": "<utf8>", "description": "<utf8>" }.
Own(profile)
Per-member profile slot (Own("profile"), scoped to MEMBER); MEMBER
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:
| Event | Field | Plugin envelope |
|---|---|---|
message | content | per-message envelope (per-sender ratchet within current epoch) |
reaction | content | per-message envelope (same ratchet schedule as message) |
notice | content | per-message envelope (sender = admin) |
rotate | content.epoch + content.epoch_or_wraps | commit object — tree copath wraps + epoch_or_wraps OR-wrap fallback |
Move(OUTSIDER→MEMBER) (admin) | content.epoch + content.epoch_or_wraps | commit object piggy-backed on the Move (see §When new epochs are issued) |
Move(PENDING→MEMBER) (admin) | content.epoch + content.epoch_or_wraps | commit object piggy-backed on the Move |
Move(MEMBER→OUTSIDER|BLOCKED) (admin, removal) | content.epoch + content.epoch_or_wraps | commit 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 change | RBAC event | Carries epoch payload? |
|---|---|---|
| Admin invite | Move(OUTSIDER→MEMBER) by admin | YES — piggybacked on the Move's content |
| Approve application | Move(PENDING→MEMBER) by admin | YES — piggybacked on the Move's content |
| Kick / Ban | Move(MEMBER→OUTSIDER|BLOCKED) by admin | YES — piggybacked on the Move's content |
| Voluntary leave | Move(MEMBER→OUTSIDER) by Self | NO — a separate rotate MUST follow |
| Auto-join | Move(OUTSIDER→MEMBER) by Self | NO — a separate rotate MUST follow |
| Standalone rotation | rotate by admin | YES |
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
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_secretsnever 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:
- Scan admin-created Move events (
OUTSIDER→MEMBER,PENDING→MEMBER,MEMBER→OUTSIDER|BLOCKED) withcontent.epoch. - Scan
rotateevents withcontent.epoch. - 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). - Validate strict
epoch.nmonotonicity; 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.