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
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
12 changes: 12 additions & 0 deletions advanced/signal-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,18 @@ 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. The ratchet installs fresh key material from a new random ephemeral at counter zero and drops the retired chain instead of archiving it. The counter lease described above is a **record-level** ceiling, but the chain it bounds is **per-ratchet-epoch**. Without a matching lease rebase, the ceiling keeps describing a chain that no longer exists.

For ping-pong traffic, that gap is one batch and you'd never notice it. It's different for a peer you only ever monologue at — say, your own other device, which gets a copy of every message you send but rarely replies. The chain climbs past `MAX_RESERVATION_FAST_FORWARD` before one reply triggers the ratchet, and the ceiling ends up stranded thousands of counters above a chain that just restarted at zero.

A live reload never surfaces this, since a trusted-incarnation reload (above) skips the fast-forward entirely. The gap only shows up on **recovery** — a restart, or any lossy cache reset. There, the reload has to fast-forward across a span no send ever created. It refuses past `MAX_RESERVATION_FAST_FORWARD` and fails the whole record load. From that point the address is stranded: 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 gap. 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 you can never publish a counter under a ceiling that isn't yet durable. The lowering happens atomically with the chain swap, so no snapshot can pair the retired chain with a ceiling rebased for it, or vice versa. Rebasing to one batch instead of 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's *archived* rather than discarded keeps its claim on the lease instead: `promote_fresh_state` burns the outgoing state to the ceiling before resetting it.

If a record already has a stranded ceiling — written by a build that predates this fix — it recovers on its own the next time you use that address; you don't need to delete the row by hand. 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: 3 additions & 1 deletion api/signal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -614,8 +614,10 @@ if !client.signal().has_sender_key(&group_jid, &my_jid).await? {

### Reset a broken session

Use this for a session that decodes fine but is logically wrong — for example, after a known identity compromise. A session row that fails to decode from storage doesn't need this: the next send or decrypt for that address recovers it automatically, by fetching a fresh pre-key bundle and replacing the row. See [undecodable session rows](/concepts/storage#signalstorecache) for the mechanics.

```rust
// Delete the corrupted session
// Delete a session you know is logically wrong (e.g. after an identity compromise)
client.signal().delete_sessions(&[jid.clone()]).await?;

// Re-establish
Expand Down
8 changes: 8 additions & 0 deletions concepts/storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,14 @@ 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. A session row that fails to decode once — from genuine corruption, or from 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 that row as **absent** instead of propagating a decode error. Loading the row doesn't repair it by itself — it only lets the caller treat the address as session-less.
- That matters because the paths that would otherwise repair the session — decrypting the peer's next pre-key message, the retry-receipt handler — have to load the record first. If the decode error propagated instead, it would strand the address until you deleted the row by hand.
- Reporting the row absent lets the ordinary no-session recovery run instead: the next send or decrypt for that address fetches a fresh pre-key bundle and persists a replacement session, overwriting the unreadable row. This build never derives key material from bytes it can't decode, so it loses nothing usable — but the overwrite is destructive to the original bytes. If you're rolling back to a build that could still decode that row, back up the database first; the original bytes don't survive the overwrite.

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 Distinguish inbound retry recovery from bundle fetching

When the next use is an inbound ciphertext, decryption does not fetch a fresh peer pre-key bundle: an ordinary message without a usable session triggers a retry, and only a later pre-key message can establish the replacement session. The revised wording correctly says that loading alone does not repair the row, but this sentence still promises that the next decrypt both fetches a bundle and recovers automatically; distinguish the outbound send's server-side bundle fetch from the inbound retry/pre-key-message flow.

Useful? React with 👍 / 👎.

- `has_session()` decodes the row instead of only checking for its existence, so it never reports a quarantined row as present to a caller deciding whether to skip recovery.
- Each quarantine increments the `wa_session_record_quarantined_total` [counter](/advanced/metrics#counters). Watch for a non-zero rate — steady state is zero.

## AppSyncStore

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