Skip to main content

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.

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.

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 to vector-mls.db.
  • MlsService in src-tauri/src/mls.rs — the Pacto facade that creates a persistent engine at get_mls_directory(...)/vector-mls.db, per account (see account_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:
TableRole
mls_groupsgroup_id, engine_group_id, name, eviction flag, timestamps
mls_keypackagesCached key packages for members/devices
mls_event_cursorsLast-seen Nostr event per group, used for cursor-based kind 444 backfill
Decrypted application messages are stored in the same unified chat/message model as DMs — the 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 not Send — 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 in message.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

PathCommand / flow
New group with memberscreate_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 groupinvite_member_to_group → refresh KeyPackages → add_member_device → engine add_members → publish commit; send Welcomes via Gift Wrap; merge pending commit locally
Welcomes always use 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

  1. The invitee must be subscribed to Gift Wraps addressed to their pubkey (covered by the standard sync and live subscription paths).
  2. On unwrap, if the inner kind is MlsWelcome, the engine’s process_welcome runs on a blocking thread.
  3. On success, mls_invite_received is emitted to the frontend (gated during bulk sync to avoid spamming the UI).

Listing and accepting pending welcomes

CommandRole
list_pending_mls_welcomesReturns SimpleWelcome rows; use the id field (inner welcome event id) for accepting
accept_mls_welcome(welcome_event_id_hex)Pass SimpleWelcome.idnot the wrapper_event_id
The accept flow at a high level: get_welcomeaccept_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 command remove_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:
  1. Set the evicted flag on the mls_groups row.
  2. Remove the chat from STATE and delete it from the DB.
  3. Drop the MLS event cursor for that group.
  4. 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.
Pending proposal errors — if you see log messages like Can't create message because a pending proposal exists or Unprocessable event, the MLS engine is blocking new application messages because an uncommitted proposal exists for the group.Common causes:
  • You already left the group, but the engine still holds a pending leave proposal.
  • Another member’s proposal has not been committed yet.
  • merge_pending_commit did not run after an add/remove path.
Mitigation: Another member can commit the pending proposal, or you can use engine-specific merge/discard APIs if available and then restart/re-sync. Pacto merges pending commits after remove_member_device and invite_member_to_group, but not necessarily after every leave_group.

Build docs developers (and LLMs) love