diff --git a/api/store.mdx b/api/store.mdx
index 5422c917..7da818f5 100644
--- a/api/store.mdx
+++ b/api/store.mdx
@@ -292,16 +292,27 @@ async fn touch_tc_token_sender_timestamp(&self, jid: &str, sender_timestamp: i64
/// `sender_timestamp`. The symmetric counterpart of
/// `touch_tc_token_sender_timestamp` — each writer owns its own field, so the
/// notification path never drops a sender bucket the issuance path wrote
-/// concurrently. Same atomicity requirement as above.
+/// concurrently.
+///
+/// **Newer-wins**: the stored `(token, token_timestamp)` pair is overwritten
+/// only when the existing token is a byte-less placeholder, or the incoming
+/// `token_timestamp` is at least as new as the stored one — a stale write must
+/// never clobber a fresher real token. `SqliteStore` enforces this atomically
+/// inside an `IMMEDIATE` transaction; `InMemoryBackend` under its state lock.
+/// This is what lets concurrent history-sync chunks and the
+/// privacy-notification path converge on the same row without an external
+/// lock. The default read-modify-write below is a best-effort for
+/// third-party backends — same atomicity caveat as
+/// `touch_tc_token_sender_timestamp`.
async fn store_received_tc_token(&self, jid: &str, token: &[u8], token_timestamp: i64) -> Result<()> {
- // default: read-modify-write via get_tc_token + put_tc_token
+ // default: read-modify-write via get_tc_token + put_tc_token, newer-wins
}
```
`delete_expired_tc_tokens` gained a second `sender_cutoff` parameter — **this is a breaking change** for any custom backend that overrides it; update the signature to `(&self, token_cutoff: i64, sender_cutoff: i64) -> Result` and prune on both windows independently (see the built-in `SqliteStore`/`InMemoryBackend` implementations for the two-filter pattern).
-`touch_tc_token_sender_timestamp` and `store_received_tc_token` are new defaulted methods — existing custom backends compile and work unchanged, but should override both with an atomic upsert if the backend supports one, since the default read-modify-write can race a concurrent writer touching the same row (post-send issuance vs. an incoming `privacy_token` notification).
+`touch_tc_token_sender_timestamp` and `store_received_tc_token` are defaulted methods — existing custom backends compile and work unchanged, but should override both with an atomic upsert if the backend supports one, since the default read-modify-write can race a concurrent writer touching the same row (post-send issuance vs. an incoming `privacy_token` notification). For `store_received_tc_token` specifically, a non-atomic override that races two callers can let an older token's write land last and clobber a fresher one — the built-in backends close this by making the newer-wins check part of the same read+write.
### Sent message store
diff --git a/api/tctoken.mdx b/api/tctoken.mdx
index 2206735e..71feca71 100644
--- a/api/tctoken.mdx
+++ b/api/tctoken.mdx
@@ -284,12 +284,14 @@ When a contact sends you a privacy token, the library handles it automatically:
1. Parses the `` stanza
2. Resolves the sender to a LID for storage (using `sender_lid` attribute or LID-PN cache)
-3. Applies a timestamp monotonicity guard — an older token doesn't overwrite a newer *real* token
+3. Applies a timestamp monotonicity pre-filter — if the incoming token is older than a stored *real* token, storage and the presence re-subscribe are both skipped
4. Stores the token in the backend, preserving any `sender_timestamp` already recorded by the post-send issuance path
5. Re-subscribes presence for the sender to pick up the updated token
+The same newer-wins rule (older writes rejected, a byte-less placeholder always accepts the first real token) is also enforced **atomically inside the store itself** — see [`store_received_tc_token`](/api/store#tctoken-storage). This is what closes the cross-source race between this notification path and history-sync's tc-token candidates: both call the same store method, so whichever call lands last can never clobber a fresher token, without needing a lock shared across the two paths.
+
-A byte-less placeholder (written by post-send issuance before any real token has been received) is always replaced by the contact's first real token, even if the placeholder's own timestamp is newer — the monotonicity guard only applies once a real token is on record.
+A byte-less placeholder (written by post-send issuance before any real token has been received) is always replaced by the contact's first real token, even if the placeholder's own timestamp is newer — the newer-wins rule only blocks a stale write once a real token is on record.
### Startup pruning
@@ -384,7 +386,7 @@ sequenceDiagram
Note over Client,Server: Incoming token notification
Server->>Client: notification type="privacy_token"
Client->>Client: Resolve sender LID
- Client->>Backend: store_received_tc_token (monotonicity guard, preserves sender_timestamp)
+ Client->>Backend: store_received_tc_token (atomic newer-wins, preserves sender_timestamp)
Client->>Server: Re-subscribe presence
Note over App,Server: Outgoing 1:1 call offer (voip feature, unconditional — no AB prop gate)
diff --git a/concepts/storage.mdx b/concepts/storage.mdx
index be0a1320..04557834 100644
--- a/concepts/storage.mdx
+++ b/concepts/storage.mdx
@@ -449,7 +449,9 @@ pub trait ProtocolStore: Send + Sync {
// byte-less placeholder if no entry exists yet. Defaulted (read-modify-write).
async fn touch_tc_token_sender_timestamp(&self, jid: &str, sender_timestamp: i64) -> Result<()> { /* default */ }
// Store a token received from a contact without clobbering an existing
- // sender_timestamp. Defaulted (read-modify-write).
+ // sender_timestamp. Newer-wins: a stale write (older token_timestamp,
+ // unless the existing token is a byte-less placeholder) is a no-op.
+ // Defaulted (read-modify-write); SqliteStore/InMemoryBackend enforce it atomically.
async fn store_received_tc_token(&self, jid: &str, token: &[u8], token_timestamp: i64) -> Result<()> { /* default */ }
// Sent Message Store (retry support, matches WA Web's getMessageTable)