> ## Documentation Index
> Fetch the complete documentation index at: https://whatsapp-rust.jlucaso.com/llms.txt
> Use this file to discover all available pages before exploring further.

# June 10, 2026 — Prekey upload window reuse (WA Web parity)

> The prekey upload path now tracks a first-unuploaded watermark alongside the generation counter, matching WA Web's two-watermark model. Batches re-offer leftover generated-but-unuploaded keys before minting new ones, and retry-receipt keys are reused from the window head. Custom backend implementations must add the new mark_prekeys_uploaded method.

## What changed

**Background:** Previously, every prekey upload generated a full fresh batch of 812 keys regardless of how many were already stored but never uploaded (e.g. after a failed IQ). The old `next_pre_key_id` counter served double duty as both the generation watermark and the upload watermark, and a failed upload left dead rows in the store that the next attempt silently skipped.

WA Web uses two separate meta-keys — `NEXT_PK_ID` (advances at generation time) and `FIRST_UNUPLOAD_PK_ID` (advances past the upload window) — so that `getOrGenPreKeys(812)` is a *target-total* operation: it re-offers the `[FIRST, NEXT)` leftover window and only generates `812 - (NEXT - FIRST)` new keys.

This release brings the Rust client into full parity with that model.

## Behavior changes

**Prekey upload window reuse**

Upload batches now re-offer leftover generated-but-unuploaded keys first, generating only as many new keys as needed to reach the configured count:

* **FIRST is advanced past the window *before* the IQ is sent** (matching WA Web's `markKeyAsUploaded` ordering in `PreKeysJob.js`). This means that after a mid-flight IQ failure the window is intentionally empty — the server state is unknown and re-offering an id a peer may already have consumed would corrupt the server pool. The next upload attempt mints strictly fresh ids. The abandoned rows stay stored locally and remain decryptable if the upload did land. What the window *reuse* protects is everything *before* the send: keys generated but never sent (process death or disconnect between the generation and IQ phases).
* A window with more leftovers than the target uploads only `wanted` of them and generates nothing, without regressing `NEXT_PK_ID`.
* A corrupt `first > next` pair and windows that would cross the 24-bit id boundary collapse to a fresh window automatically.

**Retry-receipt single prekey (`getOrGenSinglePreKey` parity)**

The single prekey allocated for retry receipts now mirrors WA Web's `getOrGenSinglePreKey = getOrGenPreKeys(1)`:

* When a leftover exists in the window, the window head is returned unchanged. The next batch upload re-offers the same stored key (true WA Web parity).
* A consumed window head (the peer spent the key) heals by skipping the dead slot to the next live key, instead of failing like WA Web does.
* A fully exhausted window generates a fresh key at `NEXT_PK_ID`.

**Upload marking is UPDATE-only**

After a successful IQ, keys are marked uploaded via an SQL `UPDATE` (not `UPSERT`). A key deleted between the upload snapshot and the mark (a one-time key consumed by an inbound pkmsg while the IQ was in flight) stays deleted and is never resurrected.

## Breaking changes

### New `SignalStore` method: `mark_prekeys_uploaded`

Custom backend implementations must add this method. It marks already-stored prekeys as uploaded using `UPDATE` semantics — consuming rows must not be resurrected by the mark:

```rust theme={null}
async fn mark_prekeys_uploaded(&self, ids: &[u32]) -> Result<()>;
```

The `InMemoryBackend` no-ops this (it doesn't track the uploaded flag). The `SqliteStore` implementation uses chunked `UPDATE` to stay under SQLite's host-parameter limit.

**If you implement a custom backend**, add:

```rust theme={null}
async fn mark_prekeys_uploaded(&self, ids: &[u32]) -> Result<()> {
    if ids.is_empty() {
        return Ok(());
    }
    // UPDATE prekeys SET uploaded = true WHERE id IN (...) AND device_id = ?
    // Use UPDATE, not UPSERT — consumed rows must stay deleted.
    todo!()
}
```

### `DeviceCommand::SetPreKeyWatermarks` replaces `SetNextPreKeyId`

`DeviceCommand::SetNextPreKeyId(u32)` has been removed. Use `SetPreKeyWatermarks` to update both watermarks atomically:

```rust theme={null}
// Before
DeviceCommand::SetNextPreKeyId(next_id)

// After
DeviceCommand::SetPreKeyWatermarks {
    next_pre_key_id: next_id,
    first_unupload_pre_key_id: first_id,
}
```

Both watermarks must always move together — the split-update model was exactly how the pre-watermark code lost track of generated keys.

### `Device` gains `first_unupload_pre_key_id`

A new `u32` field is added to `Device`:

```rust theme={null}
pub first_unupload_pre_key_id: u32, // serde(default) = 0 (unset/legacy)
```

The value `0` means "unset" (legacy device before this watermark existed). The upload path initialises it on the first upload from the legacy-safe starting point. Existing serialized blobs and SQLite rows load with `0` thanks to `#[serde(default)]` and `DEFAULT 0` on the new column — no migration step is required for existing deployments.

### SQLite migration

A new migration adds the column with a safe default:

```sql theme={null}
-- up
ALTER TABLE device ADD COLUMN first_unupload_pre_key_id INTEGER NOT NULL DEFAULT 0;

-- down
ALTER TABLE device DROP COLUMN first_unupload_pre_key_id;
```

The `SqliteStore` applies this automatically via Diesel migrations on startup.

### `allocate_next_one_time_prekey_id` removed

The crate-private `Client::allocate_next_one_time_prekey_id` method has been replaced by `get_or_gen_single_pre_key`. This was an internal surface and is only relevant if you had a fork that called it directly.
