Group Chat Enclave — RBAC v2
Status: Design Based on: rbac-v2.md
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": "*" }
],
"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"]
- 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)"]
- owner (rank 0) = group owner, full control (lifecycle, transfer, top-level admin)
- admin (rank 1) = moderator (manage members, topic, delete messages, notice)
- muted (rank 2) = content deny (cannot create messages)
- dataview (rank 3) = push delivery for service accounts
Rank Rule protection: owner (0) can target admin (1), admin can target muted (2) and dataview (3). Admin cannot kick/demote other admins — only owner can. Owner cannot be kicked by anyone (rank 0, no one outranks). If either party holds no traits, rank check is skipped — admin can kick regular members.
Readers: MEMBER reads "*" — all members can read all events. PENDING and BLOCKED cannot read. OUTSIDER cannot read — dataview grants push delivery (P), not read access (R).
Operators:
MEMBER(State) — message, reaction, Own(profile). Base write access.admin(trait) — message, notice, Shared(topic), rotate. Manage 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.
- 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.Encrypted with the current epoch's per-sender ratchet chain.
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:
ref(event hash of the target message),emoji(reaction emoji)
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.
rotate
Epoch rotation event. Created by admin (admin
). Distributes a new epoch secret encrypted for current MEMBERs. Used when admin-created Moves can't carry the epoch:- 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)
See Encryption & Epoch Lifecycle for content format.
Encryption & Epoch Lifecycle
Group chat uses lazy MLS: shared epoch secrets with per-sender symmetric ratchet chains and stateless decryption. This is the same scheme as the existing implementation — RBAC v2 changes how membership is managed (Move instead of Grant/Revoke), not how encryption works.
Key difference from DM (Ratchet_DM): DM uses per-contact epochs (one sender per epoch, no sender_pub in domain separation). Group uses shared epochs (multiple senders per epoch, sender_pub in ratchet seed). See dm.md for the DM scheme.
Lazy MLS Summary
- Shared epoch secret — one 32-byte random secret per epoch, held by all current MEMBERs
- Per-sender ratchet chain — each sender gets an independent chain seeded with
epoch_secret + sender_pub - Stateless decryption — receiver re-derives the ratchet from epoch secret + sender_seq each time; no persistent ratchet state
- Epoch rotation — new epoch on membership change; old epochs retained for history
- Tree ratchet distribution — epoch secrets distributed via encrypted path secrets addressed to ratchet tree nodes, O(log N) per rotation
Tree Ratchet Distribution
Members are leaves in a binary ratchet tree. Each internal node has a key pair known only to members in its subtree. When distributing a new epoch, the committer encrypts path secrets to copath nodes — O(log N) encryptions instead of O(N).
root ← epoch secret derived from root path secret
/ \
X Y ← internal nodes (key pairs known to subtree)
/ \ / \
A B C D ← leaves (members)When member B is removed, the committer updates B's direct path (B → X → root) and encrypts:
- New path secret for X → encrypted to A's leaf key (copath sibling)
- New path secret for root → encrypted to Y's node key (covers both C and D)
Two encryptions instead of three. For N members: O(log N).
The epoch payload carries encrypted_path_secrets addressed to tree node positions, not individual members:
{
"epoch": {
"n": 2,
"committer": "<admin_pub>",
"encrypted_path_secrets": [
{ "node": 0, "ciphertext": "...", "ecdh_pub": "..." },
{ "node": 6, "ciphertext": "...", "ecdh_pub": "..." }
]
}
}Each member finds the entry for their copath ancestor, decrypts the path secret, and derives the epoch secret upward to the root. Tree structure (leaf assignments) is derived from the CT by replaying membership events.
Implementation note: The existing implementation has not yet adopted the tree ratchet. Migration is pending.
How MLS Data Rides on RBAC Events
In the old implementation, epoch secrets were embedded in Grant/Revoke events. In RBAC v2, membership changes use Move events. The MLS data piggybacks on Move content as application-defined fields (permitted by rbac-v2.md Section 8.1).
Every membership-changing Move by admin creates a new epoch — matching the old impl where Grant/Revoke always rotated. This provides backward secrecy (new members can't decrypt old messages) and forward secrecy (removed members can't decrypt new messages).
| Membership change | RBAC event | MLS payload |
|---|---|---|
| Admin invite | Move(OUTSIDER, MEMBER) by admin | New epoch for all members (existing + new) |
| Approve application | Move(PENDING, MEMBER) by admin | New epoch for all members (existing + approved) |
| Kick / Ban | Move(MEMBER, OUTSIDER/BLOCKED) by admin | New epoch for remaining members |
| Voluntary leave | Move(MEMBER, OUTSIDER) by Self | (none — rotate follows) |
| Auto-join | Move(OUTSIDER, MEMBER) by Self | (none — rotate follows) |
Admin-created Moves carry the epoch payload — the epoch object with encrypted_path_secrets:
{
"target": "<alice_pub>",
"from": "OUTSIDER",
"to": "MEMBER",
"epoch": {
"n": 1,
"committer": "<admin_pub>",
"encrypted_path_secrets": [
{ "node": 0, "ciphertext": "...", "ecdh_pub": "..." },
{ "node": 6, "ciphertext": "...", "ecdh_pub": "..." }
]
}
}Self-initiated Moves carry no epoch — the actor either doesn't have the epoch (auto-join) or can't distribute to others (leave). A separate rotate event follows, using the same epoch format.
Trait changes (Grant/Revoke of admin, muted, dataview) do NOT trigger epoch rotation. Traits modify permissions, not membership — the identity is still MEMBER and already holds the epoch.
Epoch Lifecycle
Group creation:- Owner generates epoch 0
- Init bootstraps owner to MEMBER with owner+admin
- Owner creates
rotatewith epoch 0 (self-encrypted, for recovery) - Owner invites initial members via Move(OUTSIDER, MEMBER) — each Move creates a new epoch distributed to all current members
- Move creates a new epoch distributed to all members (existing + new) — backward secrecy
- Move creates a new epoch distributed to remaining members (excluding removed) — forward secrecy
- Self-created Move has no
epochfield (member doesn't have it) - Admin creates
rotatewith new epoch including the auto-join member - Until rotate arrives, new member has MEMBER but cannot decrypt. Clients may implement peer-to-peer epoch delivery as a fallback when no admin is online
- Self-created Move has no
epochfield - Admin creates
rotatewith new epoch for remaining members
- Admin creates
rotatewith new epoch for all current members - No membership change — security hygiene or ratchet cost management
Monotonicity (client-validated): epoch.n must be strictly greater than the highest epoch.n in the CT.
Epoch Recovery
On sync, the client replays the CT to recover epoch secrets:
- Scan Move events with
epochfield — decrypt path secrets fromencrypted_path_secrets - Scan
rotateevents — decrypt path secrets fromencrypted_path_secrets - Derive epoch secret from path secrets (upward to root)
- Build epoch map:
{n → epoch_secret}
Security Properties
| Property | Status |
|---|---|
| Per-message key isolation | YES — unique key per message via ratchet |
| Per-sender chain isolation | YES — sender_pub in ratchet domain separation |
| Per-epoch forward secrecy | YES — independent random epoch secrets |
| Forward secrecy on removal | YES — epoch rotation excludes removed member |
| Post-compromise security | NO — identity key recovers all epochs from CT |
| Stateless decryption | YES — no persistent ratchet state |
| Multi-device support | YES — identity key sufficient for all epochs |
All members with the epoch secret can derive any sender's ratchet chain (no per-contact isolation — by design for group read access). Same CT tradeoff as DM — see dm.md Security Properties.