Nostrautica docs

Marmot Group Chat: current behavior

The only shipped per-event group-chat backend is Marmot (MLS-over-Nostr), enabled by a chat = marmot tag on the 31600 event config. This page describes what the implementation does today; the deep design docs are linked below.

Per-device identity model

Chat identity is per device, not per account. Every browser/device mints its own chat keypair and attests it to the account via a gift-wrapped kind-21607 attestation; each device is then an ordinary, independent MLS group member. This is GPT56-AUDIT §13.10's "Option B": distinct credentials are just distinct members, so the same npub can participate from mobile and desktop at once. Synced history across devices is a non-goal: MLS state is per-leaf and each device reads from its own join epoch forward.

Only attested device keys are chat members, for every account type. The raw account pubkey is never itself an implicit chat identity; it participates only through a device key it has attested.

What the coordinator does (admin bot)

The coordinator runs a headless Marmot admin bot (packages/coordinator/src/chat/):

What clients do

The app enrolls the current device's chat key at approval time, discovers the group via the roster's nostr_group_id, and reads live kind-445 traffic forward from its own join epoch. A device that loses its chat key mints a new one and re-attests; the old leaf is removed on revoke.

Rejoining a device that fell out of the group

A device can be a listed, active chat device of an approved attendee (it appears in the roster's chat_keys, so the app's "Chat devices" card shows it, badged as this device), while holding no group state at all: the coordinator's Welcome was lost, the local MLS state was cleared, or the group was recreated under a new routing id. Chat then looks joined but every send fails.

Re-advertising the same key package does not fix that, because it is a no-op at three points: marmot returns the existing unused local key package (so the same addressable kind-30443 is republished under the same d, same event id), the coordinator dedupes that event id for 30 days (marmot_consumed_kps), and it skips anyone who still holds a leaf.

So the app offers Rejoin this chat, next to a failed send, and in the "setting up" state once it has been slow for a while. It revokes this device's chat key (dropping the coordinator-held leaf), rotates the key package (a new event id under the same slot), and re-attests, which the coordinator handles as an ordinary enrolment: Add commit + Welcome. The device key, its label, its roster entry and its device-cap slot are all reused, unlike clearing site data, which mints a new identity and burns another slot.

The coordinator reconciles from its side too: whenever it deliberately syncs a member (approval, a fresh attestation, the startup backfill) it re-adds an attested device that holds no leaf even if that device's key package was already consumed. The passive kind-30443 watcher keeps the plain consumed-id check, so a relay replaying old key packages cannot drive repeated Add commits.

History from before the new Add stays unreadable in every case: MLS is forward-secret from each leaf's own join epoch.

The add decision table

Every add path funnels through one routine (tryAddKeyPackage), and what it does is a function of three things it can observe: whether this event already spent this key package's event id, whether the coordinator holds a leaf for the author, and which trigger is asking. Writing that out as a table is worth the space: five consecutive production incidents in this area were each a single wrong cell, and each fix reasoned about the trigger it had in hand rather than the whole column.

Leaf? Key package Passive 30443 watcher Deliberate sync: this device attested here Deliberate sync: backfill / sibling device
no fresh Add Add Add
no consumed skip (dedupe) re-Add (repairs a lost Welcome) re-Add
yes fresh skip remove leaf, then Add (re-enrolment) skip, see below
yes consumed skip skip skip

The one cell that needs the trigger, not just the state, is leaf + fresh key package. A fresh key package is not evidence that the device left this group, because one kind-30443 slot serves every event the device belongs to: rotating it to rejoin event A makes it look brand new to event B too. Only the device's own kind-21607 attestation naming this event says anything about its membership here, so that is the only thing allowed to drop a live leaf. The backfill runs on every coordinator restart (and therefore every deploy), and a sibling device's attestation sweeps in every device of the account, so treating either as a re-enrolment signal evicts members who were never in trouble. That is not a recoverable hiccup: an offline client loses every message sent before its next open, permanently.

The corresponding client-side rule: every check that means "am I in the room?" must ask about the leaf, not about whether a group id resolves. A removed member still holds the group state locally: that corpse is what makes "chat looks joined but every send fails" possible in the first place.