BitChat ConversationStore and AppRuntime Architecture: A Deep Dive into Message State Management

BitChat centralizes all message state in a single ConversationStore singleton owned by the @MainActor AppRuntime, providing O(1) incremental updates via a thin intent API while publishing changes through ObservableObject conversations.

The permissionlesstech/bitchat repository implements a centralized message state architecture that eliminates legacy synchronization complexity. Understanding how the ConversationStore and AppRuntime components interact is essential for developers building on or contributing to this peer-to-peer messaging platform.

ConversationStore: The Single Source of Truth

The ConversationStore holds every message, unread count, and delivery status for every conversation type, including public, mesh, geohash-based, and direct chats. It acts as the sole writer to message state, ensuring consistent reads and incremental updates without full-dictionary rebuilds.

Singleton Design and Main Actor Concurrency

The store is an @MainActor singleton instantiated by AppRuntime during application launch (see bitchat/App/AppRuntime.swift). This design guarantees that all mutations occur on the main thread, eliminating race conditions in the UI layer. Because the store lives on the main actor, a call to any mutation returns only after the new state is observable, ensuring consistent reads.

The Conversation Model and ObservableObjects

Each conversation is modeled by a reference-type ObservableObject called Conversation. A Conversation owns an incremental message-ID index and maintains a capped message array constrained by TransportConfig caps to prevent unbounded memory growth. These objects publish their own messages and unread state via @Published properties, allowing UI components to subscribe to granular updates rather than observing the entire messagesByConversation dictionary.

Intent API for Single-Writer Semantics

The store exposes a thin intent API that enforces single-writer semantics:

  • append(_:to:) – Adds a new message to a specific conversation
  • upsertByID(_:in:) – Updates or inserts a message by ID
  • setDeliveryStatus(_:for:in:) – Updates delivery tracking
  • markRead(_:) and markUnread(_:) – Manages read receipts
  • migrateConversation(from:to:) – Handles conversation transitions
  • clear(_:) – Removes conversation data

All internal collections are marked private(set), ensuring external code can read but never mutate directly. This guarantees that every state change flows through the intent surface, making side effects predictable and debuggable.

Publishing Strategy for UI and Background Consumers

The store employs a dual-publishing strategy to serve both UI and non-UI consumers. Each Conversation publishes changes via its own @Published properties, while the store exposes a global changes: PassthroughSubject<ConversationChange, Never> stream. Background services such as delivery tracking and background sync subscribe to this subject to receive fine-grained events like "message appended in conversation X" or "status changed," without triggering unnecessary UI refreshes.

AppRuntime: The Lifecycle Orchestrator

The AppRuntime is the top-level component that owns the ConversationStore and lives on the main actor. It serves as the entry point for the app’s lifecycle, bootstrapping the UI, initializing services (transport, verification, peer management), and wiring the store to the rest of the system.

Store Ownership and Initialization

On launch, AppRuntime creates the ConversationStore instance. Coordinators and view-models (such as ChatViewModel and PrivateInboxModel) obtain a reference to this store and either issue intents for writes or subscribe to its Conversation objects for reads. This ownership model eliminates the need for dependency injection containers or global variable abuse, as the runtime provides the authoritative store reference.

Decoupling from Legacy Synchronization

Because the ConversationStore is the sole writer, AppRuntime no longer requires the bridge-based synchronization loops that previously performed full-dictionary replacements. The architecture has removed legacy stores including PublicTimelineStore, PrivateChatManager, and LegacyConversationStore, with feature models now deriving their view data directly from the unified store. This consolidation reduces state duplication and eliminates an entire class of synchronization bugs.

High-Level Message Flow

The interaction between components follows a strict unidirectional flow:

  1. Incoming message triggers ChatViewModel (or an appropriate coordinator) to call store.append(message, to: conversationID).
  2. ConversationStore inserts the message incrementally, updates the per-conversation ID index, and publishes the change through both the specific Conversation object and the global changes stream.
  3. UI components observing that Conversation receive the update instantly, while background services listening to store.changes react for delivery tracking or notification dispatch.

Implementation Examples

Appending a message to a direct conversation:

let store = ConversationStore.shared   // owned by AppRuntime
let newMessage = ChatMessage(...)
store.append(newMessage, to: .directPeer(peerID))

Observing a specific conversation in a SwiftUI view:

@ObservedObject var convo: Conversation   // injected from the store
Text(convo.messages.last?.text ?? "")

Marking a message as read:

store.markRead(messageID)

Key Implementation Files

Component File Description
Conversation Store bitchat/App/ConversationStore.swift Core implementation with intent API and singleton
Conversation Model bitchat/App/Conversation.swift ObservableObject representing individual chats
Store Tests bitchatTests/ConversationStoreTests.swift Verification of intent API and state invariants
Design Document docs/CONVERSATION-STORE-DESIGN.md Architectural rationale and decisions
Runtime Entry bitchat/App/AppRuntime.swift Main actor orchestrator that creates the store
Architecture Overview docs/ARCHITECTURE_V2.md High-level system architecture documentation

Summary

  • ConversationStore is an @MainActor singleton that acts as the single source of truth for all message state, owned by the AppRuntime.
  • The store uses single-writer semantics via a thin intent API (append, upsertByID, setDeliveryStatus, etc.) to guarantee consistent, incremental updates.
  • Each Conversation is an ObservableObject with @Published properties, enabling O(1) per-conversation updates without full-dictionary rebuilds.
  • AppRuntime initializes the store and eliminates legacy synchronization by removing PublicTimelineStore, PrivateChatManager, and other deprecated components.
  • The architecture supports both UI observation (via Conversation objects) and background processing (via PassthroughSubject<ConversationChange, Never>).

Frequently Asked Questions

What is the role of AppRuntime in BitChat?

AppRuntime is the top-level @MainActor component that initializes and owns the ConversationStore. It bootstraps the application lifecycle, initializes transport and verification services, and provides the central store reference to coordinators and view-models, eliminating the need for complex dependency injection or global state managers.

How does ConversationStore handle concurrent updates?

The store uses Swift's @MainActor annotation to ensure all mutations occur on the main thread. Because it operates as a single-writer with private(set) internal collections, external code cannot mutate state directly. All changes flow through the intent API, ensuring that reads and writes are atomic and that external observers receive updates only after the mutation completes.

Why does BitChat use an intent API pattern?

The intent API (methods like append(_:to:) and markRead(_:)) encapsulates state transitions, allowing the store to enforce invariants such as ID index maintenance, message capping based on TransportConfig, and unread-count calculations. This pattern prevents scattered mutability, makes state changes auditable, and enables O(1) incremental updates instead of expensive full-collection rebuilds.

Where are conversation messages actually stored?

Messages reside in the Conversation objects held by ConversationStore. Each Conversation maintains an in-memory array of messages (capped based on TransportConfig settings) and exposes them via @Published properties for SwiftUI observation. The store itself manages these objects in a dictionary keyed by conversation ID, providing the single point of access for the entire application.

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 →