Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions advanced/state-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -593,13 +593,13 @@ The `get` → mutate → `insert` sequence is **not atomic**. A concurrent notif

### Where patching is used

| Cache | Patched on | Persisted | Fallback |
| 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 | Usync, peer messages, device notifications | Yes (backend store) | Timestamp conflict resolution prevents stale overwrites |
| 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 patching methods and data flow, see [Granular cache patching](/concepts/storage#granular-cache-patching).
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

Expand All @@ -614,4 +614,4 @@ For full details including patching methods and data flow, see [Granular cache p
- 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`
- SQLite backend: `storages/sqlite-storage/src/sqlite_store.rs`
38 changes: 23 additions & 15 deletions api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1029,21 +1029,29 @@ if let Some(entry) = client.get_lid_pn_entry(&lid_jid).await {

#### LearningSource

The `LearningSource` enum indicates how a LID-PN mapping was discovered:

| Variant | Description |
|---------|-------------|
| `Usync` | From a device sync query response |
| `PeerPnMessage` | From an incoming message with `sender_lid` attribute (sender is PN) |
| `PeerLidMessage` | From an incoming message with `sender_pn` attribute (sender is LID) |
| `RecipientLatestLid` | From looking up a recipient's latest LID |
| `MigrationSyncLatest` | From latest history sync migration |
| `MigrationSyncOld` | From old history sync records |
| `BlocklistActive` | From an active blocklist entry |
| `BlocklistInactive` | From an inactive blocklist entry |
| `Pairing` | From device pairing (own JID to LID) |
| `DeviceNotification` | From a device notification with `lid` attribute |
| `Other` | From an unknown source |
The `LearningSource` enum indicates how a LID-PN mapping was discovered. The source is not mere provenance — it also selects the **write policy** applied when the pair reaches the cache, mirroring WhatsApp Web's `createLidPnMappings` (`WAWebDBCreateLidPnMappings`) `switch (learningSource)`:

- **Directed sources** (`Usync`, `PeerPnMessage`, `PeerLidMessage`, `RecipientLatestLid`, `MigrationSyncLatest`, `MigrationSyncOld`, `BlocklistActive`, `BlocklistInactive`) overwrite the cache on any change from what's already stored.
- **Observational bulk sources** (`Other`, `Pairing`, `DeviceNotification`) only seed a LID that isn't cached yet. If the pair conflicts with an already-known LID for that phone, the observational pair is **not** applied — the client instead fires one background live LID query (`LidQuerySpec`) and learns the authoritative result under `Usync`, which can never itself trigger another reconcile.
- **Known-stale sources** (`MigrationSyncOld`, `BlocklistInactive`) are additionally stamped with `created_at = 0`, so a fresher mapping for the same phone always outranks them in the cache's most-recent-wins (PN→LID) resolution. This only guards the forward direction — the LID→PN reverse map always takes the latest write.

| Variant | Description | Write policy |
|---------|-------------|--------------|
| `Usync` | From a device sync query response | Directed — overwrites on conflict |
| `PeerPnMessage` | From an incoming message with `sender_lid` attribute (sender is PN) | Directed — overwrites on conflict |
| `PeerLidMessage` | From an incoming message with `sender_pn` attribute (sender is LID) | Directed — overwrites on conflict |
| `RecipientLatestLid` | From looking up a recipient's latest LID | Directed — overwrites on conflict |
| `MigrationSyncLatest` | From the live 1:1 LID migration flow | Directed — overwrites on conflict |
| `MigrationSyncOld` | From old history sync records | Directed — overwrites on conflict; `created_at = 0` (stale) |
| `BlocklistActive` | From an active blocklist entry | Directed — overwrites on conflict |
| `BlocklistInactive` | From an inactive blocklist entry | Directed — overwrites on conflict; `created_at = 0` (stale) |
| `Pairing` | From device pairing (own JID to LID) | Observational — seeds new LIDs only |
| `DeviceNotification` | From a device notification with `lid` attribute | Observational — seeds new LIDs only |
| `Other` | From an unknown/bulk source (e.g. the history-sync `phoneNumberToLidMappings` seed) | Observational — seeds new LIDs only |

<Note>
A pair that already matches the cache's current mapping always re-affirms durability regardless of source — it is never treated as a conflict. This includes an exact match, and also a reverse-only match: the LID-PN cache is capacity-bounded (see [`lid_pn_cache`](/api/bot#cache-configuration-reference)), so the PN→LID entry can be evicted while the LID→PN entry survives, and a re-learn of that surviving pair still counts as self-consistent.
</Note>

### persistence_manager

Expand Down
12 changes: 10 additions & 2 deletions concepts/storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1327,9 +1327,17 @@ Group patches are cache-only — they are not persisted to the backend. If the c

Only `leave()` uses full invalidation.

**LID-PN mappings** (`src/lid_pn_cache.rs`)
**LID-PN mappings** (`src/client/lid_pn.rs`, `src/lid_pn_cache.rs`)

The `LidPnCache` uses timestamp-based conflict resolution when adding new mappings. The PN → entry map only updates if the new entry's `created_at` is newer than or equal to the existing entry's, preventing older stale mappings from overwriting newer ones.
The `LidPnCache` uses timestamp-based conflict resolution when adding new mappings: the PN → entry map only updates if the new entry's `created_at` is newer than or equal to the existing entry's, preventing older stale mappings from overwriting newer ones.

On top of that, the write itself is gated by a **source-aware write policy** (`lid_pn_write_policy`, mirroring WhatsApp Web's `createLidPnMappings` `switch (learningSource)`): the incoming pair's [`LearningSource`](/api/client#learningsource) decides whether it's even allowed to overwrite what's cached, before the timestamp comparison ever applies.

- Directed sources (device-sync, peer messages, migration, blocklist) overwrite the cache on any change.
- Observational bulk sources (`Other`, `Pairing`, `DeviceNotification` — e.g. history-sync's `phoneNumberToLidMappings` seed, group participant lists) only ever seed a **new** LID. If the pair conflicts with a LID the cache already knows for that phone, the write is skipped and the phone number is queued for a background live LID query (`LidQuerySpec`) instead — the result of that query is learned under `Usync`, which is a directed source and cannot itself re-trigger a reconcile, so there's no query → learn → query loop.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
- `MigrationSyncOld` and `BlocklistInactive` are known-stale: they still write, but with `created_at = 0`, so a fresher mapping for the same phone keeps winning the PN→LID resolution.

This means a bulk/observational seed can no longer clobber a freshly, authoritatively learned LID for a peer — it self-heals through the same live-query path WA Web uses instead of last-write-wins.

### Patching vs. invalidation summary

Expand Down