From cc6414d2fd7eedbad4de8cb4e0ffa04c3f75052b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:56:03 -0300 Subject: [PATCH 1/7] docs: reflect sender-chain counter-lease batching (whatsapp-rust#1026) --- advanced/signal-protocol.mdx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/advanced/signal-protocol.mdx b/advanced/signal-protocol.mdx index 82d7a42..1285dbf 100644 --- a/advanced/signal-protocol.mdx +++ b/advanced/signal-protocol.mdx @@ -1137,11 +1137,16 @@ impl SessionStore for SessionAdapter { *When* the dirty Signal cache reaches the backend differs by direction, because the two directions have different recovery properties: -- **Send** (DM, group, and status sends) flushes **synchronously, before the stanza reaches the wire**, and propagates a persistence failure by aborting the send. Reusing an outbound counter reuses its message key and IV, so the ratchet advance must be durable before anyone can act on the ciphertext — the send must not transmit an advance it couldn't save. +- **Send (DM/1:1 sessions)** persists through a batched **counter lease**: `SessionRecord` reserves its outbound sender-chain counter `SENDER_CHAIN_RESERVATION_BATCH` (64) values at a time (`SessionRecord::reserve_sender_chain_counters`). A send covered by an unexhausted lease is already durable, so it just schedules the same coalesced write-behind as the receive path below. Only the send that exhausts the lease — roughly 1 in 64 — raises the ceiling and flushes **synchronously, before the stanza reaches the wire**, propagating a persistence failure by aborting the send. Either way, reusing an outbound counter reuses its message key and IV, so no counter can ever be used before its lease is durable. The lease field is local-only (field 100 in the encoded `SessionRecord`, outside the vendored `whatsapp.proto`), and `SessionRecord::deserialize` fast-forwards the sender chain to the lease ceiling on every load, so a crash or reconnect mid-lease can never re-derive a possibly-spent counter. +- **Send (group and status sends)** is unaffected by the lease and still flushes **synchronously, before the stanza reaches the wire**, on every send — sender-key leasing is a potential follow-up, not implemented yet. - **Receive** (live traffic, outside the offline-drain batcher) routes through a single-flight coalescing scheduler (`src/signal_flush.rs`) instead of flushing per stanza: a burst of receives folds into one flush per ~25ms window, retried with exponential backoff (up to a 5s cap) on backend failure. This is safe because a lost receive-side advance simply re-derives forward on the next message (the receiving chain derives `CK_n → CK_n+1`), and a consumed one-time prekey stays buffered until its session is durable — a crash inside the window is recoverable. The scheduler is generation-scoped (embeds the connection generation in its atomic state), so a reconnect during an in-flight flush needs no explicit reset: a stale worker from the previous connection cannot mutate the new generation's state, and stands down when it observes a foreign generation. + +Downgrading to a version that predates the counter lease after running a leased version: the older version ignores the lease field and could reuse counters that were only reserved (not yet actually sent) by the lease. Avoid downgrading a device's local state across this boundary. + + The offline drain, retry-receipt recovery, identity-change recovery, and teardown all keep their own **synchronous** flushes — they gate acks, receipts, or follow-up reads on durability and are not routed through the receive coalescer. See [Inbound Durability Hook](/advanced/inbound-durability) for the drain-batch commit ordering, which this coalescing does not change. 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. From 37998403d044740a4d83643da266dd369c314676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:58:20 -0300 Subject: [PATCH 2/7] docs: reflect sender-chain counter-lease batching (whatsapp-rust#1026) --- api/send.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/send.mdx b/api/send.mdx index 9a6fd67..64b0594 100644 --- a/api/send.mdx +++ b/api/send.mdx @@ -44,7 +44,7 @@ pub async fn send_message( -For DMs, group, and status sends, the outbound Signal ratchet advance is persisted to the backend **synchronously, before the stanza is transmitted** — reusing an outbound counter would reuse its message key and IV, so the advance must be durable before anyone can act on the ciphertext. If that persistence write fails, `send_message` returns `Err` instead of transmitting an advance that couldn't be saved. See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model. +For DMs, the outbound Signal ratchet advance is persisted through a batched counter lease: the sender-chain counter is reserved 64 at a time, so most sends are already covered by a durable lease and only schedule a coalesced write-behind, while the send that raises the lease (roughly 1 in 64) is persisted to the backend **synchronously, before the stanza is transmitted**. Group and status sends always persist their sender-key ratchet advance synchronously before the stanza is transmitted. Either way, reusing an outbound counter would reuse its message key and IV, so the advance is durable before it can be reused, and if a required persistence write fails, `send_message` returns `Err` instead of transmitting an advance that couldn't be saved. See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model. ### SendResult From 7679a3b701151ac9dde85d54505b889eb7332675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:02:34 -0300 Subject: [PATCH 3/7] docs: reflect sender-chain counter-lease batching (whatsapp-rust#1026) --- api/client.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/client.mdx b/api/client.mdx index 6ec71d0..f9df4ce 100644 --- a/api/client.mdx +++ b/api/client.mdx @@ -1463,7 +1463,7 @@ pub async fn flush_pending_signal_state(&self) -> Result<(), anyhow::Error> Forces any pending write-behind Signal cache state to the backend, returning once the flush completes (or fails). -Ordinarily — without calling this method — the backend trails the in-memory cache: outbound sends already flush synchronously, but the live receive path only schedules a coalesced flush every ~25ms window (see [flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive)). A successful call to `flush_pending_signal_state()` closes that gap deterministically: everything dirty as of the call is persisted by the time it returns `Ok`. The call itself has **no hard wall-clock bound**, though — it can wait on locks or on slow/failing storage (a backend outage extends it until the retry loop succeeds). Check the returned `Result`: a failure means the flush did not complete and state is still pending, not persisted. +Ordinarily — without calling this method — the backend trails the in-memory cache: DM sends are usually already covered by a durable sender-chain counter lease and only schedule the coalesced write-behind (flushing synchronously only on the roughly-1-in-64 send that raises the lease), group and status sends still flush synchronously on every send, and the live receive path always schedules a coalesced flush every ~25ms window (see [flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive)). A successful call to `flush_pending_signal_state()` closes that gap deterministically: everything dirty as of the call is persisted by the time it returns `Ok`. The call itself has **no hard wall-clock bound**, though — it can wait on locks or on slow/failing storage (a backend outage extends it until the retry loop succeeds). Check the returned `Result`: a failure means the flush did not complete and state is still pending, not persisted. Never call this from inside an [`InboundDurabilityHook`](/advanced/inbound-durability) — during an offline-sync drain it runs while the processing permit is held, and settling routes through that same permit, so re-entering it would deadlock. The same risk applies to a custom `EventHandler::handle_event` implementation that itself blocks synchronously inline (dispatch is synchronous). It does **not** apply to ordinary [`Bot`](/api/bot) closure handlers (`.on_message()`, etc.) — both the default concurrent and ordered delivery modes run your callback in a detached task that never holds the permit, so calling `flush_pending_signal_state()` from inside one of those is safe. From 67adef2cb25b66259826f757835d68ee5d387e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:08:49 -0300 Subject: [PATCH 4/7] docs: split dense durability sentences per CodeRabbit style feedback --- api/send.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/send.mdx b/api/send.mdx index 64b0594..954e168 100644 --- a/api/send.mdx +++ b/api/send.mdx @@ -44,7 +44,7 @@ pub async fn send_message( -For DMs, the outbound Signal ratchet advance is persisted through a batched counter lease: the sender-chain counter is reserved 64 at a time, so most sends are already covered by a durable lease and only schedule a coalesced write-behind, while the send that raises the lease (roughly 1 in 64) is persisted to the backend **synchronously, before the stanza is transmitted**. Group and status sends always persist their sender-key ratchet advance synchronously before the stanza is transmitted. Either way, reusing an outbound counter would reuse its message key and IV, so the advance is durable before it can be reused, and if a required persistence write fails, `send_message` returns `Err` instead of transmitting an advance that couldn't be saved. See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model. +For DMs, the outbound Signal ratchet advance is persisted through a batched counter lease. The sender-chain counter is reserved 64 at a time, so most sends are already covered by a durable lease and only schedule a coalesced write-behind. The send that raises the lease, roughly 1 in 64, persists to the backend **synchronously, before the stanza is transmitted**. Group and status sends always persist their sender-key ratchet advance synchronously, before the stanza is transmitted. Reusing an outbound counter would reuse its message key and IV, so the advance is always durable before it can be reused. If a required persistence write fails, `send_message` returns `Err` instead of transmitting an advance that couldn't be saved. See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model. ### SendResult From 9c10ac65ed5779a8f4245713709fd74e0d0400ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:15:04 -0300 Subject: [PATCH 5/7] docs: reorder downgrade warning next to lease bullets, style fixes --- advanced/signal-protocol.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/advanced/signal-protocol.mdx b/advanced/signal-protocol.mdx index 1285dbf..37b31ac 100644 --- a/advanced/signal-protocol.mdx +++ b/advanced/signal-protocol.mdx @@ -1137,16 +1137,16 @@ impl SessionStore for SessionAdapter { *When* the dirty Signal cache reaches the backend differs by direction, because the two directions have different recovery properties: -- **Send (DM/1:1 sessions)** persists through a batched **counter lease**: `SessionRecord` reserves its outbound sender-chain counter `SENDER_CHAIN_RESERVATION_BATCH` (64) values at a time (`SessionRecord::reserve_sender_chain_counters`). A send covered by an unexhausted lease is already durable, so it just schedules the same coalesced write-behind as the receive path below. Only the send that exhausts the lease — roughly 1 in 64 — raises the ceiling and flushes **synchronously, before the stanza reaches the wire**, propagating a persistence failure by aborting the send. Either way, reusing an outbound counter reuses its message key and IV, so no counter can ever be used before its lease is durable. The lease field is local-only (field 100 in the encoded `SessionRecord`, outside the vendored `whatsapp.proto`), and `SessionRecord::deserialize` fast-forwards the sender chain to the lease ceiling on every load, so a crash or reconnect mid-lease can never re-derive a possibly-spent counter. -- **Send (group and status sends)** is unaffected by the lease and still flushes **synchronously, before the stanza reaches the wire**, on every send — sender-key leasing is a potential follow-up, not implemented yet. +- **Send (DM/1:1 sessions)** persists through a batched **counter lease**. `SessionRecord` reserves its outbound sender-chain counter `SENDER_CHAIN_RESERVATION_BATCH` (64) values at a time, via `SessionRecord::reserve_sender_chain_counters`. A send covered by an unexhausted lease is already durable — it only schedules the same coalesced write-behind as the receive path below. The send that exhausts the lease, roughly 1 in 64, raises the ceiling and flushes **synchronously, before the stanza reaches the wire**. If that flush fails, the send aborts instead of transmitting an advance it couldn't save. Reusing an outbound counter reuses its message key and IV, so no counter can ever be used before its lease is durable. The lease field is local-only: it's field 100 in the encoded `SessionRecord`, outside the vendored `whatsapp.proto`. On every load, `SessionRecord::deserialize` fast-forwards the sender chain to the lease ceiling, so a crash or reconnect mid-lease can never re-derive a possibly-spent counter. +- **Send (group and status sends)** ignores the lease. It still flushes **synchronously, before the stanza reaches the wire**, on every send. Sender-key leasing is a potential follow-up, not implemented yet. - **Receive** (live traffic, outside the offline-drain batcher) routes through a single-flight coalescing scheduler (`src/signal_flush.rs`) instead of flushing per stanza: a burst of receives folds into one flush per ~25ms window, retried with exponential backoff (up to a 5s cap) on backend failure. This is safe because a lost receive-side advance simply re-derives forward on the next message (the receiving chain derives `CK_n → CK_n+1`), and a consumed one-time prekey stays buffered until its session is durable — a crash inside the window is recoverable. -The scheduler is generation-scoped (embeds the connection generation in its atomic state), so a reconnect during an in-flight flush needs no explicit reset: a stale worker from the previous connection cannot mutate the new generation's state, and stands down when it observes a foreign generation. - Downgrading to a version that predates the counter lease after running a leased version: the older version ignores the lease field and could reuse counters that were only reserved (not yet actually sent) by the lease. Avoid downgrading a device's local state across this boundary. +The scheduler is generation-scoped (embeds the connection generation in its atomic state), so a reconnect during an in-flight flush needs no explicit reset: a stale worker from the previous connection cannot mutate the new generation's state, and stands down when it observes a foreign generation. + The offline drain, retry-receipt recovery, identity-change recovery, and teardown all keep their own **synchronous** flushes — they gate acks, receipts, or follow-up reads on durability and are not routed through the receive coalescer. See [Inbound Durability Hook](/advanced/inbound-durability) for the drain-batch commit ordering, which this coalescing does not change. 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. From d908bd4b152590fce276919b02819884434885b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:23:53 -0300 Subject: [PATCH 6/7] docs: correct global pre-wire gate and status-reaction claims --- api/client.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/client.mdx b/api/client.mdx index f9df4ce..b239e97 100644 --- a/api/client.mdx +++ b/api/client.mdx @@ -1463,7 +1463,9 @@ pub async fn flush_pending_signal_state(&self) -> Result<(), anyhow::Error> Forces any pending write-behind Signal cache state to the backend, returning once the flush completes (or fails). -Ordinarily — without calling this method — the backend trails the in-memory cache: DM sends are usually already covered by a durable sender-chain counter lease and only schedule the coalesced write-behind (flushing synchronously only on the roughly-1-in-64 send that raises the lease), group and status sends still flush synchronously on every send, and the live receive path always schedules a coalesced flush every ~25ms window (see [flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive)). A successful call to `flush_pending_signal_state()` closes that gap deterministically: everything dirty as of the call is persisted by the time it returns `Ok`. The call itself has **no hard wall-clock bound**, though — it can wait on locks or on slow/failing storage (a backend outage extends it until the retry loop succeeds). Check the returned `Result`: a failure means the flush did not complete and state is still pending, not persisted. +Ordinarily — without calling this method — the backend trails the in-memory cache. Most DM sends are already covered by a durable sender-chain counter lease, so they only schedule the coalesced write-behind; only the roughly-1-in-64 send that exhausts the current lease flushes synchronously — and because the pre-wire flush check is global, a pending flush on an unrelated session can force a synchronous flush too. Group sends and status posts flush synchronously on every send; status reactions are the DM-branch exception and follow the lease behavior instead. The live receive path always schedules a coalesced flush, on a ~25ms window (see [flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive)). + +A successful call to `flush_pending_signal_state()` closes that gap deterministically: everything dirty as of the call is persisted by the time it returns `Ok`. The call has **no hard wall-clock bound** — it can wait on locks, or on slow or failing storage, and a backend outage extends it until the retry loop succeeds. Check the returned `Result`: a failure means the flush did not complete, and state is still pending, not persisted. Never call this from inside an [`InboundDurabilityHook`](/advanced/inbound-durability) — during an offline-sync drain it runs while the processing permit is held, and settling routes through that same permit, so re-entering it would deadlock. The same risk applies to a custom `EventHandler::handle_event` implementation that itself blocks synchronously inline (dispatch is synchronous). It does **not** apply to ordinary [`Bot`](/api/bot) closure handlers (`.on_message()`, etc.) — both the default concurrent and ordered delivery modes run your callback in a detached task that never holds the permit, so calling `flush_pending_signal_state()` from inside one of those is safe. From 722b6806b8dd963b4d0b9c9363adc45c57ab9d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:26:18 -0300 Subject: [PATCH 7/7] docs: correct global pre-wire gate and status-reaction claims --- api/send.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/send.mdx b/api/send.mdx index 954e168..4285fc4 100644 --- a/api/send.mdx +++ b/api/send.mdx @@ -44,7 +44,7 @@ pub async fn send_message( -For DMs, the outbound Signal ratchet advance is persisted through a batched counter lease. The sender-chain counter is reserved 64 at a time, so most sends are already covered by a durable lease and only schedule a coalesced write-behind. The send that raises the lease, roughly 1 in 64, persists to the backend **synchronously, before the stanza is transmitted**. Group and status sends always persist their sender-key ratchet advance synchronously, before the stanza is transmitted. Reusing an outbound counter would reuse its message key and IV, so the advance is always durable before it can be reused. If a required persistence write fails, `send_message` returns `Err` instead of transmitting an advance that couldn't be saved. See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model. +For DMs, the outbound Signal ratchet advance is persisted through a batched counter lease. The sender-chain counter is reserved 64 at a time, so most sends are already covered by a durable lease and only schedule a coalesced write-behind — though the pre-wire flush check is global, so a pending flush on an unrelated session can still force this send to flush synchronously. The send that exhausts the current lease, roughly 1 in 64, persists to the backend **synchronously, before the stanza is transmitted**. Group sends, and status posts sent via `client.status()`, always persist their sender-key ratchet advance synchronously, before the stanza is transmitted. Status *reactions* (`send_reaction` targeting `status@broadcast`) are the exception: they route through the same DM branch as an ordinary 1:1 message, addressed to the status author's device, so they follow the DM counter-lease behavior instead. Reusing an outbound counter would reuse its message key and IV, so the advance is always durable before it can be reused. If a required persistence write fails, `send_message` returns `Err` instead of transmitting an advance that couldn't be saved. See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model. ### SendResult