# BitChat ConversationStore Architecture and State Management Explained

> Explore BitChat's ConversationStore architecture and state management. Learn how this fine-grained observable model and write-only API centralize conversation data.

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

---

**BitChat stores every chat message in a single, main-actor-owned `ConversationStore` that centralizes all conversation-level data through a fine-grained observable model and a write-only intent API.**

The `ConversationStore` serves as the single source of truth for both public and private chats in the BitChat decentralized messaging application. According to the [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat) source code, this architecture eliminates stale data by enforcing that all mutations flow through controlled intent methods while allowing views to observe individual conversations without triggering unnecessary UI updates across the entire app.

## Core Architecture: Single-Writer, Single Source of Truth

At the heart of BitChat's state management is a **@MainActor-annotated** singleton that owns all mutable collections. This design pattern guarantees thread safety and eliminates data races by ensuring only one entity can modify conversation state.

In [`bitchat/App/ConversationStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/App/ConversationStore.swift), the store declares its exclusive ownership:

```swift
@MainActor
public final class ConversationStore: ObservableObject {
    private var conversations: [ConversationID: Conversation] = [:]
    private var conversationIDsByMessageID: [String: ConversationID] = [:]
    // ...
}

```

All mutations flow through **intent methods** — `append(_:to:)`, `upsertByID(_:in:)`, `setDeliveryStatus(_:for:in:)`, and others. This pattern prevents any external component from directly modifying conversation arrays, ensuring the store remains the definitive authority on chat state.

## Conversation Objects and Granular Publishing

Each logical chat maps to exactly one `Conversation` instance, defined in [`ConversationStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift) around lines 25-44:

```swift
@MainActor
public final class Conversation: ObservableObject {
    @Published public private(set) var messages: [BitchatMessage] = []
    @Published public var isUnread: Bool = false
    
    private var idToIndex: [String: Int] = [:]
    private var indexOffset: Int = 0
    private let cap: Int?
}

```

The **granular publishing** strategy is critical for performance. Views subscribe to a single `Conversation` rather than observing the entire store dictionary. When a message arrives in the mesh conversation, only mesh observers receive updates — private chat views remain idle, dramatically reducing SwiftUI recomputation cycles.

