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

> Discover BitChat's ConversationStore design philosophy. Learn how SSOT, single-writer, and derived views prevent data races in decentralized chat for seamless synchronization.

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: architecture
- Published: 2026-08-04

---

**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`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift) (lines 33-41), the store guards its internal state:

```swift
// 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`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift) (lines 25-30):

```swift
// 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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift) (lines 53-64), this map enables:

```swift
// 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`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift) (lines 31-38), view models no longer own copies—they derive:

```swift
// 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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift) (lines 94-108), audits check:
- Timestamp ordering
- Storage cap compliance  
- `indexByMessageID` consistency
- `conversationIDsByMessageID` correctness

## Practical Usage Patterns

### Appending Messages

```swift
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:

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

```

### Observing in Views

```swift
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`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift) | Core SSOT implementation—intent API, selection state, global ID map, invariant auditing |
| [`CONVERSATION-STORE-DESIGN.md`](https://github.com/permissionlesstech/bitchat/blob/main/CONVERSATION-STORE-DESIGN.md) | Design document covering problem analysis, solution rationale, and migration strategy |
| [`ConversationStoreTests.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStoreTests.swift) | Invariant validation and intent semantics test suite |
| [`ChatViewModel.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ChatViewModel.swift) | Reference implementation showing derived view patterns |
| [`ChatDeliveryCoordinator.swift`](https://github.com/permissionlesstech/bitchat/blob/main/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.