Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions advanced/metrics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ All metrics are prefixed with `wa_` and emitted at the same boundaries as the ma
| `wa_appstate_mutations_total` | — | App-state mutations applied |
| `wa_identity_change_total` | — | Peer identity changes that triggered a session reset |
| `wa_prekey_upload_total` | `outcome` = `ok`, `fail` | Pre-key uploads by outcome |
| `wa_session_record_quarantined_total` | — | Stored session rows that failed to decode and were treated as absent for recovery. Steady state is zero — see [session row quarantine](/concepts/storage#signalstorecache) |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Describe the counter's rate rather than its value

Because this is a monotonic _total counter, its value remains non-zero after the first quarantined row and does not return to zero during a healthy steady state. Saying that steady state is zero can lead operators to alert on the cumulative value and keep an alert firing indefinitely; document that the expected steady-state increase or rate is zero and that a positive rate is the signal to investigate.

Useful? React with 👍 / 👎.


### Histograms (seconds)

Expand Down
10 changes: 10 additions & 0 deletions advanced/signal-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,16 @@ Retry-receipt recovery (`handle_retry_receipt` resending to a DM or group reques

Call [`Client::flush_pending_signal_state()`](/api/client#flush_pending_signal_state) to force a deterministic settle — e.g. before reading persisted Signal state directly, or ahead of a non-graceful shutdown. Never call it from inside an `InboundDurabilityHook` or a synchronous, inline `EventHandler::handle_event` implementation, since settling re-enters the processing permit those run under and would deadlock during an offline-sync drain. Ordinary `Bot` closure handlers are unaffected — both default delivery modes run the callback in a detached task off the permit.

### DH ratchet resets rebase the lease

A DH ratchet doesn't extend the current sender chain, it replaces it in place: fresh key material from a new random ephemeral, installed at counter zero, with the retired chain dropped rather than archived. The counter lease described above is a **record-level** ceiling, but the chain it bounds is **per-ratchet-epoch** — so a ratchet that isn't followed by a matching lease rebase leaves the ceiling describing a chain that no longer exists.

For ping-pong traffic that gap is one batch and goes unnoticed. It doesn't for a peer you only ever monologue at — for example, your own other device receiving a copy of every message you send while rarely replying: the chain climbs past `MAX_RESERVATION_FAST_FORWARD` before one reply triggers the ratchet, stranding the ceiling thousands of counters above a chain that just restarted at zero. A live reload never notices, since a trusted-incarnation reload (above) skips the fast-forward entirely — the gap only surfaces on **recovery**: a restart or any lossy cache reset. There, the reload has to fast-forward across a span no send ever created, refuses it past `MAX_RESERVATION_FAST_FORWARD`, and fails the whole record load. From that point the address is stranded, since every path that could repair the session — inbound decrypt, the group-send fan-out, the retry-receipt handler — has to load the unloadable record first.

`SessionRecord::rebase_lease_after_sender_chain_reset()` closes this: as part of the same mutation that swaps in the fresh chain, it lowers the ceiling to at most one `SENDER_CHAIN_RESERVATION_BATCH`. It only ever lowers, never raises, so a counter is never published under a ceiling that isn't yet durable — and it happens atomically with the chain swap, so no snapshot can pair the retired chain with a ceiling that was rebased for it, or vice versa. Rebasing to one batch rather than zero keeps the fresh chain's first counters lease-covered, so steady-state ping-pong keeps its write-behind send path instead of paying a synchronous flush on the very next send. A chain that is *archived* rather than discarded keeps its claim on the lease instead of being rebased: `promote_fresh_state` burns the outgoing state to the ceiling before resetting it.

A record already written with a stranded ceiling — by a build that predates this fix — self-heals on its own without an operator deleting the row: see [undecodable session rows](/concepts/storage#signalstorecache).

## Record components

`wacore-libsignal` exposes owned, validated projections of `SessionRecord` and `SenderKeyRecord` called **components**. Use them when you need to interchange or inspect session and sender-key record state without depending on the generated protobuf schema directly — for example in custom store implementations, migration tooling, or offline debugging. This API is purely additive: the protobuf-backed `serialize()`/`deserialize()` path is unchanged. A record does not round-trip through `into_components()` → `from_components()` → `serialize()` byte-for-byte — the conversion applies the validated, normalized export rules described below (counter-lease advancement, stale-chain removal, and bounded truncation), so treat it as a safe normalized re-encoding rather than a lossless copy.
Expand Down
4 changes: 4 additions & 0 deletions api/signal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,10 @@ client.signal().assert_sessions(&[jid.clone()]).await?;
let (enc_type, ciphertext) = client.signal().encrypt_message(&jid, plaintext).await?;
```

<Note>
A session row that fails to decode from storage no longer needs this manual reset — it self-heals automatically on its next load. See [undecodable session rows](/concepts/storage#signalstorecache). Manual reset is still the right tool for a session that decodes fine but is logically wrong, e.g. after a known identity compromise.
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
</Note>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### Manually migrate a session to LID addressing

```rust
Expand Down
2 changes: 2 additions & 0 deletions concepts/storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@ cache.clear().await;
- Only clears dirty tracking after ALL writes succeed
- On failure, dirty state is preserved for retry on next flush

**Undecodable session rows:** deserialization is a pure function of the stored bytes, so a session row that fails to decode once — genuine corruption, or a row written in a shape this build can no longer read, including a counter lease stranded by a since-fixed bug (see [DH ratchet resets rebase the lease](/advanced/signal-protocol#dh-ratchet-resets-rebase-the-lease)) — fails identically forever. `get_session`, `checkout_session`, and `has_session` all report such a row as **absent** rather than propagating a decode error. This matters because every path that could otherwise repair the session — decrypting the peer's next pre-key message, the retry-receipt handler — has to load the record first, so a propagated error would strand the address until an operator deleted the row by hand. Reporting it absent instead lets the ordinary no-session recovery fetch a fresh pre-key bundle and overwrite it; nothing is lost, since a record that can't be decoded can derive no key material either. `has_session()` decodes the row rather than only checking for its existence, so a quarantined row isn't reported present to the caller deciding whether to skip recovery. Each quarantine increments the `wa_session_record_quarantined_total` [counter](/advanced/metrics#counters); steady state is zero, and a non-zero rate is worth investigating rather than ignoring.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated

## AppSyncStore

**Purpose:** WhatsApp app state synchronization
Expand Down