The `ConversationID` type (defined in [`AppArchitecture.swift`](https://github.com/permissionlesstech/bitchat/blob/main/AppArchitecture.swift)) identifies chats by three variants:
- `.mesh` — public mesh-wide broadcasts
- `.geohash(String)` — location-based public channels
- `.direct(PeerHandle)` — one-to-one private messaging

## Selection State and UI Coordination

Beyond message storage, the store manages **UI selection state**:

```swift
@Published public private(set) var activeChannel: ConversationID = .mesh
@Published public private(set) var selectedPrivatePeerID: PeerHandle?

public var selectedConversationID: ConversationID? {
    selectedPrivatePeerID.map { .direct($0) } ?? activeChannel
}

```

Changes flow through explicit methods in [`ConversationStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift) (lines 40-48):
- `setActiveChannel(_:)` — switches the public channel view
- `setSelectedPrivatePeer(_:)` — enters or exits a private conversation

This design keeps UI state centralized, enabling consistent navigation behavior across all app components.

## The Intent API: Controlled Mutations

The store exposes six primary intent methods in [`ConversationStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift) (lines 93-122):

| Method | Purpose |
|--------|---------|
| `append(_:to:)` | Ordered insert with deduplication by ID |
| `upsertByID(_:in:)` | Replace existing message or append |
| `setDeliveryStatus(_:for:in:)` | Update status with no-downgrade protection |
| `markRead(_:)` / `markUnread(_:)` | Toggle unread flags |
| `clear(_:)` / `remove(_:in:)` | Delete messages or entire conversations |
| `migrateConversation(from:to:)` | Handle geohash proximity transitions |

The **no-downgrade rule** for delivery status prevents race conditions where an older receipt could overwrite a newer confirmed delivery:

```swift
public func setDeliveryStatus(
    _ status: DeliveryStatus,
    for messageID: String,
    in conversationID: ConversationID
) {
    // Internal implementation rejects transitions like
    // .delivered → .sending
}

```

## Optimized Lookup: Message-to-Conversation Mapping

A store-level dictionary enables O(1) delivery receipt routing without scanning conversations:

```swift
private var conversationIDsByMessageID: [String: ConversationID] = [:]

```

When a message arrives, the store registers its ID (lines 53-61 in [`ConversationStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift)). Later, when a receipt acknowledges delivery, the store instantly locates the target conversation — no iteration required.

## Change Notifications for Background Services

After each intent completes, the store emits structured change events:

```swift
public enum ConversationChange {
    case appended(conversationID: ConversationID, message: BitchatMessage)
    case updated(conversationID: ConversationID, messageID: String)
    case removed(conversationID: ConversationID, messageID: String)
    case cleared(conversationID: ConversationID)
}

public let changes = PassthroughSubject<ConversationChange, Never>()

```

Background services — delivery tracking, sync engines, notification handlers — subscribe to `store.changes` instead of observing `@Published` properties. This separation lets UI code use SwiftUI's native observation while specialized consumers receive precise, filtered events.

## Memory Management: Cap and Trimming Policy

Each `Conversation` enforces a configurable **cap** (e.g., `TransportConfig.meshTimelineCap`). When exceeded, the oldest messages are trimmed with index preservation:

```swift
// From Conversation.swift implementation
private func trimIfNeeded() {
    guard let cap = cap, messages.count > cap else { return }
    let overflow = messages.count - cap
    indexOffset += overflow
    messages.removeFirst(overflow)
    rebuildIndex()
}

```

The `indexOffset` adjustment maintains valid ID-to-index mappings without full rebuilds, keeping message lookup O(1) even after aggressive trimming.

## State Flow: End-to-End Message Handling

Understanding how `ConversationStore` integrates with the broader architecture:

1. **Transport reception** — [`PublicMessagePipeline.swift`](https://github.com/permissionlesstech/bitchat/blob/main/PublicMessagePipeline.swift) or private peer handlers receive raw messages
2. **Intent invocation** — coordinators call `store.append(message, to: conversationID)`
3. **Ordered insertion** — `Conversation.insert(_:)` places by timestamp, updates `idToIndex`, trims if capped
4. **Registration** — message ID added to `conversationIDsByMessageID`
5. **Dual publication** — `@Published` updates SwiftUI views; `changes` subject notifies background services

Because mutations never escape the store, observers always see consistent state immediately after the intent returns.

## Development and Diagnostic Support

The store includes **invariant checking** for development builds (lines 200-229 in [`ConversationStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift)):

```swift
public func collectInvariantViolations() -> [String] {
    var violations: [String] = []
    for (id, conversation) in conversations {
        // Validates index consistency, cap compliance,
        // timestamp ordering, and map coherence
    }
    return violations
}

```

These checks catch synchronization bugs during testing without impacting release performance.

## Practical Usage Example

```swift
import BitChat

// Obtain the store (normally injected by AppRuntime)
let store = ConversationStore()

// Send a public mesh message
let message = BitchatMessage(
    id: UUID().uuidString,
    timestamp: Date(),
    payload: .text("Hello decentralized world!"),
    deliveryStatus: .sending
)
store.append(message, to: .mesh)

// Observe specific conversation (efficient SwiftUI integration)
let meshChat = store.conversation(for: .mesh)
let cancellable = meshChat.$messages
    .sink { messages in
        print("Mesh messages: \(messages.count)")
    }

// Confirm delivery when receipt arrives
store.setDeliveryStatus(.delivered, for: message.id, in: .mesh)

// Mark private conversation unread
store.setUnread(true, for: .direct(PeerHandle(id: "bob", routingPeerID: "bob-node")))

```

## Summary

- **Single-owner architecture**: `@MainActor` `ConversationStore` guarantees thread-safe, consistent state
- **Intent-only mutations**: All writes flow through typed methods; direct mutation is impossible
- **Granular observation**: Views subscribe to individual `Conversation` instances, not the entire store
- **O(1) lookups**: Dual indexing by message ID eliminates scan operations for receipts and updates
- **Dual publication**: `@Published` for SwiftUI, `PassthroughSubject` for background services
- **Memory safety**: Configurable per-conversation caps with efficient trimming and index preservation

## Frequently Asked Questions

### How does BitChat prevent race conditions in conversation updates?

The `ConversationStore` is annotated `@MainActor`, ensuring all mutations occur on the main thread. Additionally, the **no-downgrade rule** in `setDeliveryStatus(_:for:in:)` rejects status updates that would move from a more-confirmed to less-confirmed state, preventing out-of-order receipt processing from corrupting delivery tracking.

### Why does BitChat use Conversation objects instead of a flat message array?

Individual `Conversation` instances enable **fine-grained publishing**. When a message arrives in one chat, only observers of that specific `Conversation` receive updates. This architectural choice in [`ConversationStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift) (lines 27-30) prevents the cascade of SwiftUI view invalidations that would occur if the entire message dataset were observed together.

### How does the message-to-conversation map improve performance?

The `conversationIDsByMessageID` dictionary provides **O(1) lookup** for routing delivery receipts. Without this map, confirming message delivery would require scanning every conversation's message array. According to the source in [`ConversationStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ConversationStore.swift) (lines 53-61), this optimization is essential for the high-frequency receipt traffic in mesh networking scenarios.

### What happens when a conversation exceeds its message cap?

The `Conversation` class trims oldest messages while adjusting an `indexOffset` value. This preserves the validity of the `idToIndex` map without requiring a full rebuild, maintaining constant-time message lookup even under memory pressure. The cap is configurable per transport type via `TransportConfig`.