Pacto uses Messaging Layer Security (MLS) for private group conversations. MLS is an IETF standard that provides end-to-end encryption with forward secrecy and post-compromise security for groups of any size. Each group maintains an evolving cryptographic tree (the ratchet tree); messages are encrypted to the current epoch’s keys so that removed members cannot read future traffic, and rejoining members cannot read past traffic. Pacto integrates the MDK MLS implementation to get this guarantee while keeping the wire layer thin — group messages travel as Nostr kind 444 events, just like DMs travel as kind 1059 Gift Wraps.Documentation Index
Fetch the complete documentation index at: https://mintlify.com/covenant-gov/pacto-app/llms.txt
Use this file to discover all available pages before exploring further.
Stack
The MLS subsystem is built from two crates and one facade:mdk_core— core MLS protocol logic (group operations, epoch transitions, message encryption/decryption).mdk_sqlite_storage— MDK’s own SQLite storage backend (MdkSqliteStorage), which persists cryptographic group state tovector-mls.db.MlsServiceinsrc-tauri/src/mls.rs— the Pacto facade that creates a persistent engine atget_mls_directory(...)/vector-mls.db, per account (seeaccount_manager.rs).
Two storage layers
MLS state is split between two SQLite databases that serve different purposes. Never consolidate them.1. MDK SQLite (vector-mls.db)
Owned and managed by the MDK engine. Holds cryptographic group state: ratchet trees, epoch secrets, key packages, pending proposals, and commit state. Do not hand-edit this database. Any manual modification will corrupt the engine’s view of group membership and epoch state.
2. App SQLite (vector.db)
The application’s own database, which holds plaintext-ish metadata the UI and sync logic need:
| Table | Role |
|---|---|
mls_groups | group_id, engine_group_id, name, eviction flag, timestamps |
mls_keypackages | Cached key packages for members/devices |
mls_event_cursors | Last-seen Nostr event per group, used for cursor-based kind 444 backfill |
events table — rather than a separate MLS-only messages table, so the UI handles both conversation types with the same code paths.
Nostr interaction
MLS state changes travel over Nostr in two directions: Invites: Kind 443 (MlsWelcome) arrives inside a Gift Wrap (kind 1059). The lib.rs unwrap path detects the inner kind, hands it to the engine’s process_welcome on a blocking thread, and emits mls_invite_received to the frontend.
Group messages: Kind 444 (MlsGroupMessage) carries the h tag set to the wire group id. The subscription handler reads the h tag, verifies that the local account is a member via db::load_mls_groups, then runs the engine’s process_message on a blocking thread before passing the decrypted rumor to process_rumor.
Threading / async
The MDK engine is notSend — its internal state cannot safely be moved across thread boundaries. All engine calls from async Tauri commands must go through tokio::task::spawn_blocking (or an equivalent blocking-thread wrapper) so that engine references do not cross .await points. The large comment block before list_group_cursors in lib.rs documents subscription behavior, deduplication keys (inner_event_id, wrapper_event_id), and privacy/logging expectations for MLS events.
Sending
MLS sends inmessage.rs flow through crate::mls::send_mls_message and related helpers in mls.rs. The function builds the same inner rumor types as a DM (kind 14, 15, 7, etc.), encrypts them via the MLS engine for the current group epoch, and publishes the result as kind 444 with the correct group h tag.
Invites and membership
Adding members
| Path | Command / flow |
|---|---|
| New group with members | create_group_chat / create_mls_group — engine returns one Welcome per initial member; each is sent via gift_wrap_to(TRUSTED_RELAYS, target_pubkey, welcome, …) |
| Invite to existing group | invite_member_to_group → refresh KeyPackages → add_member_device → engine add_members → publish commit; send Welcomes via Gift Wrap; merge pending commit locally |
TRUSTED_RELAYS, not arbitrary relay lists. An invite requires a published KeyPackage for the invitee’s device — if none exists, the invite fails with a “no keypackages” error.
Receiving an invite
- The invitee must be subscribed to Gift Wraps addressed to their pubkey (covered by the standard sync and live subscription paths).
- On unwrap, if the inner kind is
MlsWelcome, the engine’sprocess_welcomeruns on a blocking thread. - On success,
mls_invite_receivedis emitted to the frontend (gated during bulk sync to avoid spamming the UI).
Listing and accepting pending welcomes
| Command | Role |
|---|---|
list_pending_mls_welcomes | Returns SimpleWelcome rows; use the id field (inner welcome event id) for accepting |
accept_mls_welcome(welcome_event_id_hex) | Pass SimpleWelcome.id — not the wrapper_event_id |
get_welcome → accept_welcome → resolve engine vs wire group ids → persist mls_groups (clearing evicted if this is a re-invite) → create_or_get_mls_group_chat → save chat.
Eviction and leave
Kicking a member
The Tauri commandremove_member_device(group_id, member_pubkey, device_id) calls the engine’s remove_members. MDK enforces admin policy — Pacto does not duplicate the check in Rust before the call. At group creation, config typically sets only the creator as admin, so in practice only the creator can remove members unless the MDK group config gains more admins later.
After a successful remove: an evolution event is published, merge_pending_commit runs locally, and mls_group_updated is emitted for a UI refresh. The evicted user receives no special DM; they learn of eviction from engine errors on their next send or sync attempt (“own leaf not found”, “evicted”, etc.).
Eviction cleanup
When this client is removed from a group, sync or live kind 444 handling detects eviction-like engine errors and runs cleanup:- Set the
evictedflag on themls_groupsrow. - Remove the chat from
STATEand delete it from the DB. - Drop the MLS event cursor for that group.
- Emit
mls_group_left.
list_mls_groups skips evicted groups. A re-invite followed by accept_mls_welcome clears the evicted flag and restores the group.
Voluntary leave
leave_mls_group(group_id) emits a leave proposal via the engine, publishes it, removes the group from local metadata for the leaver, and emits mls_group_left — the group disappears from the leaver’s list immediately. The group continues for remaining members; the MLS tree updates without the leaver.
Admin handoff: Pacto does not currently expose “add admin” or “transfer MLS admin”. Only the creator is admin at creation. If the creator leaves, the MLS group ends up with no admins, meaning no further kicks are possible from MLS until MDK and the app support admin updates. Squad-level roles (e.g. Hats) are a separate product layer.