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 — 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

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.

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 changeRBAC eventMLS payload
Admin inviteMove(OUTSIDER, MEMBER) by adminNew epoch for all members (existing + new)
Approve applicationMove(PENDING, MEMBER) by adminNew epoch for all members (existing + approved)
Kick / BanMove(MEMBER, OUTSIDER/BLOCKED) by adminNew epoch for remaining members
Voluntary leaveMove(MEMBER, OUTSIDER) by Self(none — rotate follows)
Auto-joinMove(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:
  1. Owner generates epoch 0
  2. Init bootstraps owner to MEMBER with owner+admin
  3. Owner creates rotate with epoch 0 (self-encrypted, for recovery)
  4. Owner invites initial members via Move(OUTSIDER, MEMBER) — each Move creates a new epoch distributed to all current members
Admin invite / approve:
  • Move creates a new epoch distributed to all members (existing + new) — backward secrecy
Admin kick / ban:
  • Move creates a new epoch distributed to remaining members (excluding removed) — forward secrecy
Auto-join:
  • Self-created Move has no epoch field (member doesn't have it)
  • Admin creates rotate with 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
Voluntary leave:
  • Self-created Move has no epoch field
  • Admin creates rotate with new epoch for remaining members
Standalone rotation:
  • Admin creates rotate with 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:

  1. Scan Move events with epoch field — decrypt path secrets from encrypted_path_secrets
  2. Scan rotate events — decrypt path secrets from encrypted_path_secrets
  3. Derive epoch secret from path secrets (upward to root)
  4. Build epoch map: {n → epoch_secret}

Security Properties

PropertyStatus
Per-message key isolationYES — unique key per message via ratchet
Per-sender chain isolationYES — sender_pub in ratchet domain separation
Per-epoch forward secrecyYES — independent random epoch secrets
Forward secrecy on removalYES — epoch rotation excludes removed member
Post-compromise securityNO — identity key recovers all epochs from CT
Stateless decryptionYES — no persistent ratchet state
Multi-device supportYES — 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.