- Add complete docs/systems/ reference (18 system docs) - Add federation relay status doc and prior spec/plan docs - Remove superseded docs/federation-dm-s2s.md (replaced by docs/systems/federation.md) - CLAUDE.md updates - Minor fixes in social.ts, types.ts, AddDmMemberModal, NewDmModal, UserSettings
28 KiB
DM System
Source files:
packages/server/src/routes/dm.ts-- REST endpoints for DM CRUD, group lifecycle, message send/edit/delete, federation event queueing,broadcastDmMessage()with soft-close reopen logicpackages/server/src/routes/federation.ts-- Inbound relay event processors:processMemberAddEvent,processMemberRemoveEvent,processOwnershipTransferEvent,processCreateEvent,processUpdateEvent,processDeleteEvent, reaction processors, identity resolution (resolveLocalUser,resolveOrCreateReplicatedUser,findOrCreateDmChannel)packages/server/src/utils/federationOutbox.ts--queueOutboxEvent,appendMutationLog,queueDmRelay,getDmParticipants,getGroupDmTargetOrigins,computeFederatedId,buildRelayPayloadpackages/server/src/utils/storageJanitor.ts--cleanupSoftDeletedDmChannels()(24h grace period hard-delete)packages/server/src/db/migrate.ts-- Self-healing migration for corrupted group DM ownershippackages/server/src/ws/handler.ts--sendToDmMembers()broadcasts (ConnectionManager method)packages/web/src/stores/spaceStore.ts-- Zustand DM state:addDmChannel,removeDmChannel,addDmMember,removeDmMember,updateDmOwner,closeDm,leaveDm,findExistingDmForUserpackages/web/src/hooks/useWebSocket.ts-- Frontend WS event handlers fordm_channel_created,dm_channel_closed,dm_member_added,dm_member_removed,dm_owner_updatedpackages/web/src/components/modals/NewDmModal.tsx-- 1-on-1 DM creation UI with user search and deduplicationpackages/web/src/components/modals/AddDmMemberModal.tsx-- Group DM member add / 1-on-1 upgrade UI
DB tables: dm_channels, dm_members, dm_messages, dm_reactions, read_states, attachments, embeds. See docs/systems/database.md for full schemas.
Related specs: docs/systems/federation.md (wire protocol, outbox worker, peer lifecycle), docs/systems/websocket.md (event wire formats), docs/systems/voice.md (DM call state machine).
Channel Type Identification
| Property | 1-on-1 DM | Group DM |
|---|---|---|
ownerId |
NULL |
Creator's local user ID (never NULL) |
federatedId format |
32-char hex (SHA-256 hash) | 36-char UUID (random) |
| Mutable membership | No (immutable pair) | Yes (owner adds, anyone leaves) |
| Max members | 2 | 10 |
| Friendship required | No | Yes (for new adds; exempt for existing DM members during 1-on-1 upgrade) |
| Soft-close | Yes (closed=1 on dm_members) |
Yes (same) |
| Leave | Not supported (use close) | Yes (DELETE /api/dm/:id/members) |
| Deletion | Never (1-on-1 DMs persist) | Soft-delete when last member leaves, hard-delete after 24h |
Critical invariant: ownerId must NEVER be set to NULL on a group DM. A NULL ownerId identifies the channel as 1-on-1 -- nulling it corrupts the channel's type identity and breaks membership logic.
Federated ID Algorithm
// federationOutbox.ts:computeFederatedId()
// 1-on-1: deterministic SHA-256 hash of sorted home user IDs
// Same result on any instance for the same user pair
const sorted = [homeUserIdA, homeUserIdB].sort();
const federatedId = crypto.createHash('sha256')
.update(sorted.join(':'))
.digest('hex')
.slice(0, 32); // 32-char hex string
// Group: random UUID assigned by the creating instance
const federatedId = crypto.randomUUID(); // 36-char UUID with dashes
The format difference (32-char hex vs 36-char UUID with dashes) allows detecting channel type independently of ownerId. The self-healing migration uses this: length(federated_id) = 36 AND federated_id LIKE '________-____-____-____-____________' identifies group DMs.
1-on-1 DM Creation
Endpoint: POST /api/dm -- dm.ts:dmRoutes
Request: { userId: string }
Deduplication algorithm:
- Query all
dm_membersrows whereuserId = caller - For each membership, check if
targetUserIdis also a member of that channel - If found, verify exactly 2 members in that channel (skip group DMs that happen to include the target)
- If the channel exists and is not soft-deleted: reopen if caller had
closed=1, return existing channel - If no match: create new channel atomically in a transaction
Creation transaction:
- Insert
dm_channelswithownerId = NULL, nofederatedId(assigned lazily when federation relay first fires) - Insert two
dm_membersrows (caller + target)
Post-creation:
- Send
dm_channel_createdto the target user via WebSocket - Return 201 with the
DmChannelresponse to the caller
No federation event queued at creation time. The federatedId for 1-on-1 DMs is computed on demand when the first message is relayed via queueDmRelay(). The receiving instance uses findOrCreateDmChannel() which computes the deterministic hash and creates the channel if needed.
Group DM Creation
Endpoint: POST /api/dm/group -- dm.ts:dmRoutes
Request: CreateGroupDmRequest
interface CreateGroupDmRequest {
users: GroupDmUserIdentity[]; // At least 2
fromDmChannelId?: string; // Source 1-on-1 DM for upgrade
}
interface GroupDmUserIdentity {
id: string;
homeUserId?: string | null;
homeInstance?: string | null;
}
Validation:
usersarray must have at least 2 entries (minimum 3 total members including caller)- Total members (1 + users.length) capped at 10
- Each identity resolved to a local user row:
- If
homeUserId+homeInstanceprovided:resolveOrCreateReplicatedUser() - Else: direct ID lookup, falling back to
resolveLocalUser()for remote snowflake IDs
- If
- No duplicate resolved IDs
- Caller cannot include themselves
- All target users must be friends with the caller (exception: existing DM members when
fromDmChannelIdreferences a 1-on-1 DM the caller belongs to)
Creation transaction:
- Insert
dm_channelswithownerId = caller - Insert
dm_membersfor caller + all target users
Post-creation federation setup:
- If federation relay is enabled and any member has a remote
homeInstance:- Generate random UUID
federatedIdviacomputeFederatedId() - Update channel with
federatedId,ownerHomeUserId,ownerHomeInstance
- Generate random UUID
Broadcasting (local-only principle):
dm_channel_createdsent only to members whosehomeInstancematches this instance- Remote members receive the channel via federation relay bootstrap on their home instance
System messages:
- One
member_addedsystem message per target user, inserted intodm_messages - Broadcast only to local members (remote instances create their own system messages)
Federation relay (for remote members):
- For each target user with a remote
homeInstance:- Queue
member_addevent with fullgrouproster (all participants) targetOriginsincludes all participant home origins plus the new member's origin- Event
messageIdformat:member_add:{userId}:{timestamp}
- Queue
Soft-Close and Reopen
Close (Hide)
Endpoint: DELETE /api/dm/:id -- dm.ts:dmRoutes
- Verify caller is a member
- Set
dm_members.closed = 1for the caller (preserves membership) - Send
dm_channel_closedto the caller (multi-tab sync) - Channel disappears from the caller's sidebar but they remain a member
Automatic Reopen
Trigger: dm.ts:broadcastDmMessage()
When a new message arrives in a DM channel, for each member with closed = 1:
- Flip
closedback to0 - Send
dm_channel_createdwith full channel payload (including the new message aslastMessage) so their sidebar picks it up - Then send the
dm_message_createdevent
This ensures closed DMs resurface automatically when new activity occurs.
Frontend
spaceStore.closeDm(id)callsapi.dm.close(id)then removes the channel fromdmChannelsstatedm_channel_closedWS event callsremoveDmChannel(id)which also cleans up unread/read state viachatStore.removeChannelStates()
Adding Members to an Existing Group DM
Endpoint: POST /api/dm/:id/members -- dm.ts:dmRoutes
Request: { userId: string }
Validation:
- Caller must be a member of the channel
- Channel must be a group DM (
ownerIdis not NULL) - Caller must be the group owner (
dmChannel.ownerId === request.userId) - Target user must exist
- Caller and target must be friends
- Target must not already be a member
- Current member count must be < 10
Lazy federation setup:
- If the channel lacks a
federatedIdand the new member (or any existing member) is remote:- Generate UUID
federatedId, setownerHomeUserIdandownerHomeInstance
- Generate UUID
Broadcast sequence:
dm_member_addedto all existing members (before the new one sees it)dm_channel_createdto the new member (full channel payload)- System message (
member_added) broadcast to all members viasendToDmMembers
Federation relay:
- Queue
member_addwith fullgrouproster - Target origins include the new member's home instance even if not previously in the group
Leaving a Group DM
Endpoint: DELETE /api/dm/:id/members -- dm.ts:dmRoutes
Preconditions:
- Caller must be a member
- Channel must be a group DM (
ownerIdis not NULL; 1-on-1 DMs return 400)
Sequence:
- If caller is in an active voice call in this DM, leave it first (auto-end call if room becomes empty)
- Capture federation target origins BEFORE member deletion (so the leaving user's peer is included)
- Insert
member_removedsystem message (while user is still a member, so broadcast includes them) - Delete
dm_membersrow - Delete
read_statesfor the departing user - Queue
member_removefederation event (reason:'leave')
Ownership transfer (if caller was owner and members remain):
- New owner = first remaining member (
remainingMembers[0]) - Update
dm_channels.ownerId - Broadcast
dm_owner_updatedto remaining members - Insert
owner_changedsystem message - Update
ownerHomeUserId/ownerHomeInstanceon the channel - Queue
ownership_transferfederation event
Last member leaves:
- Soft-delete: set
dm_channels.deletedAt = Date.now() - No ownership transfer (no remaining members)
- Storage janitor hard-deletes after 24-hour grace period
Broadcast to leaving user:
dm_channel_closedevent (removes from sidebar)
DM Deletion and Garbage Collection
Soft-Delete Trigger
A channel is soft-deleted (deletedAt set) when:
- The last member leaves a group DM (
dm.tsleave endpoint) - The last local member is removed via federation relay (
federation.ts:processMemberRemoveEvent)
Hard-Delete (GC)
Function: storageJanitor.ts:cleanupSoftDeletedDmChannels()
Grace period: 24 hours from deletedAt
Cascade (single transaction):
- Delete
dm_reactionsfor all message IDs - Delete
embedsfor all message IDs - Delete
attachments(DB rows) for all message IDs - Delete
federation_file_queueentries for all message IDs - Delete
dm_messages - Delete
dm_members(should be 0, defensive) - Delete
read_states - Delete
federation_outboxentries (bycontextId) - Delete
federation_mutation_logentries (bycontextId) - Delete the
dm_channelsrow
Post-transaction: Delete attachment files from disk (filesystem ops are idempotent)
Re-activation
If a member_add federation event arrives for a soft-deleted channel (non-null deletedAt), processMemberAddEvent cancels the soft-delete by setting deletedAt = NULL.
Message Operations
Send Message
Endpoint: POST /api/dm/:id/messages -- dm.ts:dmRoutes
Rate limit: 5 per 5 seconds per user
Request: { content?: string, attachments?: string[], replyToId?: string }
Validation:
- Caller must be a member (
isDmMember) - Must have content or attachments (not both empty)
- Content max length: 4000 chars (
MAX_MESSAGE_LENGTH) - Attachment ownership verified (must be unlinked and owned by caller)
Flow:
- Insert message + link attachments in a single transaction
- Hydrate full
DmMessageWithUserviagetDmMessageWithUser() - Broadcast via
broadcastDmMessage()(handles soft-close reopen) - Queue federation relay via
queueDmRelay(message, channelId, 'create') - Resolve embeds asynchronously via
setImmediate()
Edit Message
Endpoint: PATCH /api/dm/messages/:id -- dm.ts:dmRoutes
- Author-only (
msg.userId !== request.userIdreturns 403) - Update content and set
editedAt - Delete old embeds, re-resolve new embeds asynchronously
- Broadcast
dm_message_updatedto all members - Queue federation relay via
queueDmRelay(updated, channelId, 'update')
Delete Message
Endpoint: DELETE /api/dm/messages/:id -- dm.ts:dmRoutes
- Author-only
- Collect attachment filenames before deletion
- Delete attachments, reactions, and message atomically in a transaction
- Clean up files from disk
- Broadcast
dm_message_deletedto all members - Federation:
appendMutationLog()+queueOutboxEvent()witheventType='delete'
Note: Delete federation events are queued without targetOrigins -- they broadcast to ALL active peers regardless of group membership. This differs from create/update which use getGroupDmTargetOrigins() for group DMs.
Federation Relay Pipeline
This section covers the DM-specific application-level relay logic. For the wire protocol, outbox delivery, HMAC signing, and retry mechanics, see docs/systems/federation.md.
Outbound: Target Origin Resolution
Function: federationOutbox.ts:getGroupDmTargetOrigins()
Channel has ownerId?
├── No (1-on-1) → return undefined → broadcasts to ALL active peers
└── Yes (group) → query all members' homeInstances
→ normalize bare domains to full URLs
→ filter out our own origin
→ return unique peer origins
Function: federationOutbox.ts:queueDmRelay()
Single source of truth for message relay payload construction:
- Build attachment array with
sourceUrlpointing to local uploads - Fetch
getDmParticipants()for identity resolution on the receiving side - Fetch channel to check for
federatedId(included only for group DMs with an owner) - Call
appendMutationLog()+queueOutboxEvent()with the constructed payload
Outbound: Relay Payload Structure
// federationOutbox.ts:buildRelayPayload()
{
userId: localUser.id,
homeUserId: user.homeUserId || user.id,
homeInstance: user.homeInstance || getOurOrigin(),
content: message.content,
replyToId: message.replyToId ?? null,
editedAt: message.editedAt ?? null,
createdAt: message.createdAt,
}
The full event includes participants (all channel members with their federated identities and profile snapshots) and optionally federatedId (for group DMs).
Inbound: Message Create
Function: federation.ts:processCreateEvent()
Deduplication: Check sourceInstance + sourceMessageId -- reject if already exists.
Participant resolution:
- ALL participants resolved via
resolveOrCreateReplicatedUser()(auto-creates stubs for unknown remote users) - Profile data from relay event hydrated onto replicated user stubs via
hydrateReplicatedUserProfile()
Channel resolution (group vs 1-on-1):
Has federatedId? |
Path |
|---|---|
| Yes (group DM) | Lookup by federatedId. If not found, reject (channel_not_found) -- channel must exist from prior member_add bootstrap |
| No (1-on-1 DM) | Compute deterministic federatedId from the two participants' home user IDs, then findOrCreateDmChannel() |
findOrCreateDmChannel():
- Lookup by
federatedId: if found, ensure both users are members (re-add if removed) - If not found: create new channel with
ownerId = NULLand the computedfederatedId, add both users as members
Attachment handling:
- Attachment rows created immediately with
filename = sourceUrl(remote URL) - Frontend renders remote URLs directly when filename starts with
http - Background file worker downloads the file and updates the filename to the local path
- SSRF protection:
isUrlFromPeer()validates attachment URL hostname matches peer origin
Broadcast filtering:
- Skip members whose
homeInstance === sourceInstance(they already have the message from their home instance)
Inbound: Message Update
Function: federation.ts:processUpdateEvent()
- Find local message by
sourceInstance+sourceMessageId - Update content and
editedAt - Broadcast
dm_message_updatedto all local members
Inbound: Message Delete
Function: federation.ts:processDeleteEvent()
- Find local message by
sourceInstance+sourceMessageId - Delete attachments, reactions, and message atomically
- Clean up attachment files from disk
- Broadcast
dm_message_deletedto all local members
Inbound: Reaction Add/Remove
Functions: federation.ts:processReactionAddEvent(), processReactionRemoveEvent()
- Uses
resolveLocalDmMessage()for cross-instance message resolution (handles messages originating on this instance vs relayed messages) - Reaction add is idempotent (existing reaction accepted silently)
- Broadcasts
reaction_added/reaction_removedto local members
Group DM Federation Lifecycle
Bootstrap Path (Channel Does Not Exist Locally)
Trigger: processMemberAddEvent() receives a member_add event with event.group metadata for a federatedId not found locally.
Sequence:
- Resolve owner via
resolveOrCreateReplicatedUser()-- guaranteed non-null - Create
dm_channelsrow withownerId,federatedId,ownerHomeUserId,ownerHomeInstance - For each member in
event.group.members: resolve viaresolveOrCreateReplicatedUser(), insertdm_members(idempotent skip if already exists) - Set local
bootstrapped = trueflag - Build full
DmChannelpayload - Send
dm_channel_createdonly to members whose home instance is THIS instance (local-only broadcast)
Incremental Path (Channel Already Exists)
Trigger: processMemberAddEvent() finds the channel by federatedId.
Sequence:
- Validate authority:
sourceInstancemust matchchannel.ownerHomeInstance - Cancel soft-delete if channel was pending GC (
deletedAtset) - Resolve added user via
resolveOrCreateReplicatedUser() - Enforce 10-member cap
- Insert
dm_membersrow (idempotent) - Insert system message for member addition
- Broadcast
dm_message_created(system) anddm_member_addedto local members
Bootstrap vs Incremental Batching
When a group DM is created with multiple remote members, the origin instance queues one member_add event per remote member. These events arrive in a batch on the receiving instance. Only the FIRST event triggers bootstrap (channel not found). Subsequent events find the channel and take the incremental path. This is correct because the bootstrap adds ALL roster members from event.group.members, making the incremental events idempotent.
Member Remove (Inbound)
Function: federation.ts:processMemberRemoveEvent()
- Find channel by
federatedId. If not found, accept silently (idempotent). - Authority check: for kicks,
sourceInstancemust matchownerHomeInstance. For self-leave (reason === 'leave'), any instance is accepted. - Resolve user via
resolveLocalUser()(they should already exist). If not found, accept silently. - Insert
member_removedsystem message (before deletion so broadcast includes leaving user) - Delete
dm_membersrow - Delete
read_states - Broadcast
dm_member_removedto remaining local members - If zero members remain: soft-delete channel
Ownership Transfer (Inbound)
Function: federation.ts:processOwnershipTransferEvent()
- Find channel by
federatedId. If not found, accept silently. - Authority check:
sourceInstancemust matchchannel.ownerHomeInstance - Resolve new owner via
resolveOrCreateReplicatedUser()-- MUST guarantee non-null (see invariant above) - Update
dm_channels:ownerId,ownerHomeUserId,ownerHomeInstance - Broadcast
dm_owner_updatedto local members - Insert
owner_changedsystem message
System Messages
System messages (type = 'system' in dm_messages) record group lifecycle events in the chat timeline.
Event Types
| Event | Content JSON | Actor (userId) |
|---|---|---|
member_added |
{ event, targetUserId, targetDisplayName } |
User who added them |
member_removed |
{ event, targetUserId, targetDisplayName, reason } |
User who left/was removed |
owner_changed |
{ event, newOwnerId, newOwnerDisplayName } |
Previous owner |
Instance-Local Creation
System messages are NOT relayed via federation. Each instance creates its own independently:
- Origin instance: Creates in the REST endpoint, broadcasts to local members only (group DM creation) or all local members (incremental add/leave)
- Receiving instance: Creates in the federation event processor, broadcasts to local members
This avoids duplicate system messages for users connected to multiple instances.
Local-Only Broadcast Principle
Users connected to multiple instances must see each DM channel exactly once (from their home instance). dm_channel_created and system message broadcasts during group DM creation filter to local members:
const isLocalMember = (u: { homeInstance?: string | null }) =>
!u.homeInstance || !domainOrigin ||
u.homeInstance === domainOrigin ||
`https://${u.homeInstance}` === domainOrigin;
Applies to:
dm_channel_createdbroadcasts (both origin and receiving instance bootstrap)- System message broadcasts during group DM creation (origin instance only)
Does NOT apply to:
- Regular DM messages (
dm_message_createdfor user messages) -- these broadcast to all localdm_members dm_member_added/dm_member_removed/dm_owner_updatedstructural events
Frontend State Management
Zustand Store (spaceStore.ts)
| Action | Behavior |
|---|---|
addDmChannel(channel, origin?) |
Prepends to dmChannels, deduplicates by ID, records origin in channelOriginMap |
removeDmChannel(id) |
Filters from dmChannels, cleans up unread/read state via chatStore.removeChannelStates() |
addDmMember(dmChannelId, user) |
Appends user to channel's members array (dedup by ID) |
removeDmMember(dmChannelId, userId) |
Filters user from channel's members array |
updateDmOwner(dmChannelId, newOwnerId) |
Updates ownerId on the channel |
closeDm(id) |
Calls api.dm.close(id) via origin-aware API client, removes from state |
leaveDm(id) |
Calls api.dm.leave(id) via origin-aware API client, removes from state |
findExistingDmForUser(targetUser) |
Scans dmChannels for a 2-member DM where the other member's homeUserId matches the target's homeUserId |
WebSocket Event Handlers (useWebSocket.ts)
| WS Event | Handler |
|---|---|
dm_channel_created |
Normalize remote user assets, call addDmChannel(channel, origin) |
dm_channel_closed |
Call removeDmChannel(dmChannelId) |
dm_member_added |
Normalize remote user assets, call addDmMember(dmChannelId, user) |
dm_member_removed |
Call removeDmMember(dmChannelId, userId) |
dm_owner_updated |
Call updateDmOwner(dmChannelId, newOwnerId) |
New DM Modal (NewDmModal.tsx)
- User types a search query (min 2 chars, 300ms debounce)
- Calls
api.social.search()for user results - On user selection:
- Check
findExistingDmForUser()for deduplication -- navigate to existing DM if found - Otherwise call
api.dm.create({ userId })via the origin-aware API client - Add channel to state and navigate
- Check
Add DM Member Modal (AddDmMemberModal.tsx)
- Shows the caller's friends list, filtered by search query
- Excludes current DM members (shown as "Already in this DM")
- Enforces 10-member cap in the UI (
remainingSlotscalculation) - Two creation paths:
- 1-on-1 DM upgrade: If
dmChannel.ownerIdis null, callsapi.dm.createGroup()with the existing other member + selected friends +fromDmChannelId - Existing group DM: Calls
api.dm.addMember()sequentially for each selected friend
- 1-on-1 DM upgrade: If
Self-Healing Migration
Location: migrate.ts:runMigrations()
Detection: Find dm_channels where:
owner_id IS NULLfederated_id IS NOT NULLdeleted_at IS NULLlength(federated_id) = 36 AND federated_id LIKE '________-____-____-____-____________'(UUID format = group DM)
Repair: Set owner_id to the first remaining dm_members.user_id.
Root cause: A bug in processOwnershipTransferEvent (fixed in commit cd7aff0) used resolveLocalUser with a ?? null fallback. When resolution failed (even transiently), it set ownerId = NULL, converting the group DM into a 1-on-1-looking channel.
Fix: processOwnershipTransferEvent now uses resolveOrCreateReplicatedUser() which always returns a valid user, making null impossible.
Origin Normalization
Critical pitfall (origin format mismatch):
| Location | Format | Example |
|---|---|---|
users.home_instance |
Bare domain | nova.ddns.net |
federation_peers.origin |
Full URL | https://nova.ddns.net |
getOurOrigin() |
Full URL | https://orbit.ddns.net |
When comparing home instances against peer origins, always normalize:
const normalized = homeInstance.startsWith('http')
? homeInstance
: `https://${homeInstance}`;
getGroupDmTargetOrigins() performs this normalization. Failure to normalize causes queueOutboxEvent to find zero matching peers and silently drop events.
API Reference
REST Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/api/dm |
JWT | List caller's DM channels (excludes closed=1 and deleted_at IS NOT NULL) |
POST |
/api/dm |
JWT | Create or get existing 1-on-1 DM |
POST |
/api/dm/group |
JWT | Create group DM with multiple members |
DELETE |
/api/dm/:id |
JWT | Soft-close DM for caller |
POST |
/api/dm/:id/members |
JWT | Add member to group DM (owner only) |
DELETE |
/api/dm/:id/members |
JWT | Leave group DM |
GET |
/api/dm/:id/messages |
JWT | Get messages with cursor pagination |
POST |
/api/dm/:id/messages |
JWT | Send message (rate-limited: 5/5s) |
PATCH |
/api/dm/messages/:id |
JWT | Edit message (author only) |
DELETE |
/api/dm/messages/:id |
JWT | Delete message (author only) |
Pagination
GET /api/dm/:id/messages supports cursor-based pagination:
before: Message ID cursor (fetch messages before this ID)limit: 1-100, default 50- Results returned in chronological order (oldest first)
DM Channel List Sorting
GET /api/dm returns channels sorted by lastMessage.createdAt descending (newest activity first), falling back to channel.createdAt for channels with no messages.
WebSocket Events
For full wire formats, see docs/systems/websocket.md.
State-Change Events
| Event | Direction | Triggered By |
|---|---|---|
dm_channel_created |
S->C | Group DM bootstrap, new 1-on-1, soft-close reopen |
dm_channel_closed |
S->C | User closes DM, user leaves group |
dm_member_added |
S->C | Incremental member add (not bootstrap) |
dm_member_removed |
S->C | Member leave/kick |
dm_owner_updated |
S->C | Ownership transfer |
Content Events
| Event | Direction | Triggered By |
|---|---|---|
dm_message_created |
S->C | New message (user or system) |
dm_message_updated |
S->C | Message edit |
dm_message_deleted |
S->C | Message delete |
Historical Bugs
| Bug | Symptom | Root Cause | Fix |
|---|---|---|---|
| ownerId nulling | Group DM becomes 1-on-1 | processOwnershipTransferEvent used resolveLocalUser ?? null |
Use resolveOrCreateReplicatedUser (always non-null) + self-healing migration |
| Origin normalization | Federation events silently dropped | getGroupDmTargetOrigins returned bare domains vs full URL peer origins |
Normalize to full URL before comparison |
| Missing federatedId in outbox | All membership events rejected by peer | Outbox worker reconstruction omitted federatedId |
Copy parsed.federatedId during reconstruction |
| Cross-instance duplicate channels | Duplicate sidebar entries | dm_channel_created broadcast to ALL members including remote |
Local-only broadcast principle |
| Bootstrap vs incremental confusion | N/A (design note) | bootstrapped flag is function-local; batch events work correctly because bootstrap adds ALL roster members |
No fix needed -- documented as correct behavior |