Each message event (received, sent, status change) is forwarded immediately to sibling devices through the **same server infrastructure** as regular peer messages. Each device maintains its own complete local DB. Convergence is eventual; dedup via `ConversationStatus.Uuid`.
### Key Design Decisions
1.**Zero server changes required.** Device sync messages are addressed to the sibling device's lookup key and travel through the existing `msg:{lookup_key}` Redis sorted-set on the server, returned in `from_server.Chat` — identical to peer messages.
2.**Device peers reuse the `Peer` struct** with `Type = "device"`, stored in `Identity.OwnedDevices`. They have their own three keypairs (`MyIdentity`, `MyEncryptionKp`, `MyLookupKp`) and `MyPullServers`.
3.**A new proto message `DeviceSyncPayload`** is added to `messages.proto`. It is serialised and placed in `UserMessage.Appdata`; the parent `UserMessage.Type` is set to `"device_sync"`. This lets the client recognise sync messages without any server-side awareness.
4.**`GetRequestJobs()`** is extended to include device lookup keys alongside peer lookup keys for the appropriate servers, so the background poll thread picks up device sync messages without any extra call.
5.**Dedup** is handled by a small SQLite table `device_sync_seen` (one table per identity folder, not per peer) keyed on `DeviceSyncPayload.DedupId`.
**Files to touch:** `client/identity.go`, `client/helpers/` (new file `deviceHelper.go`)
**Goal:** Allow two app instances owned by the same user to establish a shared keypair relationship, mirroring the peer invitation flow but flagging the peer as `Type = "device"`.
- **Sym + DR inherited automatically**: because the implementation mirrors `InvitePeer`, the device peer will have `MySymKey`, `DrKpPublic`, `DrKpPrivate`, `DrRootKey`, and `DrInitiator = true` populated automatically. The resulting `ContactCard` will carry `dr_root_key` and `dr_public_key` so the answering device can initialise its own DR session via `AnswerDevicePairing`.
`OwnedDevices` is currently a `PeerList` (in-memory slice). This **must** be migrated to the same Badger-backed `PeerStorage` mechanism as `Peers` — it is no longer optional. Device peers carry a Double Ratchet session state (`DrStateJson`) that advances with every message sent or received. Without persistent storage the DR state is lost on restart, breaking the decryption of all subsequent messages. Add a `DeviceStorage PeerStorage` field to `Identity` with its own `DbFile`, and ensure `StorePeer` is called on the device peer after every outbound dispatch (in `DispatchSyncToDevices`) and after every inbound consume (in `ConsumeDeviceSyncMessage`), mirroring the pattern used in `messageHelper.go` and `bgPollHelper.go` for regular peers.
1. Serialise `DeviceSyncPayload{SyncType, PeerUid, DbMessage, DedupId}` with `proto.Marshal`.
2. Create a `UserMessage` with `Type = "device_sync"`, `Destination = devicePeer.ContactLookupKey`, `Appdata = serialisedPayload`.
3. Set `Status.Uuid = dedupId`.
#### 2.2 Dispatch sync to all sibling devices
```go
// DispatchSyncToDevices sends a DeviceSyncPayload to every device peer whose
// pull server list overlaps with the available servers.
// It enqueues a SendJob per device, reusing the existing bgSendHelper queue.
func DispatchSyncToDevices(
storagePath string,
syncType string,
peerUid string,
dbm *meowlib.DbMessage,
dedupId string,
) error
```
Iterates `identity.OwnedDevices`, builds and queues one `SendJob` per device (just like `CreateUserMessageAndSendJob` but using device peer keys and putting the message in `outbox/` with a recognisable prefix, e.g. `dev_{devPeerUid}_{dedupId}`).
After calling `peer.ProcessOutboundUserMessage` for each device peer, persist the updated DR state: `identity.DeviceStorage.StorePeer(devPeer)` if `devPeer.DrRootKey != ""`.
The message is packed into `ToServerMessage.Messages` (same field as regular chat). No server changes needed.
---
### Phase 3 — Integrate Dispatch into Send/Receive Paths
**Files to touch:** `client/helpers/messageHelper.go`, `client/helpers/bgPollHelper.go`
#### 3.1 After outbound message stored (`CreateAndStoreUserMessage`)
At the end of `CreateAndStoreUserMessage` (after `peer.StoreMessage`), add:
```go
// Async: do not block the caller
go DispatchSyncToDevices(storagePath, "msg", peerUid, dbm, usermessage.Status.Uuid)
```
The `dbm` is obtained from `UserMessageToDbMessage(true, usermessage, nil)` (files are excluded from sync — they stay on the originating device or are re-requested).
#### 3.2 After inbound message stored (`ConsumeInboxFile`)
After `peer.StoreMessage(usermsg, filenames)` succeeds:
1. Decrypt with `devPeer.ProcessInboundUserMessage(packed)` (takes the full `*PackedUserMessage` — **not**`payload, signature` separately; that API was updated when the sym-encryption + double-ratchet layer was added).
6.**Persist DR state** — after decryption, if `devPeer.DrRootKey != ""`, call `identity.OwnedDevices.StorePeer(devPeer)` (or the equivalent Badger-backed store) to persist the updated `DrStateJson`. This mirrors what `ConsumeInboxFile` does for regular peers.
### Phase 6 — Peer Metadata and Identity Profile Sync
**Files to touch:** `client/helpers/deviceHelper.go`, `client/helpers/deviceSyncHelper.go`, `client/identity.go`
The goal is to propagate non-message data across sibling devices: peer names/avatars/settings and the identity profile. This is **one-directional fan-out** (whichever device makes the change dispatches to all siblings) — no merge protocol is needed because conflicts are resolved by last-write-wins (the dedupId carries a timestamp or UUID sufficient for dedup; ordering is not guaranteed but is acceptable for profile data).
Dispatch a `"peer_update"` payload whenever a peer record is meaningfully mutated (name, avatar, notification settings, visibility, blocked state, etc.).
**Payload**: `DeviceSyncPayload.PeerData` is a JSON-encoded **full `Peer` struct**, including all private key material and DR state. This is safe because:
- The device sync channel is E2E-encrypted with the same X25519 + sym + DR stack as peer messages.
- The target server is user-owned; the operator is the user themselves.
- The recipient is the same person on a different device.
Fields included in `PeerData`:
- All keypairs in full: `MyIdentity`, `MyEncryptionKp`, `MyLookupKp` (private + public)
-`MySymKey` — shared symmetric key for that peer's channel
- **`DrStateJson`** — current live DR session state (see DR note below)
- All contact keys: `ContactPublicKey`, `ContactEncryption`, `ContactLookupKey`, `ContactPullServers`
- All metadata: `Name`, `Avatar`, `Avatars`, `MyName`, `Visible`, `Blocked`, `MessageNotification`, `SendDeliveryAck`, `SendProcessingAck`, `CallsAllowed`, server lists, etc.
Fields excluded from `PeerData`:
-`dbPassword` — transient in-memory field, never serialised; the receiving device uses its own memory password.
The receiving device upserts the peer into its local `Peers` store. After applying the sync, the sibling device is a full participant in the conversation: it can send and receive messages using the replicated keypairs, has the same DR session state, and monitors the same lookup key queues.
**DR state sync (Phase 6 only)**: Syncing `DrStateJson` as part of `"peer_update"` gives sibling devices a working DR session at the point of pairing and keeps them in sync during normal single-active-device use. Phase 7 supersedes this with independent per-device DR sessions, eliminating all shared-state concerns. If Phase 7 is implemented, the `DrStateJson` field in `PeerData` can be omitted from the sync payload (each device initialises its own fresh session via the device introduction flow).
**New peers**: When Device A completes an invitation with a new contact, it dispatches `"peer_update"` to all siblings with the full peer record. Device B immediately becomes a full participant — same keypairs, same lookup key, same DR session start state — and can transparently send and receive messages with that contact without any secondary invitation.
Dispatch an `"identity_update"` whenever `Identity.Nickname`, `Identity.DefaultAvatar`, `Identity.Avatars`, or `Identity.Status` changes.
**Payload**: `DeviceSyncPayload.IdentityData` is a JSON-encoded subset of `Identity`:
```go
type IdentityProfileSnapshot struct {
Nickname string `json:"nickname"`
DefaultAvatar string `json:"default_avatar"`
Avatars []Avatar `json:"avatars"`
Status string `json:"status"`
}
```
The receiving device deserialises this and updates only the listed fields on its local `Identity`, then calls `identity.Save()`.
**Explicitly NOT synced** in `IdentityData`:
-`RootKp` — the user's root signing keypair is the trust anchor; it should be established once per identity creation and never transmitted, even over a secure channel. Compromise of the root key invalidates the entire identity.
-`Device` — device-specific keypair for server auth; each device has its own.
-`OwnedDevices` — the device mesh itself; managed separately by the pairing flow.
-`HiddenPeers` — sensitive by design; out of scope.
-`DefaultDbPassword`, `DbPasswordStore` — local security preferences.
-`MessageServers` / `Peers` — covered by their own sync types (`"server_add"`, `"peer_update"`).
#### 6.3 Server list sync (future — `sync_type = "server_add"`)
When a new `MessageServer` is added to one device's `MessageServers`, dispatch `"server_add"` so all siblings discover it. Implementation deferred; placeholder `sync_type` reserved.
#### 6.4 Dispatch hooks
- After `Identity.InvitePeer` / `FinalizeInvitation` / any peer metadata update: call `DispatchSyncToDevices(..., "peer_update", peer.Uid, nil, uuid.New().String())`.
- After `Identity.Save()` when profile fields changed: call `DispatchSyncToDevices(..., "identity_update", "", nil, uuid.New().String())`.
---
### Phase 7 — Per-Device DR Sessions (Bullet-proof Forward Secrecy)
**Goal**: Eliminate the concurrent-send DR race without shared ratchet state and without leaking device count to contacts.
#### 7.0 Privacy constraint
The naive per-device DR approach (introduce all devices to all contacts) has a fundamental privacy problem: every contact learns how many devices you own and receives session material for each. This leaks metadata — device count, device rotation events, possibly device fingerprints. This is unacceptable for a privacy-first library.
Two architecturally sound options are described below. **Option B (primary device relay) is recommended** because it preserves complete contact-side opacity and requires no protocol extension on the contact side.
---
#### Option A — Contact-aware per-device sessions (not recommended)
Each device is introduced to all contacts via a `"device_introduce"` message. The contact maintains one independent DR session per device and sends a separate encrypted copy per device on every message.
| Backward compatibility | ❌ Old clients can't participate |
| Server changes | ✅ None |
This is Signal's model. It is appropriate when contacts are expected to be aware of device multiplicity (e.g. a closed ecosystem). It is **not** appropriate for meowlib's open, privacy-first design.
---
#### Option B — Primary device relay (recommended)
The device that owns the peer relationship (the one whose keypairs are in the `Peer` record — call it the **primary**) is the only device that ever communicates directly with a contact. Its DR session with the contact is singular, unshared, and advances normally.
Sibling devices that want to send a message do so by dispatching a `"forward"` device sync payload to the primary. The primary re-encrypts with the contact's keys and forwards. From the contact's perspective: one sender, one DR session, zero device awareness.
| Property | Value |
|---|---|
| DR race | ❌ Eliminated (only primary drives the DR session) |
| Contact privacy | ✅ Contact is completely unaware of sibling devices |
| Contact protocol change | ✅ None required |
| Backward compatibility | ✅ Full |
| Server changes | ✅ None |
| Trade-off | If primary is offline, sibling outbound messages queue until it returns |
##### 7.1 Primary device designation
The device that completes the invitation flow for a peer (calls `InvitePeer` or `FinalizeInvitation`) is the primary for that peer. The `Peer` record synced to sibling devices carries a `PrimaryDeviceUid string` field (the UID of the device peer that "owns" this peer relationship):
// empty = this device IS the primary for this peer
```
When a sibling device receives a `"peer_update"` sync, it sets `PrimaryDeviceUid` to the sender's device UID. When the primary device sends a peer update, it leaves `PrimaryDeviceUid` empty (it is the primary).
##### 7.2 New sync type: `"forward"`
Add to `DeviceSyncPayload.sync_type`:
```
"forward" — sibling device requests primary to send a message to a peer on its behalf
```
New fields needed in `DeviceSyncPayload`:
```protobuf
bytes forward_payload = 7; // serialized UserMessage (plaintext, will be encrypted by primary)
string forward_peer_uid = 8; // local peer UID on the primary device to forward to
```
##### 7.3 Send path on a sibling device
When a sibling device (one where `peer.PrimaryDeviceUid != ""`) sends a message to peer P:
1. Build the `UserMessage` normally.
2.**Do not** call `peer.ProcessOutboundUserMessage` — the sibling does not have a valid DR state for the contact.
3. Serialize the `UserMessage` (plaintext proto bytes).
4. Build a `DeviceSyncPayload{SyncType: "forward", ForwardPayload: serialized, ForwardPeerUid: peer.Uid}`.
5. Dispatch it to the primary device via the normal device sync send path.
6. Store the message locally with a `"pending_forward"` status so the UI reflects it immediately.
##### 7.4 Receive and forward path on the primary device
When `ConsumeDeviceSyncMessage` on the primary sees `sync_type == "forward"`:
1. Deserialize `ForwardPayload` into a `UserMessage`.
2. Locate the local peer by `ForwardPeerUid`.
3. Call `peer.ProcessOutboundUserMessage(userMessage)` — primary uses its DR session normally.
4. Enqueue a `SendJob` to deliver to the contact's server (same path as any outbound message).
5. Dispatch a `"msg"` sync back to all siblings with the now-stored `DbMessage` so they update the message status from `"pending_forward"` to sent.
##### 7.5 Offline queuing
If the primary device is offline when the sibling dispatches a `"forward"` sync, the sync message sits in the device sync queue on the server (same Redis sorted-set as all device messages). When the primary comes back online and polls, it picks up the forwarded message and delivers it. No message is lost; latency equals the primary's offline window.
##### 7.6 Result
- Zero contact protocol changes. Contacts cannot distinguish a primary-only device from a multi-device user.
- No device count leakage. Device topology is fully opaque to the outside world.
- No DR race. The primary drives a single ratchet per contact.
- No server changes.
-`ProcessOutboundUserMessage` signature stays `(*PackedUserMessage, error)` — no ripple through callers.
- Trade-off is well-bounded: forward latency ≤ primary polling interval, which is already the existing long-poll timeout.
| Peer full keypairs (private + public) | ✅ | Phase 6 — included in `"peer_update"``PeerData`; channel is E2E-encrypted on user-owned server |
| Peer symmetric key | ✅ | Phase 6 — included in `"peer_update"``PeerData` |
| Peer DR session state (`DrStateJson`) | ✅ | Phase 6 — synced on peer_update; Phase 7 (Option B) eliminates the need: primary drives one DR session, siblings never touch it |
- Device sync messages are end-to-end encrypted (same X25519 + sym + DR stack as peer messages).
- The server sees only the device lookup key as destination; it cannot distinguish sync messages from peer messages.
- Including device lookup keys in batch pull requests does not leak which device belongs to you (same privacy model as multiple peer lookup keys per request).
-`OwnedDevices` peers should be treated as "hidden" (not shown in contact lists) and stored in the device storage, separate from regular peers.
- **Contacts are never made aware of device count or device identity** (Phase 7 Option B). The primary device relay model means the outside world observes exactly one sender per user identity, regardless of how many devices are active.
- The device mesh topology (which devices exist, how many) is known only to the user's own devices, and is carried exclusively over the E2E-encrypted device sync channel on the user-owned server.