BitChat ConversationStore Design Philosophy: A Single Source of Truth for Decentralized Chat

BitChat's ConversationStore acts as the single source of truth (SSOT) for all conversation state, enforcing single-writer semantics, fine-grained publishing, and derived read-only views to eliminate data races and synchronization bugs.

The ConversationStore in permissionlesstech/bitchat solves a critical problem in peer-to-peer messaging: fragmented state management across multiple view models and caches. By centralizing all conversation mutations through one intent API, BitChat achieves predictable, high-performance updates without the quadratic costs of legacy multi-store designs.

Core Design Principles of the ConversationStore

BitChat's ConversationStore architecture rests on seven deliberate principles that together create a robust, observable conversation layer.

Single Writer, Single Holder

All mutations flow through a single intent API exposed by ConversationStore. The store owns the mutable collections, while external components receive only read-only or derived views. This eliminates many-to-many mutation pathways that plagued legacy stores and bridges.

In ConversationStore.swift (lines 33-41), the store guards its internal state:

// Intent API — the only way to modify conversation state
ConversationStore.shared.append(message, to: .mesh)
ConversationStore.shared.upsertByID(message, in: .directPeer(peerID))
ConversationStore.shared.setDeliveryStatus(.delivered, forMessageID: "msg-1234")

External code cannot directly modify Conversation.messages. All changes route through these explicit intent methods.

Fine-Grained Publishing with ObservableObject

Each Conversation is an ObservableObject representing one timeline: .mesh, .geohash, or .direct. Views subscribe to one conversation, ensuring that appends to chat A never invalidate observers of chat B.

From ConversationStore.swift (lines 25-30):

// SwiftUI view observes a specific conversation
struct ChatView: View {
    @ObservedObject var conversation: Conversation

    var body: some View {
        List(conversation.messages) { msg in
            Text(msg.body)
        }
    }
}

// Retrieve lazily — creates conversation if needed
let conv = ConversationStore.shared.conversation(for: .directPeer(peerID))

This granularity prevents cascading view updates across unrelated conversations.

Incremental, Index-Based Mutations

Each conversation maintains a message-ID → logical-index map (indexByMessageID) enabling O(1) operations. The store supports:

  • Deduplication — fast checks against existing IDs
  • Delivery-status updates — in-place mutation without list rebuilds
  • Ordered insertions — by timestamp with automatic trimming of oldest entries

From ConversationStore.swift (lines 42-48, 95-100), insertions preserve ordering and enforce storage caps efficiently.

Unified Message-ID Membership Map

The global map conversationIDsByMessageID routes delivery receipts to every conversation holding a message. This handles edge cases where private messages appear in two direct conversations (sender and recipient views).

From ConversationStore.swift (lines 53-64), this map enables:

// Receipt contains only message ID — store finds all relevant conversations
ConversationStore.shared.setDeliveryStatus(.delivered, forMessageID: "msg-1234")

Derived, Read-Only Views for Feature Models

Legacy collections like ChatViewModel.messages or PrivateInboxModel.messagesByPeerID became thin, computed wrappers fetching directly from the store. This removes duplicated state and guarantees readers always see consistent data.

From ConversationStore.swift (lines 31-38), view models no longer own copies—they derive:

// Old: ChatViewModel owned and mutated its own messages array
// New: ChatViewModel.messages is a computed property reading from ConversationStore
var messages: [BitchatMessage] {
    ConversationStore.shared.conversation(for: .mesh).messages
}

Explicit Selection State

UI selection axes—active public channel and selected private peer—live inside the store. A derived selectedConversationID always points to an existing conversation, eliminating stale selection bugs.

From ConversationStore.swift (lines 41-49), selection changes trigger conversation switching without view model coordination.

Robust Invariant Auditing

The auditInvariants() method validates ordering, cap limits, index consistency, and map integrity. This debugging tool ensures the SSOT remains healthy under concurrent load.

From ConversationStore.swift (lines 94-108), audits check:

  • Timestamp ordering
  • Storage cap compliance
  • indexByMessageID consistency
  • conversationIDsByMessageID correctness

Practical Usage Patterns

Appending Messages

let message = BitchatMessage(
    id: "msg-1234",
    timestamp: Date(),
    body: "Hello mesh network"
)
ConversationStore.shared.append(message, to: .mesh)

Upsert for Media Progress

Media uploads use upsert to replace pending messages with completed ones:

ConversationStore.shared.upsertByID(completedMessage, in: .directPeer(somePeerID))

Observing in Views

struct ChatView: View {
    @ObservedObject var conversation: Conversation

    var body: some View {
        List(conversation.messages) { msg in
            MessageRow(message: msg)
        }
    }
}

Key Implementation Files

File Purpose
ConversationStore.swift Core SSOT implementation—intent API, selection state, global ID map, invariant auditing
CONVERSATION-STORE-DESIGN.md Design document covering problem analysis, solution rationale, and migration strategy
ConversationStoreTests.swift Invariant validation and intent semantics test suite
ChatViewModel.swift Reference implementation showing derived view patterns
ChatDeliveryCoordinator.swift Delivery-status routing through store API

Summary

  • Single source of truth — ConversationStore owns all mutable conversation state, eliminating scattered mutations
  • Intent-based API — All changes flow through explicit methods like append(_:to:), upsertByID(_:in:), setDeliveryStatus(_:forMessageID:)
  • Fine-grained observability — Each Conversation publishes independently; views subscribe to exactly what they display
  • O(1) mutations — indexByMessageID enables fast deduplication and updates without full list rebuilds
  • Derived views — Legacy view models read from the store instead of duplicating state
  • Invariant auditing — auditInvariants() catches consistency violations during development

Frequently Asked Questions

What problem does the single source of truth pattern solve in BitChat?

Before ConversationStore, conversation state was duplicated across ChatViewModel, PrivateInboxModel, and multiple caches. Updates required coordination across these copies, causing race conditions and stale data. The SSOT pattern consolidates mutations to one place, guaranteeing that all readers see identical, up-to-date state.

How does ConversationStore handle delivery status updates efficiently?

The store maintains conversationIDsByMessageID, a global map from message ID to all conversations containing that message. When a delivery receipt arrives with only a message ID, the store immediately locates every relevant conversation and applies the status update without scanning all messages. This design supports O(1) routing even when the same message appears in multiple conversations.

Why does each Conversation use ObservableObject instead of the entire store?

Granular observability prevents unnecessary view updates. If the entire ConversationStore were observable, any message in any conversation would invalidate all chat views. By making each Conversation an ObservableObject, SwiftUI re-renders only views subscribed to the specific conversation that changed—critical for performance with many active chats.

What happens when media uploads need to update existing messages?

The upsertByID(_:in:) intent handles replace-or-append semantics. When a pending media message receives upload progress or completion data, calling upsertByID replaces the existing entry in-place using indexByMessageID for O(1) lookup. This preserves message ordering while updating content, avoiding the need to rebuild the conversation's message array.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →