> ## Documentation Index
> Fetch the complete documentation index at: https://whatsapp-rust.jlucaso.com/llms.txt
> Use this file to discover all available pages before exploring further.

# State Management & Persistence

> Device state management, PersistenceManager, and the DeviceCommand pattern in whatsapp-rust

## Overview

whatsapp-rust uses a strict state management architecture to ensure consistency and prevent race conditions. All device state modifications must go through the `PersistenceManager` using the `DeviceCommand` pattern.

<Warning>
  **Critical**: Never modify `Device` state directly. Always use `DeviceCommand` + `PersistenceManager::process_command()` for writes, or `get_device_snapshot()` for reads.
</Warning>

## Architecture

The state management system has three main components:

```
src/store/
├── persistence_manager.rs  # Central state coordinator
├── commands.rs             # DeviceCommand pattern
├── device.rs              # Device state structure
└── backend/               # Storage backend (SQLite)
```

## Device State

The `Device` struct holds all client state (defined in `wacore::store::device`):

```rust theme={null}
#[derive(Clone, Serialize, Deserialize)]
pub struct Device {
    pub pn: Option<Jid>,              // Phone number JID
    pub lid: Option<Jid>,             // Linked Identity JID
    pub registration_id: u32,
    #[serde(with = "key_pair_serde")]
    pub noise_key: KeyPair,           // Noise protocol keypair
    #[serde(with = "key_pair_serde")]
    pub identity_key: KeyPair,        // Signal protocol identity
    #[serde(with = "key_pair_serde")]
    pub signed_pre_key: KeyPair,
    pub signed_pre_key_id: u32,
    #[serde(with = "BigArray")]
    pub signed_pre_key_signature: [u8; 64],
    pub adv_secret_key: [u8; 32],
    #[serde(with = "account_serde", default)]
    pub account: Option<Arc<wa::ADVSignedDeviceIdentity>>,
    pub push_name: String,
    pub app_version_primary: u32,
    pub app_version_secondary: u32,
    pub app_version_tertiary: u32,
    pub app_version_last_fetched_ms: i64,
    #[serde(skip)]
    pub device_props: wa::DeviceProps,  // Not persisted
    #[serde(skip)]
    pub client_profile: ClientProfile,  // Not persisted; noise-handshake identity
    #[serde(default)]
    pub edge_routing_info: Option<Vec<u8>>,
    #[serde(default)]
    pub props_hash: Option<String>,
    #[serde(default)]
    pub next_pre_key_id: u32,
    #[serde(default)]
    pub server_cert_chain: Option<CachedServerCertChain>,
}
```

Location: `wacore/src/store/device.rs`

`server_cert_chain` caches the verified server cert chain returned by a successful XX (or XXfallback) handshake. `Device` exposes the leaf key on the next connect so `do_handshake` can attempt Noise IK and skip a server round trip. See [WebSocket & Noise Protocol — Noise Protocol Handshake](/advanced/websocket-handling#noise-protocol-handshake).

### Serialization details

The `Device` struct uses several custom serde strategies:

| Field                                                                     | Strategy            | Notes                                             |
| ------------------------------------------------------------------------- | ------------------- | ------------------------------------------------- |
| `noise_key`, `identity_key`, `signed_pre_key`                             | `key_pair_serde`    | Custom serde for Signal `KeyPair` types           |
| `signed_pre_key_signature`                                                | `BigArray`          | Handles fixed-size arrays larger than 32 bytes    |
| `account`                                                                 | `account_serde`     | Bridges buffa protobuf types to serde (see below) |
| `device_props`                                                            | `#[serde(skip)]`    | Transient, not persisted                          |
| `client_profile`                                                          | `#[serde(skip)]`    | Transient noise-handshake identity, not persisted |
| `edge_routing_info`, `props_hash`, `next_pre_key_id`, `server_cert_chain` | `#[serde(default)]` | Backward-compatible optional fields               |

The `account` field holds an `ADVSignedDeviceIdentity` (a buffa-generated protobuf type that lacks `serde::Deserialize`). The `account_serde` module bridges this gap by encoding the protobuf struct to bytes on serialization and decoding on deserialization:

```rust theme={null}
pub mod account_serde {
    pub fn serialize<S: Serializer>(
        val: &Option<Arc<wa::ADVSignedDeviceIdentity>>, s: S,
    ) -> Result<S::Ok, S::Error> {
        // Encodes to protobuf bytes, then wraps as Option<Vec<u8>>
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(
        d: D,
    ) -> Result<Option<Arc<wa::ADVSignedDeviceIdentity>>, D::Error> {
        // Deserializes Option<Vec<u8>>, then decodes protobuf bytes
    }
}
```

The `#[serde(default)]` attribute on `account` ensures backward compatibility — data serialized before this field existed will deserialize with `account: None`.

## PersistenceManager

The `PersistenceManager` is the gatekeeper for all state changes.

### Architecture

```rust theme={null}
pub struct PersistenceManager {
    device: Arc<tokio::sync::RwLock<Device>>,
    device_snapshot: std::sync::RwLock<Arc<Device>>,
    backend: Arc<dyn Backend>,
    dirty: Arc<AtomicBool>,
    save_notify: Arc<Notify>,
}
```

`device_snapshot` is rebuilt under the `device` write guard inside `modify_device` — the single mutation funnel — so it is always coherent with committed state.

Location: `src/store/persistence_manager.rs`

### Key Methods

#### Read-Only Access

```rust theme={null}
// Returns the cached Arc<Device> snapshot — sync, no clone, no contention against writers
pub fn get_device_snapshot(&self) -> Arc<Device> {
    self.device_snapshot.read().unwrap().clone()
}
```

Location: `src/store/persistence_manager.rs`

#### State Modification

```rust theme={null}
// Modify device state with a closure; rebuilds device_snapshot under the write guard
pub async fn modify_device<F, R>(&self, modifier: F) -> R
where
    F: FnOnce(&mut Device) -> R,
{
    let mut device_guard = self.device.write().await;
    let result = modifier(&mut device_guard);

    // Rebuild the cached snapshot before releasing the write lock
    *self.device_snapshot.write().unwrap() = Arc::new(device_guard.clone());

    // Mark dirty and notify background saver
    self.dirty.store(true, Ordering::Relaxed);
    self.save_notify.notify_one();

    result
}
```

Location: `src/store/persistence_manager.rs`

#### Command Processing

```rust theme={null}
// Process a device command (preferred for state changes)
pub async fn process_command(&self, command: DeviceCommand) {
    self.modify_device(|device| {
        apply_command_to_device(device, command);
    }).await;
}
```

Location: `src/store/persistence_manager.rs:145-150`

### Background Saver

The persistence manager runs a background task that periodically saves dirty state:

```rust theme={null}
pub fn run_background_saver(self: Arc<Self>, interval: Duration) {
    tokio::spawn(async move {
        loop {
            tokio::select! {
                _ = self.save_notify.notified() => {
                    debug!("Save notification received.");
                }
                _ = sleep(interval) => {}
            }
            
            if let Err(e) = self.save_to_disk().await {
                error!("Error saving device state: {e}");
            }
        }
    });
}
```

**How it works:**

1. Wakes up when notified OR every `interval` (typically 30s)
2. Checks if state is dirty (`dirty` flag)
3. If dirty, serializes device state and saves to database
4. Clears dirty flag

Location: `src/store/persistence_manager.rs:123-140`

### Initialization

```rust theme={null}
pub async fn new(backend: Arc<dyn Backend>) -> Result<Self> {
    // Ensure device row exists in database
    let exists = backend.exists().await?;
    if !exists {
        let id = backend.create().await?;
        debug!("Created device row with id={id}");
    }
    
    // Load existing state or create new
    let device = if let Some(serializable_device) = backend.load().await? {
        let mut dev = Device::new(backend.clone());
        dev.load_from_serializable(serializable_device);
        dev
    } else {
        Device::new(backend.clone())
    };
    
    let snapshot = Arc::new(device.clone());
    Ok(Self {
        device: Arc::new(tokio::sync::RwLock::new(device)),
        device_snapshot: std::sync::RwLock::new(snapshot),
        backend,
        dirty: Arc::new(AtomicBool::new(false)),
        save_notify: Arc::new(Notify::new()),
    })
}
```

Location: `src/store/persistence_manager.rs:23-55`

## DeviceCommand Pattern

The `DeviceCommand` enum defines all possible state mutations:

```rust theme={null}
pub enum DeviceCommand {
    SetId(Option<Jid>),
    SetLid(Option<Jid>),
    SetPushName(String),
    SetAccount(Option<wa::ADVSignedDeviceIdentity>),
    SetAppVersion((u32, u32, u32)),
    SetDeviceProps(
        Option<String>,
        Option<wa::device_props::AppVersion>,
        Option<wa::device_props::PlatformType>,
    ),
    SetClientProfile(ClientProfile),
    SetPropsHash(Option<String>),
    SetNextPreKeyId(u32),
    SetLidMigrated(bool),
    /// Install a freshly rotated signed pre-key (WA Web `RotateKeyJob`). Sets
    /// the key trio and stamps the rotation cadence clock in one command so
    /// they can never be observed split.
    SetSignedPreKey {
        key_pair: KeyPair,
        id: u32,
        signature: [u8; 64],
        rotation_ms: i64,
    },
    /// Seed the rotation cadence baseline without touching the key. Used once
    /// for devices upgraded in with `last_signed_pre_key_rotation_ms == 0`, so
    /// the first rotation lands a full interval out instead of firing
    /// immediately on the next connect.
    SetSignedPreKeyRotationBaseline(i64),
}
```

Location: `wacore/src/store/commands.rs`

<Note>
  `DeviceCommand` cannot derive `Debug` once a variant holds a `KeyPair` — `KeyPair` deliberately omits `Debug` so private key material never formats into logs. The enum has a hand-written `Debug` impl instead; `SetSignedPreKey`'s logs only `id` and `rotation_ms`, redacting the key pair and signature via `finish_non_exhaustive()`.
</Note>

### Why Commands?

The command pattern provides:

1. **Type safety**: All state changes are explicitly defined
2. **Auditability**: Easy to log/trace state mutations
3. **Testability**: Commands can be tested in isolation
4. **Consistency**: Single code path for all modifications
5. **Future compatibility**: Easy to add undo/redo or migration logic

### Applying Commands

Commands are applied via pattern matching:

```rust theme={null}
pub fn apply_command_to_device(device: &mut Device, command: DeviceCommand) {
    match command {
        DeviceCommand::SetId(id) => {
            device.pn = id;
        }
        DeviceCommand::SetLid(lid) => {
            device.lid = lid;
        }
        DeviceCommand::SetPushName(name) => {
            device.push_name = name;
        }
        DeviceCommand::SetAccount(account) => {
            device.account = account.map(std::sync::Arc::new);
        }
        DeviceCommand::SetAppVersion((p, s, t)) => {
            device.app_version_primary = p;
            device.app_version_secondary = s;
            device.app_version_tertiary = t;
        }
        DeviceCommand::SetLidMigrated(migrated) => {
            device.lid_migrated = migrated;
        }
        DeviceCommand::SetSignedPreKey { key_pair, id, signature, rotation_ms } => {
            device.signed_pre_key = key_pair;
            device.signed_pre_key_id = id;
            device.signed_pre_key_signature = signature;
            device.last_signed_pre_key_rotation_ms = rotation_ms;
        }
        DeviceCommand::SetSignedPreKeyRotationBaseline(rotation_ms) => {
            device.last_signed_pre_key_rotation_ms = rotation_ms;
        }
        // ... handle all variants
    }
}
```

<Note>
  `SetLidMigrated` gates outbound DM wire addressing (LID vs. PN) on whether the account is 1:1-LID-migrated. Runtime paths only ever set `true` — `false` is reserved for pair-success, where a fresh pairing of a *different* account must not inherit the previous account's migration state. See [Authentication — one-to-one LID migration state](/concepts/authentication#one-to-one-lid-migration-state).
</Note>

Location: `wacore/src/store/commands.rs`

## Usage Patterns

### Reading device state

```rust theme={null}
// sync — no .await needed
let snapshot = persistence_manager.get_device_snapshot();
println!("Device JID: {:?}", snapshot.pn);
println!("Push name: {}", snapshot.push_name);
```

<Note>
  `get_device_snapshot()` is a plain `fn` (not `async`) and returns `Arc<Device>` — a refcount bump, no `Device` clone. Borrow fields directly from the held `Arc`; clone individual fields only when ownership escapes the scope. Don't hold the snapshot across an await point — doing so pins an old allocation while the live state may have advanced.
</Note>

### Modifying device state (simple)

For simple state changes, use commands:

```rust theme={null}
use wacore::store::commands::DeviceCommand;

// Update push name
persistence_manager.process_command(
    DeviceCommand::SetPushName("My New Name".to_string())
).await;

// Update props hash
persistence_manager.process_command(
    DeviceCommand::SetPropsHash(Some("new_hash".to_string()))
).await;
```

### Modifying device state (complex)

For complex logic involving multiple fields or conditionals:

```rust theme={null}
persistence_manager.modify_device(|device| {
    // Complex mutation logic
    device.push_name = "Updated Name".to_string();
    device.edge_routing_info = Some(new_routing_data);
}).await;
```

<Warning>
  Keep the closure passed to `modify_device` as short as possible. It holds a write lock on the device state, blocking all other modifications.
</Warning>

## Concurrency Patterns

### RwLock Semantics

Device state uses two separate locks with different roles:

* **`device_snapshot` (`std::sync::RwLock<Arc<Device>>`)**: read by `get_device_snapshot()`. Readers never contend with writers — they take a brief `std::sync` read lock to clone the `Arc`, then release immediately. The snapshot is updated inside the `tokio::sync::RwLock` write guard in `modify_device`, so readers always see fully committed state.
* **`device` (`tokio::sync::RwLock<Device>`)**: write-locked inside `modify_device()`. Only store-adapter code that needs `&mut Device` trait access (`get_device_arc()`) takes a read lock here directly.

In practice: `get_device_snapshot()` is contention-free for readers, and the tokio write lock is only held during actual mutations (rare).

### Session locks and message queues

The `Client` uses two cache-based lock mechanisms for per-chat and per-device serialization:

```rust theme={null}
pub struct Client {
    /// Per-device session locks for Signal protocol operations.
    /// Prevents race conditions when multiple messages from the same sender
    /// are processed concurrently across different chats.
    /// Keys are Signal protocol address strings (e.g., "user@s.whatsapp.net:0")
    pub(crate) session_locks: Cache<String, Arc<async_lock::Mutex<()>>>,

    /// Per-chat lane combining enqueue lock + message queue into a single cached entry.
    /// One cache lookup instead of two per incoming message.
    pub(crate) chat_lanes: Cache<Jid, ChatLane>,
    // ...
}
```

Both use `PortableCache` with capacity-based eviction (configurable via `CacheConfig`), so stale entries are automatically cleaned up.

On disconnect, `chat_lanes` is cleared via the async `clear()` to drop per-chat queue senders. This causes worker tasks from the old connection to exit via channel close, preventing them from surviving reconnects with outdated Signal session state that would cause decryption failures. See [disconnect cleanup](/concepts/architecture#disconnect-cleanup) for the full list of resources reset on disconnect.

Location: `src/client.rs`

### Blocking Operations

CPU-heavy or blocking operations must use `spawn_blocking` to avoid stalling the async runtime:

```rust theme={null}
use tokio::task::spawn_blocking;

// Bad: Blocks async runtime
let encrypted = expensive_crypto_operation(&data);

// Good: Offloads to thread pool
let encrypted = spawn_blocking(move || {
    expensive_crypto_operation(&data)
}).await?;
```

For more details on async patterns, see the [Architecture](/concepts/architecture) guide.

## Storage Backend

### Backend Trait

The `Backend` trait is a combination of four domain-specific traits:

```rust theme={null}
pub trait Backend: SignalStore + AppSyncStore + ProtocolStore + DeviceStore + Send + Sync {}

impl<T> Backend for T 
where 
    T: SignalStore + AppSyncStore + ProtocolStore + DeviceStore + Send + Sync
{}
```

See [Storage Traits](/api/store) for the full trait definitions.

Location: `wacore/src/store/traits.rs`

### SQLite Implementation

The default storage backend uses SQLite with the Diesel ORM. See [Storage Traits](/api/store#sqlitestore-implementation) for details on `SqliteStore`, including connection pooling, WAL mode, and multi-device support.

Location: `storages/sqlite-storage/`

### Serialization

The `Device` struct derives `Serialize` and `Deserialize` (from serde) for persistence. The `PersistenceManager` handles serializing device state to the backend via the `DeviceStore` trait's `save()` and `load()` methods.

## Debugging State

### Database Snapshots

The `debug-snapshots` feature enables database snapshots for debugging:

```rust theme={null}
// In error handler
if let Err(e) = decrypt_message(...) {
    persistence_manager.create_snapshot(
        "decrypt_error",
        Some(error_details.as_bytes())
    ).await?;
    
    return Err(e);
}
```

This creates a timestamped copy of the database:

```
chats.db
chats_snapshot_decrypt_error_20260228_143022.db
chats_snapshot_decrypt_error_20260228_143022.txt  (metadata)
```

Location: `src/store/persistence_manager.rs:99-121`

### Logging

State changes are logged at debug level:

```rust theme={null}
RUST_LOG=whatsapp_rust::store=debug cargo run
```

Output:

```
[DEBUG] PersistenceManager: Ensuring device row exists.
[DEBUG] PersistenceManager: Loaded existing device data (PushName: 'Alice')
[DEBUG] Device state is dirty, saving to disk.
[DEBUG] Device state saved successfully.
```

## Best Practices

### 1. Always use commands for state changes

```rust theme={null}
// Bad: Direct modification
persistence_manager.modify_device(|device| {
    device.push_name = "New Name".to_string();
}).await;

// Good: Use command
persistence_manager.process_command(
    DeviceCommand::SetPushName("New Name".to_string())
).await;
```

### 2. Minimize lock duration

```rust theme={null}
// Bad: Long lock duration
persistence_manager.modify_device(|device| {
    let data = expensive_calculation(&device.pn);  // Blocks all access!
    device.push_name = data;
}).await;

// Good: Release lock during expensive operation
let snapshot = persistence_manager.get_device_snapshot();
let data = expensive_calculation(&snapshot.pn);
persistence_manager.process_command(
    DeviceCommand::SetPushName(data)
).await;
```

### 3. Use chat locks for chat-specific operations

```rust theme={null}
// Per-chat locks serialize operations on the same chat
// The Client uses session_locks and chat_lanes internally
// to prevent race conditions during message processing.
```

### 4. Offload heavy operations

```rust theme={null}
use tokio::task::spawn_blocking;

// Crypto operations should use spawn_blocking
let ciphertext = spawn_blocking(move || {
    encrypt_message(&plaintext, &key)
}).await??;
```

## Cache patching strategy

Beyond device state, whatsapp-rust maintains several in-memory caches (device registry, group metadata, LID-PN mappings) that require real-time updates when server notifications arrive.

### Granular patching vs. invalidation

The client uses **granular cache patching** rather than the simpler invalidate-and-refetch approach. When a notification indicates a change (for example, a new device added or a group participant removed), the client reads the cached value, applies the diff in memory, and writes the updated value back — all without making any network requests.

This avoids an extra IQ round-trip per update. If no cache entry exists when the notification arrives, the patch is silently skipped, and the next read fetches authoritative state from the server.

```rust theme={null}
// Internal patching pattern (not a public API)
if let Some(mut cached) = cache.get(&key).await {
    cached.apply_change(notification_data);
    cache.insert(key, cached).await;
}
```

### Concurrency model

The `get` → mutate → `insert` sequence is **not atomic**. A concurrent notification for the same key could race and cause one update to be lost. This is acceptable because:

1. The cache is best-effort — a full server fetch on the next read corrects any drift
2. Races are rare in practice (device and group notifications for the same user rarely overlap)
3. Device registry patches persist to the backend store immediately, so even if the cache entry is evicted, the persistent state is correct

### Where patching is used

| Cache           | Patched on                                                                                                                     | Persisted           | Fallback / conflict behavior                                                                           |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------ |
| Device registry | Device add/remove/update notifications                                                                                         | Yes (backend store) | Hash-only notifications trigger full invalidation                                                      |
| Group metadata  | Participant add/remove notifications and API calls                                                                             | No (cache-only)     | `leave()` and cache eviction trigger server re-fetch                                                   |
| LID-PN mappings | Directed sources overwrite; observational bulk sources only seed new LIDs (see [`LearningSource`](/api/client#learningsource)) | Yes (backend store) | Conflict: source-aware write policy gates overwrites; observational mismatches queue a live re-resolve |

For full details including the write-policy gate, the live-query reconcile path, and data flow, see [Granular cache patching](/concepts/storage#granular-cache-patching).

## Related Components

* [Signal Protocol](/advanced/signal-protocol) - Cryptographic state stored in Device
* [Binary Protocol](/advanced/binary-protocol) - Protocol messages modify device state
* [WebSocket Handling](/advanced/websocket-handling) - Connection state in Device
* [Storage](/concepts/storage) - Cache patching and pluggable cache stores

## References

* Implementation: `src/store/persistence_manager.rs`
* Commands: `wacore/src/store/commands.rs`
* Device structure: `wacore/src/store/device.rs`
* Backend trait: `wacore/src/store/traits.rs`
* SQLite backend: `storages/sqlite-storage/src/sqlite_store.rs`
