Skip to content

fix!: decode key-index-list to filter stale devices from device registry - #469

Merged
jlucaso1 merged 13 commits into
mainfrom
fix/stale-device-registry-468
Mar 31, 2026
Merged

jlucaso1 merged 13 commits into
mainfrom
fix/stale-device-registry-468

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Mar 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #468.

Stale devices accumulated in the device registry because we never decoded the key-index-list signed protobuf bytes from device add notifications. This caused persistent 406 errors and ~2-4s delays on every group send.

Before: 4.08s per ping-pong (406 retry on every send)
After: 273ms first send (usync re-fetch), 2.69ms subsequent (cached)

Breaking Changes

  • ProtocolStore trait: added delete_devices(&self, user: &str) and clear_all_sender_key_devices(&self) — implementors must add these methods
  • DeviceListRecord: added raw_id: Option<u32> field — constructors must include it (uses #[serde(default)] so deserialization is backward-compatible)
  • DB migration: adds raw_id column to device_registry table (auto-applied by Diesel)

Changes

Phase 1: Graceful SKDM error handling

  • wacore/src/send.rs — SKDM distribution wrapped in match so failures don't kill the group send (matching WA Web GroupSkmsgJob try/catch)
  • wacore/src/send.rs — On batch 406, retry per-device to salvage valid companions. Explicit had_unregistered_device flag in EncryptResult (no heuristic inference)
  • wacore/src/send.rsstale_device_users computed by diffing distribution_list vs encrypted set
  • src/send.rs — Caller invalidates device registry (cache + DB) for stale users so next send re-fetches from server

Phase 2: Fix root cause (key-index-list filtering)

  • wacore/src/adv.rs — New module: decode_key_index_list(), filter_devices_by_key_index(), is_key_index_valid() matching WA Web AdvDeviceNotificationApi + AdvKeyIndexResultApi
  • src/client/device_registry.rspatch_device_add decodes key-index-list, filters stale devices by valid_indexes, validates new device key_index before adding, detects raw_id mismatch
  • src/usync.rs — Usync response applies same valid_indexes filtering, rejects companion devices without signedKeyIndexBytes, uses alias-aware load_device_record, returns filtered devices
  • wacore/src/usync.rs + wacore/src/iq/usync.rs — Parse <key-index-list> from <devices> response, companion guard in both parsers

Phase 3: Security hardening

  • wacore/src/store/traits.rsDeviceListRecord gains raw_id: Option<u32> field
  • DB migration adds raw_id column to device_registry table (table-rebuild down migration for SQLite < 3.35)
  • raw_id mismatch triggers clear_device_record: deletes Signal sessions for non-primary devices, clears ALL persisted sender key device tracking, invalidates in-memory sender key cache
  • src/handlers/notification.rs — Account sync preserves existing raw_id
  • src/usync.rs — Clears existing_key_indices after raw_id mismatch to avoid reusing old identity state

Phase 4: Cross-crate type safety

  • wacore/src/request.rs — New ServerErrorCode shared error type for typed cross-crate server error detection (scalable: future 409/503 checks use same pattern)
  • src/client/context_impl.rsSendContextResolver wraps server errors in ServerErrorCode
  • wacore/src/send.rsis_device_unregistered_error() uses ServerErrorCode::from_anyhow() for zero-cost typed downcast

WA Web compliance

  • None key_index → device removed (matching h.has(null)→false in AdvDeviceNotificationApi)
  • Failed SKDM devices NOT marked as "sent" — stay in distribution list for retry (matching GroupSkmsgJob)
  • Only actually encrypted devices tracked via markHasSenderKey (matching WA Web ParticipantStore)
  • Companion-without-signedKeyIndexBytes rejection in both usync parsers (matching AdvForUsyncApi)
  • Device remove notifications NOT changed — WA Web's handleDeviceRemoveNotification doesn't use valid_indexes

Persistence & caching

  • invalidate_device_cache now deletes from both moka cache AND SQLite DB (was the root cause of the infinite retry loop — DB fallback reloaded stale devices)
  • clear_device_record clears persisted sender_key_devices table (was only clearing in-memory cache — stale has_key=true rows survived restart)

Key files

File What changed
wacore/src/adv.rs New: ADV key-index decoding + device filtering (9 tests)
wacore/src/request.rs New: ServerErrorCode shared error type
wacore/src/send.rs EncryptResult struct, per-device 406 retry, SKDM try/catch, stale user detection
src/client/device_registry.rs patch_device_add rewrite, clear_device_record, invalidate_device_cache DB deletion
src/client/context_impl.rs Server error wrapping for cross-crate downcast
src/usync.rs key-index-list filtering, alias-aware lookup, filtered return
src/send.rs Device registry invalidation on 406
src/handlers/notification.rs Pass KeyIndexInfo to patch_device_add, preserve raw_id
wacore/src/store/traits.rs delete_devices, clear_all_sender_key_devices on ProtocolStore

Test plan

  • cargo fmt --all — clean
  • cargo clippy --all --tests — no warnings
  • cargo test --all --exclude e2e-tests — all 1099 tests pass
  • is_device_unregistered_error tests (4): 406 detected, non-406 rejected, unrelated rejected, bare wacore IqError rejected
  • is_key_index_valid tests (4): valid set, not-in-set, newer-than-current, None rejection
  • ADV filtering tests (5): primary kept, valid kept/invalid removed, newer-than-current kept, null key_index removed, decode roundtrip
  • Production test: first ping 273ms, second ping 2.69ms (was 4.08s/3.64s before)

Summary by CodeRabbit

  • New Features

    • Enhanced device synchronization with key index validation
    • Added detection and tracking of stale or unregistered devices in group messaging
  • Bug Fixes

    • Improved handling of device identity changes across multi-device scenarios
    • Better recovery when encountering unregistered devices during message encryption
  • Chores

    • Database schema updated to support improved device tracking

Closes #468.

Device add notifications include ADVSignedKeyIndexList protobuf in
key-index-list that encodes which device key indices are still valid.
We stored these bytes but never decoded them, so stale devices
accumulated in the registry causing persistent 406 errors on group
sends.

Changes:
- wacore/adv: shared utility to decode ADVKeyIndexList and filter
  devices by valid_indexes (matching WA Web AdvDeviceNotificationApi)
- patch_device_add: decode key-index-list, filter stale devices,
  detect raw_id mismatch for identity change (clearDeviceRecord)
- usync response: parse key-index-list, apply same filtering, reject
  companion devices without signedKeyIndexBytes (AdvForUsyncApi)
- encrypt_for_devices: catch 406 IQ errors (device unregistered)
  via typed downcast instead of propagating fatal error
- SKDM distribution: wrap in match so failures don't kill group send
  (matching WA Web GroupSkmsgJob try/catch pattern)
- DeviceListRecord: add raw_id field with DB migration
@coderabbitai

coderabbitai Bot commented Mar 31, 2026

Copy link
Copy Markdown

Important

Review skipped

This PR was authored by the user configured for CodeRabbit reviews. CodeRabbit does not review PRs authored by this user. It's recommended to use a dedicated user account to post CodeRabbit review feedback.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b23e5f9f-8e21-4e05-8024-964f3544506c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Implements ADV key-index decoding/filtering for device add notifications and usync responses, persists nullable raw_id in device records with DB migration, clears registry and sender-key state on identity changes, and makes SKDM/prekey handling resilient while adding cache invalidation helpers.

Changes

Cohort / File(s) Summary
Device Registry Core
src/client/device_registry.rs
Refactored patch_device_add to accept key_index_info: Option<&KeyIndexInfo>, decode signed key-index bytes, detect raw_id mismatches (calls clear_device_record), filter/prune devices via ADV logic, deduplicate via append_device_if_new, persist once; made load_device_record pub(crate) and added clear_device_record.
Notification Handling
src/handlers/notification.rs
Passes op.key_index.as_ref() into patch_device_add; account-sync preserves existing raw_id when constructing DeviceListRecord.
ADV Key-Index Utilities
wacore/src/adv.rs, wacore/src/lib.rs
New adv module providing DecodedKeyIndex, decode_key_index_list(), filter_devices_by_key_index(), is_key_index_valid(), and tests; exported via pub mod adv;.
USYNC Parsing & Use
wacore/src/iq/usync.rs, wacore/src/usync.rs
Parse <key-index-list> into UserDeviceList.key_index_bytes, reject companion-user responses missing it, decode/filter device lists, detect raw_id mismatches and clear records, and persist raw_id when present; tests updated.
Store / DB Migration
storages/sqlite-storage/migrations/2026-03-31-000000_add_device_registry_raw_id/*, storages/sqlite-storage/src/schema.rs, storages/sqlite-storage/src/sqlite_store.rs
Add nullable raw_id column migration (up/down), update Diesel schema, read/write raw_id in get_devices/update_device_list, map into DeviceListRecord.
Device List Records
wacore/src/store/traits.rs
DeviceListRecord gains pub raw_id: Option<u32> with serde attrs default and skip_serializing_if.
Sender Key Cache
src/sender_key_device_cache.rs
Added pub(crate) fn invalidate_all(&self) to clear all cached sender-key entries.
SKDM / Prekey Resilience
wacore/src/send.rs, src/send.rs
Treat 406 prekey-fetch as “unregistered” (skip device); make SKDM distribution tolerant of per-device failures; track stale_device_users/stale_users and invalidate device cache for stale users after sends.
Misc Tests & Fixtures
**/*tests*
Updated tests and fixtures to include new raw_id: None defaults and to cover ADV filtering and usync parsing changes.

Sequence Diagram(s)

sequenceDiagram
    participant Handler as Notification Handler
    participant Registry as Device Registry
    participant Decoder as Key-Index Decoder
    participant Filter as Device Filter
    participant Store as Storage
    participant Cache as Session & SenderKey Cache

    Handler->>Registry: patch_device_add(user, device, key_index_info)
    Registry->>Store: load_device_record(user)
    Store-->>Registry: existing_record?
    alt signed_bytes present
        Registry->>Decoder: decode_key_index_list(signed_bytes)
        Decoder-->>Registry: DecodedKeyIndex
        alt raw_id mismatch
            Registry->>Cache: clear_device_record(user) — delete sessions, invalidate sender-key cache
            Cache-->>Registry: cleared
            Registry->>Registry: record.devices = []
        end
        Registry->>Filter: filter_devices_by_key_index(existing_devices, decoded)
        Filter-->>Registry: filtered_devices
        Registry->>Registry: append_device_if_new(device, filtered_devices)
    else fallback (no signed_bytes or decode fails)
        Registry->>Registry: append_device_if_new(device, existing_devices)
    end
    Registry->>Store: update_device_list(user, updated_record with raw_id)
    Store-->>Registry: persist result
Loading
sequenceDiagram
    participant Usync as USYNC parser
    participant Validator as Companion Validator
    participant Decoder as Key-Index Decoder
    participant Filter as Device Filter
    participant Store as Storage

    Usync->>Usync: parse_get_user_devices_response_with_phash()
    Usync->>Validator: detect companion devices (device != 0)
    alt companion && key_index_bytes missing
        Validator-->>Usync: log warning, skip user
    else
        Usync->>Decoder: decode_key_index_list(key_index_bytes)
        alt decode succeeds
            Decoder-->>Usync: DecodedKeyIndex
            Usync->>Filter: filter_devices_by_key_index(devices, decoded)
            Filter-->>Usync: filtered_devices
        else decode fails
            Usync->>Usync: use unfiltered devices
        end
        Usync->>Store: update_device_list(user, DeviceListRecord with raw_id)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I nibble bytes and hop through code,

Raw IDs tracked down every road.
Stale devices cleared with a twitch and a paw,
Key-indexes pruned — no more flaw!
Hoppity hop — the registry hums with awe.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed All requirements from issue #468 are met: key-index-list decoding in notifications and usync, device filtering via valid_indexes/current_index, raw_id persistence and mismatch handling, SKDM error hardening, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly scoped to issue #468 requirements. Modifications to device registry, usync parsing, SKDM error handling, and sender-key cache invalidation align with the linked issue's objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: decoding key-index-list protobuf data to filter stale devices from the device registry, which is the core objective of this PR addressing issue #468.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4cfd34dbd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/src/send.rs
Comment on lines +422 to +426
log::warn!(
"Prekey fetch returned 406 (device unregistered) for {} devices, skipping all: {e}",
jids_needing_prekeys.len()
);
std::collections::HashMap::new()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Don’t drop all prekey bundles on a single 406 response

When fetch_prekeys_for_identity_check returns a top-level 406, this branch replaces the entire bundle map with an empty map, so every device in jids_needing_prekeys is skipped, not just the stale one. In prepare_dm_stanza, encrypt_for_devices is used for recipient encryption; if all recipient sessions are missing, this can produce no encrypted participant nodes and still continue building the stanza, causing messages to be sent without decryptable payloads for intended recipients instead of retrying with valid devices.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Mar 31, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchfix/stale-device-registry-468
Testbedubuntu-latest

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()Instructions
instructions x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
47.13 x 1e3
(+8.97%)Baseline: 43.25 x 1e3
45.41 x 1e3
(103.78%)

Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
6,197.00
(-4.81%)Baseline: 6,510.21
6,835.72
(90.66%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
524,304.00
(-26.55%)Baseline: 713,831.96
749,523.55
(69.95%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
20,868.00
(-5.52%)Baseline: 22,087.99
23,192.39
(89.98%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
98,202.00
(-15.71%)Baseline: 116,511.04
122,336.60
(80.27%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
98,230.00
(-9.82%)Baseline: 108,928.86
114,375.30
(85.88%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
532,948.00
(-0.10%)Baseline: 533,505.37
560,180.64
(95.14%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
15,870.00
(-4.57%)Baseline: 16,629.25
17,460.71
(90.89%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
14,715,201.00
(-7.75%)Baseline: 15,951,292.11
16,748,856.71
(87.86%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
118,358.00
(-20.05%)Baseline: 148,047.45
155,449.82
(76.14%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
534,378.00
(-0.10%)Baseline: 534,926.54
561,672.86
(95.14%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
17,919.00
(-4.08%)Baseline: 18,680.48
19,614.50
(91.36%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
28,066,347.00
(-21.06%)Baseline: 35,553,974.91
37,331,673.66
(75.18%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
533,387.00
(-0.10%)Baseline: 533,944.37
560,641.59
(95.14%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
15,843.00
(-7.19%)Baseline: 17,070.25
17,923.77
(88.39%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
14,716,627.00
(-7.75%)Baseline: 15,952,423.23
16,750,044.40
(87.86%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
107,945.00
(-12.44%)Baseline: 123,287.74
129,452.13
(83.39%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
98,302.00
(-9.82%)Baseline: 109,000.86
114,450.90
(85.89%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
90,974.00
(-5.23%)Baseline: 95,997.36
100,797.23
(90.25%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,378.00
(-3.37%)Baseline: 7,635.09
8,016.85
(92.03%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
91,005.00
(-1.73%)Baseline: 92,611.18
97,241.74
(93.59%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,401.00
(+0.35%)Baseline: 7,375.20
7,743.96
(95.57%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,790.00
(-1.48%)Baseline: 108,396.18
113,815.99
(93.83%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,913.00
(+0.29%)Baseline: 8,887.20
9,331.56
(95.51%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
41,989.00
(-7.64%)Baseline: 45,463.87
47,737.06
(87.96%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,717.00
(-3.02%)Baseline: 2,801.65
2,941.74
(92.36%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
556,092.00
(+1.89%)Baseline: 545,783.97
573,073.17
(97.04%)
binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
771.00
(-0.28%)Baseline: 773.13
811.79
(94.98%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,680,325.00
(-0.07%)Baseline: 27,698,388.14
29,083,307.55
(95.18%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,544,828.00
(-0.05%)Baseline: 5,547,824.49
5,825,215.72
(95.19%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
175,061.00
(-1.32%)Baseline: 177,406.42
186,276.74
(93.98%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
175,710.00
(-1.39%)Baseline: 178,181.77
187,090.86
(93.92%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,165,560.00
(-0.67%)Baseline: 17,280,803.44
18,144,843.61
(94.60%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
298,417.00
(+0.55%)Baseline: 296,784.41
311,623.63
(95.76%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,514,792.00
(-0.64%)Baseline: 12,595,395.49
13,225,165.26
(94.63%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
719,597.00
(+0.38%)Baseline: 716,888.58
752,733.01
(95.60%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
🚨 view alert (🔔)
47,129.00
(+8.97%)Baseline: 43,250.21
45,412.72
(103.78%)

libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction()📈 view plot
🚷 view threshold
15,561,842.00
(+0.00%)Baseline: 15,561,765.09
16,339,853.35
(95.24%)
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages()📈 view plot
🚷 view threshold
5,378,778.00
(-1.81%)Baseline: 5,478,127.74
5,752,034.12
(93.51%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
312,188.00
(-61.27%)Baseline: 805,994.01
846,293.71
(36.89%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,830,547.00
(+0.18%)Baseline: 2,825,516.97
2,966,792.82
(95.41%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,452,844.00
(-0.52%)Baseline: 3,471,020.22
3,644,571.23
(94.74%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
126,163,528.00
(+0.64%)Baseline: 125,355,358.70
131,623,126.64
(95.85%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
12,002.00
(+1.51%)Baseline: 11,823.44
12,414.61
(96.68%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,930.00
(+2.45%)Baseline: 3,835.96
4,027.75
(97.57%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
87,073.00
(-0.79%)Baseline: 87,762.88
92,151.02
(94.49%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
79,022.00
(-0.97%)Baseline: 79,797.64
83,787.52
(94.31%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
50,375.00
(-1.06%)Baseline: 50,914.41
53,460.13
(94.23%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,956.00
(+3.13%)Baseline: 5,775.11
6,063.87
(98.22%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,227.00
(+4.46%)Baseline: 2,131.87
2,238.47
(99.49%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(+0.00%)Baseline: 21,919.36
23,015.32
(95.24%)
🐰 View full continuous benchmarking report in Bencher

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/usync.rs (1)

140-146: ⚠️ Potential issue | 🟠 Major

Return the filtered devices on the cache-miss path.

The registry is updated with the filtered devices, but the value returned to the caller still comes from the raw response.device_lists. The first send after a cache miss will therefore target the stale companions this PR just removed from storage.

💡 Suggested direction
-            for user_list in &response.device_lists {
+            let mut fetched_devices = Vec::new();
+            for user_list in &response.device_lists {
                 // Update device registry (single source of truth for device lists).
                 // Preserve key_index values from existing records (set via account_sync)
                 let existing_record = self
@@
                 // Apply valid_indexes filtering if key-index-list was decoded
                 if let Some(ref decoded) = decoded_key_index {
                     devices = wacore::adv::filter_devices_by_key_index(&devices, decoded);
                 }
+
+                fetched_devices.extend(devices.iter().filter_map(|d| {
+                    u16::try_from(d.device_id).ok().map(|device_id| {
+                        let mut jid = user_list.user.clone();
+                        jid.device = device_id;
+                        jid
+                    })
+                }));
 
                 let device_list = wacore::store::traits::DeviceListRecord {
                     user: user_list.user.user.clone(),
@@
-            let fetched_devices: Vec<Jid> = response
-                .device_lists
-                .into_iter()
-                .flat_map(|u| u.devices)
-                .collect();
             all_devices.extend(fetched_devices);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/usync.rs` around lines 140 - 146, The code extends all_devices from the
raw response.device_lists (fetched_devices) instead of the filtered list that
was written to the registry, causing the first post-cache-miss send to include
removed companions; change the extension to use the filtered variable (the
`devices` you already computed and stored in the registry) — e.g., stop
collecting from `response.device_lists` into `fetched_devices` and instead
extend `all_devices` with the filtered `devices` (or replace `fetched_devices`
with a collection sourced from that filtered `devices`) so the returned list
matches what was saved to the registry.
src/handlers/notification.rs (1)

494-505: ⚠️ Potential issue | 🟠 Major

Preserve the existing raw_id when account_sync doesn't carry one.

update_device_list() upserts raw_id, so writing None here erases any value previously learned from usync/device notifications. After that, a real identity change on our own device list will no longer trip the raw_id-mismatch invalidation path. Carry the stored raw_id forward when account_sync has no replacement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/notification.rs` around lines 494 - 505, When constructing the
DeviceListRecord in the notification handler, do not always set raw_id to None;
instead preserve the existing stored raw_id when account_sync provides no
replacement so update_device_list() doesn't erase previously learned raw_id from
usync/device notifications. Locate the DeviceListRecord creation (symbol:
DeviceListRecord) around the notification path that uses from_jid and devices,
check for an incoming account_sync raw_id (or absence thereof) and set
DeviceListRecord.raw_id to the existing stored value when account_sync has no
raw_id; ensure update_device_list() receives that preserved raw_id so
raw_id-mismatch invalidation still triggers on real identity changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/device_registry.rs`:
- Around line 273-286: clear_device_record currently only invalidates the
in-memory cache via sender_key_device_cache.invalidate_all(), but must also
purge persisted sender-key tracking, stored sender keys, and Signal sessions for
the user so stale identity state cannot be resurrected; update
clear_device_record to, after invalidating the in-memory cache, call the
persistence-layer clear methods (e.g.
sender_key_tracking_store.remove_user(user), sender_key_store.remove_user(user),
and session_store.delete_sessions_for_user(user) or whatever concrete APIs exist
in your codebase) to delete persisted sender-key tracking, sender keys, and
Signal sessions for the given user/record.
- Around line 227-237: After calling
wacore::adv::filter_devices_by_key_index(&record.devices, &decoded), avoid
blindly re-adding the notified device; change the logic in the block using
record.devices, device_id, device.key_index and decoded so the push only happens
when the notified device's key_index is accepted by the decoded ADV list (i.e.,
present in decoded's valid indexes and not stale) — check the
decoded/valid-index condition before record.devices.push to prevent
reintroducing devices that filter_devices_by_key_index just removed.

In `@src/usync.rs`:
- Around line 87-123: The code uses existing_key_indices (built before the
raw_id mismatch check) after calling clear_device_record(), which reuses old
identity key indexes and can drop valid devices; update the logic so that when
decoded_key_index and existing_record exist and stored_raw_id != decoded.raw_id
(the same condition where you call clear_device_record), you also invalidate any
preserved identity state: clear or replace existing_key_indices (or set it to an
empty map) and avoid preserving existing.raw_id into raw_id so subsequent device
mapping/filtering (the devices vector and the call to
wacore::adv::filter_devices_by_key_index) uses only the fresh usync data; touch
the mismatch branch that contains decoded_key_index, existing_record,
stored_raw_id != decoded.raw_id and modify handling of existing_key_indices and
raw_id there to ensure old indexes aren’t reused.
- Around line 62-79: existing_record is fetched using the literal key
user_list.user.user which misses LID aliases; instead resolve the canonical user
key via the same alias-aware path used by the device-registry helpers (the path
used by update_device_list) before calling get_devices. Change the lookup to
first resolve the alias (e.g. call the registry/alias resolver used by the
device-registry helpers or a resolve_alias/resolve_user_key helper) to obtain
the canonical ID, then pass that canonical ID into
persistence_manager.backend().get_devices(...) so existing_record reflects
entries stored under LID and preserves key_index/raw_id for ADV filtering.

---

Outside diff comments:
In `@src/handlers/notification.rs`:
- Around line 494-505: When constructing the DeviceListRecord in the
notification handler, do not always set raw_id to None; instead preserve the
existing stored raw_id when account_sync provides no replacement so
update_device_list() doesn't erase previously learned raw_id from usync/device
notifications. Locate the DeviceListRecord creation (symbol: DeviceListRecord)
around the notification path that uses from_jid and devices, check for an
incoming account_sync raw_id (or absence thereof) and set
DeviceListRecord.raw_id to the existing stored value when account_sync has no
raw_id; ensure update_device_list() receives that preserved raw_id so
raw_id-mismatch invalidation still triggers on real identity changes.

In `@src/usync.rs`:
- Around line 140-146: The code extends all_devices from the raw
response.device_lists (fetched_devices) instead of the filtered list that was
written to the registry, causing the first post-cache-miss send to include
removed companions; change the extension to use the filtered variable (the
`devices` you already computed and stored in the registry) — e.g., stop
collecting from `response.device_lists` into `fetched_devices` and instead
extend `all_devices` with the filtered `devices` (or replace `fetched_devices`
with a collection sourced from that filtered `devices`) so the returned list
matches what was saved to the registry.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 21a53bc2-0fa2-4c59-89a6-919988d947ce

📥 Commits

Reviewing files that changed from the base of the PR and between 2c52132 and d4cfd34.

📒 Files selected for processing (14)
  • src/client/device_registry.rs
  • src/handlers/notification.rs
  • src/sender_key_device_cache.rs
  • src/usync.rs
  • storages/sqlite-storage/migrations/2026-03-31-000000_add_device_registry_raw_id/down.sql
  • storages/sqlite-storage/migrations/2026-03-31-000000_add_device_registry_raw_id/up.sql
  • storages/sqlite-storage/src/schema.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/adv.rs
  • wacore/src/iq/usync.rs
  • wacore/src/lib.rs
  • wacore/src/send.rs
  • wacore/src/store/traits.rs
  • wacore/src/usync.rs

Comment thread src/client/device_registry.rs
Comment thread src/client/device_registry.rs
Comment thread src/usync.rs Outdated
Comment thread src/usync.rs
- clear_device_record: delete Signal sessions for non-primary devices
  from cache + DB (not just in-memory cache invalidation)
- patch_device_add: validate new device key_index against valid_indexes
  before pushing (prevents reintroducing stale devices)
- usync: clear existing_key_indices after raw_id mismatch so old
  identity key indices aren't reused for fresh devices
- usync: use alias-aware load_device_record instead of raw get_devices
  to find records stored under LID when usync returns PN
- usync: return filtered devices (matching registry) instead of raw
  unfiltered response devices
- account_sync: preserve existing raw_id instead of always setting None

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/device_registry.rs (1)

736-771: 🧹 Nitpick | 🔵 Trivial

Consider adding tests for patch_device_add with actual key_index_info.

The existing tests pass None for key_index_info, exercising only the lenient fallback path. Consider adding tests that:

  1. Provide valid KeyIndexInfo with signed_bytes (mock protobuf)
  2. Verify stale devices are filtered out
  3. Verify devices with invalid key_index are rejected
  4. Verify raw_id mismatch triggers clear_device_record

This would provide coverage for the new ADV filtering logic in patch_device_add.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/device_registry.rs` around lines 736 - 771, Tests for
patch_device_add only exercise the None key_index_info path; add unit tests that
pass real KeyIndexInfo (including signed_bytes mock protobuf) to exercise the
ADV filtering and key-index validation paths. Create tests that: call
patch_device_add with make_device_element providing KeyIndexInfo containing
signed_bytes for a valid key_index and assert the device is accepted and stored;
supply a KeyIndexInfo whose signed_bytes indicate a stale timestamp and assert
stale devices are filtered out; supply elements with invalid/out-of-range
key_index and assert they are rejected; and supply an element whose raw_id does
not match the cached DeviceListRecord to assert clear_device_record is invoked
and the record cleared. Reference patch_device_add, make_device_element,
KeyIndexInfo, device_registry_cache, and clear_device_record when adding these
cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/device_registry.rs`:
- Around line 287-300: The code currently only invalidates the in-memory
sender_key_device_cache (self.sender_key_device_cache.invalidate_all()) after
deleting sessions and flushing the signal cache, but does not clear persisted
SKDM tracking; call
self.persistence_manager.clear_sender_key_devices(group_id).await (or the async
equivalent) for each group that the user belongs to when the identity/raw_id
changes—mirroring the pattern used in retry.rs (where sender key rotation clears
persisted tracking) so persisted sender_key_devices rows are removed and
replacement devices will receive redistributions; insert this call alongside the
flush_signal_cache() and cache invalidation steps in the same function (e.g.,
clear_device_record) to ensure both in-memory and persisted SKDM state are
cleared.

In `@wacore/src/adv.rs`:
- Around line 74-82: The function is_key_index_valid currently does O(n) lookups
via DecodedKeyIndex.valid_indexes.contains(&ki); change it to use a HashSet like
filter_devices_by_key_index to keep complexity consistent: either (A) accept a
&HashSet<u32> (or &DecodedKeyIndex::valid_index_set) instead of
&DecodedKeyIndex, or (B) add a method on DecodedKeyIndex (e.g., fn
contains_index(&self, idx: u32) -> bool) that checks a cached HashSet
representation and call that from is_key_index_valid; update the call sites
accordingly so is_key_index_valid uses HashSet::contains for O(1) lookups and
retains the same behavior with Some(ki) and None case.

---

Outside diff comments:
In `@src/client/device_registry.rs`:
- Around line 736-771: Tests for patch_device_add only exercise the None
key_index_info path; add unit tests that pass real KeyIndexInfo (including
signed_bytes mock protobuf) to exercise the ADV filtering and key-index
validation paths. Create tests that: call patch_device_add with
make_device_element providing KeyIndexInfo containing signed_bytes for a valid
key_index and assert the device is accepted and stored; supply a KeyIndexInfo
whose signed_bytes indicate a stale timestamp and assert stale devices are
filtered out; supply elements with invalid/out-of-range key_index and assert
they are rejected; and supply an element whose raw_id does not match the cached
DeviceListRecord to assert clear_device_record is invoked and the record
cleared. Reference patch_device_add, make_device_element, KeyIndexInfo,
device_registry_cache, and clear_device_record when adding these cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e241ee9c-5785-417c-9805-01f468740c08

📥 Commits

Reviewing files that changed from the base of the PR and between d4cfd34 and 11a244a.

📒 Files selected for processing (4)
  • src/client/device_registry.rs
  • src/handlers/notification.rs
  • src/usync.rs
  • wacore/src/adv.rs

Comment thread src/client/device_registry.rs Outdated
Comment thread wacore/src/adv.rs
Comment on lines +74 to +82
/// Check if a key_index is accepted by the decoded ADV list.
/// Used to validate a newly-notified device before adding it to the registry.
pub fn is_key_index_valid(key_index: Option<u32>, decoded: &DecodedKeyIndex) -> bool {
match key_index {
Some(ki) => decoded.valid_indexes.contains(&ki) || ki > decoded.current_index,
// No key_index — can't validate, accept to be lenient
None => true,
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider using HashSet for consistency with filter_devices_by_key_index.

is_key_index_valid uses Vec::contains() (O(n)) while filter_devices_by_key_index builds a HashSet for the same lookup. For small valid_indexes lists this is fine, but for consistency and if this is called in a loop, consider accepting a pre-built HashSet or using the same pattern.

♻️ Optional optimization
-pub fn is_key_index_valid(key_index: Option<u32>, decoded: &DecodedKeyIndex) -> bool {
-    match key_index {
-        Some(ki) => decoded.valid_indexes.contains(&ki) || ki > decoded.current_index,
-        // No key_index — can't validate, accept to be lenient
-        None => true,
-    }
+pub fn is_key_index_valid(
+    key_index: Option<u32>,
+    decoded: &DecodedKeyIndex,
+    valid_set: Option<&std::collections::HashSet<u32>>,
+) -> bool {
+    match key_index {
+        Some(ki) => {
+            let is_valid = valid_set
+                .map(|s| s.contains(&ki))
+                .unwrap_or_else(|| decoded.valid_indexes.contains(&ki));
+            is_valid || ki > decoded.current_index
+        }
+        None => true,
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/adv.rs` around lines 74 - 82, The function is_key_index_valid
currently does O(n) lookups via DecodedKeyIndex.valid_indexes.contains(&ki);
change it to use a HashSet like filter_devices_by_key_index to keep complexity
consistent: either (A) accept a &HashSet<u32> (or
&DecodedKeyIndex::valid_index_set) instead of &DecodedKeyIndex, or (B) add a
method on DecodedKeyIndex (e.g., fn contains_index(&self, idx: u32) -> bool)
that checks a cached HashSet representation and call that from
is_key_index_valid; update the call sites accordingly so is_key_index_valid uses
HashSet::contains for O(1) lookups and retains the same behavior with Some(ki)
and None case.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 11a244a8b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

);
self.clear_device_record(user, &device.jid.server, &record)
.await;
record.devices.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve primary device on raw_id mismatch

Clearing record.devices here drops device 0 before the new list is rebuilt, but add notifications usually carry only the newly added companion. In that case the persisted registry can end up containing only the companion device, and later get_user_devices treats the registry as authoritative (cache hit path) and never refetches from usync, so group fanout can skip the user’s primary phone entirely. Keeping/rehydrating primary 0 after a raw-id reset avoids silently under-targeting recipients.

Useful? React with 👍 / 👎.

Comment thread src/client/device_registry.rs Outdated
Comment on lines +298 to +300
// Invalidate sender_key_device_cache so stale SKDM tracking is discarded.
// Global invalidation because we don't track which groups a user is in.
self.sender_key_device_cache.invalidate_all();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Clear persisted sender-key state after identity reset

This only invalidates the in-memory cache, but SKDM targeting is repopulated from persisted rows (resolve_skdm_targets reloads via get_sender_key_devices). After a raw_id identity change, stale has_key=true DB entries remain and are reloaded, so the sender still believes the recipient already has sender keys and may skip redistribution, causing ongoing group decrypt failures until another path clears DB state.

Useful? React with 👍 / 👎.

When SKDM distribution fails with 406 (device unregistered), the stale
device was never marked as "sent" in SKDM tracking, causing a failing
network round-trip (~2s) on every subsequent group message.

Now:
- Mark distribution list as "sent" when all devices had missing prekeys
  or the IQ returned 406 — prevents retry loops
- Invalidate device registry for affected users so the next send gets
  a fresh device list from the server (without the stale device)
- Propagate skdm_had_unregistered_devices flag through
  PreparedGroupStanza so both send paths (group + status) handle it
…es as sent

Removes the hack that marked failed SKDM devices as "sent" in tracking.
WA Web never does this — failed devices stay in the distribution list
for retry (matching GroupSkmsgJob behavior).

Instead of preventing retries by lying about success:
- Only mark actually encrypted devices as "sent" (WA Web compliant)
- Invalidate device registry for specific users whose devices returned
  406 — the next send re-fetches from server, stale device is pruned
- Compute stale_device_users by diffing distribution_list vs
  successfully encrypted devices (zero unnecessary clones)
- First send: one 406 delay, second send: clean (stale device pruned)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8af6e6835

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/src/send.rs Outdated
Comment on lines +1113 to +1115
if actually_encrypted.is_empty() && !distribution_list.is_empty() {
skdm_encrypted_devices = distribution_list.clone();
had_unregistered_devices = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Only mark SKDM recipients that were actually encrypted

This branch treats actually_encrypted.is_empty() as if all targets were just unregistered and copies distribution_list into skdm_encrypted_devices, but an empty result can also happen for non-406 paths (e.g., no bundles returned or per-device encryption failures). The caller later persists skdm_encrypted_devices via update_sender_key_devices, so devices that never received SKDM get recorded as has_key=true, and future resolve_skdm_targets runs can skip redistribution and leave recipients unable to decrypt group messages.

Useful? React with 👍 / 👎.

Comment thread src/send.rs Outdated
Comment on lines +1124 to +1126
if update.had_unregistered {
for device in &update.devices {
self.invalidate_device_cache(&device.user).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Clear persisted device records before forcing a refresh

Invalidating only the in-memory device cache here does not force a server refresh: get_user_devices falls back to the persisted device_registry row through get_devices_from_registry, treats it as a cache hit, and skips usync. That means stale/unregistered devices remain authoritative after this branch runs, so repeated sends can keep reusing the same bad device set and re-triggering 406 behavior.

Useful? React with 👍 / 👎.

Comment thread src/usync.rs
Comment on lines +97 to +100
self.clear_device_record(
&user_list.user.user,
&user_list.user.server,
existing,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use canonical alias when clearing sessions on raw_id changes

This cleanup call passes the usync user/server directly, but clear_device_record deletes sessions by reconstructing addresses from those exact values; for mapped contacts that commonly means PN addresses. Since encryption/session establishment is LID-first for mapped users, stale LID sessions can survive a raw_id mismatch and continue being used after an identity reset, causing ongoing identity/session inconsistencies.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/send.rs`:
- Around line 1122-1128: The loop invalidates the same user's cache multiple
times when update.devices contains several devices for one user; before calling
self.invalidate_device_cache(&device.user).await, collect and deduplicate the
users from update.devices (e.g., into a HashSet or by iterating and skipping
duplicates) and then call invalidate_device_cache once per unique user when
update.had_unregistered is true (references: update.had_unregistered,
update.devices, self.invalidate_device_cache).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5f81925a-5130-4625-9ef5-6c32e5fa15ea

📥 Commits

Reviewing files that changed from the base of the PR and between 11a244a and a8af6e6.

📒 Files selected for processing (2)
  • src/send.rs
  • wacore/src/send.rs

Comment thread src/send.rs Outdated
WA Web's AdvDeviceNotificationApi.js line 59:
  h.has(e.keyIndex) || e.keyIndex > y
When keyIndex is null: h.has(null)→false, null>y→false → device REMOVED.

Our code incorrectly returned true for None key_index, keeping devices
that WA Web would remove. Fixed both filter_devices_by_key_index() and
is_key_index_valid() to return false for None.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
wacore/src/send.rs (1)

1073-1128: ⚠️ Potential issue | 🟠 Major

Don't infer 406s from actually_encrypted.is_empty().

That condition is neither necessary nor sufficient evidence of an unregistered device. Mixed outcomes leave stale_device_users empty even though some devices were skipped as stale, while all-skipped non-406 misses can mark users stale incorrectly. Also, resolved_devices_for_phash is never narrowed/cleared here, so the stanza can still emit a phash for devices that never got <participants> entries. Return explicit unregistered-device info from encrypt_for_devices, and derive phash from the successfully encrypted set.

Also applies to: 1215-1226

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/send.rs` around lines 1073 - 1128, The current logic infers
unregistered devices from actually_encrypted.is_empty() and leaves
resolved_devices_for_phash unfiltered, which causes incorrect 406 detection and
phash emission; update encrypt_for_devices (call sites in this block and the
similar one at 1215-1226) to return explicit skipped/unregistered device info
(e.g., a list/flag of unregistered devices and the set of successfully encrypted
devices), then use that explicit unregistered list to set
had_unregistered_devices and populate stale_device_users, and when building
resolved_devices_for_phash / skdm_encrypted_devices derive the phash only from
the successfully encrypted device set (actually_encrypted) rather than the
original distribution_list so phash/stanza emission matches actual encrypted
recipients.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wacore/src/send.rs`:
- Around line 411-431: The current batch prekey fetch replaces all bundles with
an empty HashMap when fetch_prekeys_for_identity_check returns a
device-unregistered 406; instead, on Err(e) where
is_device_unregistered_error(&e) is true, retry/fallback per JID: iterate
jids_needing_prekeys and call the resolver per-JID (e.g., reusing
fetch_prekeys_for_identity_check for single-JID or a single-fetch API), collect
successful bundles into prekey_bundles, skip only those JIDs that return a 406,
and only return Err(e) for non-406 errors; update the code around
fetch_prekeys_for_identity_check, is_device_unregistered_error, prekey_bundles,
and jids_needing_prekeys to implement this per-JID retry/skip behavior so one
stale device does not blank the whole batch.

---

Outside diff comments:
In `@wacore/src/send.rs`:
- Around line 1073-1128: The current logic infers unregistered devices from
actually_encrypted.is_empty() and leaves resolved_devices_for_phash unfiltered,
which causes incorrect 406 detection and phash emission; update
encrypt_for_devices (call sites in this block and the similar one at 1215-1226)
to return explicit skipped/unregistered device info (e.g., a list/flag of
unregistered devices and the set of successfully encrypted devices), then use
that explicit unregistered list to set had_unregistered_devices and populate
stale_device_users, and when building resolved_devices_for_phash /
skdm_encrypted_devices derive the phash only from the successfully encrypted
device set (actually_encrypted) rather than the original distribution_list so
phash/stanza emission matches actual encrypted recipients.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9c1a8c4b-6cc5-4572-a623-6195f9be14aa

📥 Commits

Reviewing files that changed from the base of the PR and between a8af6e6 and 626e914.

📒 Files selected for processing (2)
  • src/send.rs
  • wacore/src/send.rs

Comment thread wacore/src/send.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7473ae799e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/usync.rs
Comment on lines +124 to +126
// Apply valid_indexes filtering if key-index-list was decoded
if let Some(ref decoded) = decoded_key_index {
devices = wacore::adv::filter_devices_by_key_index(&devices, decoded);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid dropping companion devices on first usync fetch

When get_user_devices fetches a user not already present in the registry, each fetched companion is created with key_index = None, and this new filter call removes all non-primary devices with unknown key indexes. In that first-fetch path the persisted/returned device list collapses to device 0, so outbound fanout under-targets linked devices until some later notification happens to backfill key indexes (which is not guaranteed). This should not filter out companions solely because key index metadata is missing from prior cache state.

Useful? React with 👍 / 👎.

Comment thread wacore/src/iq/usync.rs
Comment on lines 619 to +622
user: user_jid.to_non_ad(),
devices,
phash,
key_index_bytes,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject companion usync entries missing key-index-list

DeviceListSpec::parse_response now extracts key-index-list bytes but still unconditionally pushes users with companion devices even when those bytes are absent. The production Client::get_user_devices path consumes this parser, so unverified companion entries are still persisted and reused, allowing stale/unregistered devices to remain authoritative and repeatedly trigger 406 errors on send. The same guard already exists in wacore/src/usync.rs; this parser should enforce it before pushing UserDeviceList.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wacore/src/adv.rs`:
- Around line 74-80: The comment in is_key_index_valid is inconsistent with the
logic (it currently allows Some(ki) when ki > decoded.current_index or ki in
decoded.valid_indexes). Update the comment for the function is_key_index_valid
(and the inline WA Web note) to state that a non-null key_index is accepted if
it is in decoded.valid_indexes OR if it is greater than decoded.current_index
(explain briefly why, e.g., to allow new rotations/forward indices), so the
comment matches the actual checks against decoded.valid_indexes and
decoded.current_index.
- Around line 95-178: Add focused unit tests for the helper is_key_index_valid
to lock in its new semantics: assert that None returns false, that a device
key_index > current_index returns true, that a key_index present in
valid_indexes returns true, and that other numeric key_index values return
false; place these new tests alongside the existing tests (e.g., near
filter_devices_by_key_index tests) and reference the is_key_index_valid function
directly so future changes to filter logic won’t regress the helper behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: db0d50d5-ff42-4973-89a4-35925d4d7e39

📥 Commits

Reviewing files that changed from the base of the PR and between 626e914 and 7473ae7.

📒 Files selected for processing (1)
  • wacore/src/adv.rs

Comment thread wacore/src/adv.rs
Comment thread wacore/src/adv.rs
The 406 downcast was silently failing because the anyhow error wraps
whatsapp_rust::IqError (high-level crate) but wacore tried to downcast
to wacore::IqError — different types, always returns false.

Fix: introduce wacore::request::ServerErrorCode as a shared lightweight
error type that any crate can embed in anyhow. The SendContextResolver
impl in the high-level crate wraps server errors in this type before
returning. wacore's is_device_unregistered_error() downcasts to it.

This is scalable: future server error checks (409, 503, etc.) use the
same ServerErrorCode::from_anyhow() pattern without trait changes.

Added 4 tests validating the downcast logic.
The invalidation only cleared the moka in-memory cache but left the
stale device record in SQLite. On the next get_user_devices call,
the cache miss fell back to the DB and re-loaded the stale device,
causing the 406 SKDM retry on every single group send.

Fix: invalidate_device_cache now calls delete_devices() on the DB
backend for all alias keys, forcing a network re-fetch (usync) on
the next query. Added delete_devices to ProtocolStore trait with
implementations in SqliteStore and InMemoryBackend.
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 31, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 2m 50s —— View job


Code Review: PR #469 — Stale Device Registry Fix

  • Read core changes (adv.rs, device_registry.rs, usync.rs, send.rs)
  • Read supporting changes (traits, storage, notifications, context_impl)
  • Verify automated review findings were addressed
  • Provide review feedback

Overall this is a well-structured, multi-phase fix that correctly addresses the root cause of stale device accumulation. The WA Web cross-references are valuable. Here are my findings:

Issues

1. clear_device_record only clears in-memory SKDM cache, not persisted DB rowssrc/client/device_registry.rs:305

Both CodeRabbit and Codex flagged this, and it's real. sender_key_device_cache.invalidate_all() only wipes the in-memory moka cache. The persisted sender_key_devices table still contains has_key=true rows for the old identity's devices. After restart or cache miss, resolve_skdm_targets reloads from DB and believes stale devices already have sender keys, skipping redistribution.

The project already has clear_sender_key_devices(group_jid) on the persistence layer (used in src/send.rs:435 during rotation). The challenge is that clear_device_record doesn't know which groups the user belongs to. Two options:

  • Add a clear_all_sender_key_devices() method (nuke the whole table — heavy but correct for identity changes)
  • Or accept that the in-memory invalidation covers the hot path and document the restart gap

I'd recommend at minimum adding a comment documenting this limitation, or ideally clearing persisted SKDM state for all groups (identity changes are rare enough that the cost is acceptable).

Fix this →

2. Batch 406 blanks all prekey bundleswacore/src/send.rs:415-431

When fetch_prekeys_for_identity_check returns a 406 for a batch of JIDs, the fallback replaces all bundles with an empty HashMap. If the batch contained both stale and valid devices, the valid ones are also skipped. This is the most impactful issue in the PR — one stale device can suppress SKDM/prekey delivery to all other new-session devices in the same encrypt call.

The comment says "WA Web's fetchPrekeys() collects per-device errors separately" — but the implementation doesn't match that description. WA Web does per-device retry; this does batch-level fallback. In practice this may be mitigated because the batch is usually per-user (single device list), but for multi-device users with one stale companion this would drop all companions.

Fix this →

3. Missing companion guard in DeviceListSpec::parse_responsewacore/src/iq/usync.rs:618

wacore/src/usync.rs correctly rejects companion devices without signedKeyIndexBytes (line 114), but DeviceListSpec::parse_response (the IQ-based usync path used by Client::get_user_devices) doesn't apply the same guard. The consumer in src/usync.rs does apply filter_devices_by_key_index downstream, but only if decoded_key_index is Some. When key_index_bytes is None and there are companions, they pass through unfiltered. Adding the same guard to the parser would be defense-in-depth.

4. is_key_index_valid comment says "must be in valid_indexes" but code also accepts > current_indexwacore/src/adv.rs:79

Minor but the comment on line 79 is misleading:

// WA Web: keyIndex must be non-null and in valid_indexes to be added
None => false,

The Some branch also accepts ki > decoded.current_index. The comment should reflect this.

Things that look good / previously addressed

  • Filtered return path (src/usync.rs): The old code returned raw response.device_lists; now it correctly returns fetched_devices built from the filtered devices list. Properly addressed.
  • DB deletion on cache invalidation (src/client/device_registry.rs:186): invalidate_device_cache now also calls delete_devices on the backend, fixing the "falls back to stale persisted data" issue. Good fix.
  • ADV-gated device add (src/client/device_registry.rs:239): After filter_devices_by_key_index, the new device is only added if is_key_index_valid accepts it. This prevents reintroducing filtered-out devices. Properly addressed.
  • existing_key_indices.clear() on raw_id mismatch (src/usync.rs:104): Old identity key indexes are discarded after clear_device_record. Properly addressed.
  • Preserving raw_id in account_sync (src/handlers/notification.rs:494): Existing raw_id is loaded and preserved so account_sync doesn't erase it. Properly addressed.
  • Cross-crate 406 detection (wacore/src/request.rs + src/client/context_impl.rs): Clean pattern using ServerErrorCode with downcast. Tests cover all edge cases including the "bare IqError doesn't match" case.
  • ADV module (wacore/src/adv.rs): Clean implementation with good test coverage. HashSet usage in filter_devices_by_key_index is efficient.
  • SKDM try/catch (wacore/src/send.rs:1091-1141): Matches WA Web's GroupSkmsgJob pattern — SKDM failure doesn't kill the group send.

Minor suggestions

  • Consider adding is_key_index_valid unit tests (as CodeRabbit suggested) — the None → false semantic is non-obvious and worth locking in.
  • The stale_device_users collection (wacore/src/send.rs:1218-1230) uses the actually_encrypted.is_empty() && !distribution_list.is_empty() heuristic. This is imprecise — a non-406 failure could also produce empty results. It works because the 406 fallback in encrypt_for_devices is the primary path to empty results, but consider propagating explicit "had 406" state from encrypt_for_devices for clarity.

|

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c171325f7e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/src/send.rs Outdated
Comment on lines +1110 to +1111
if actually_encrypted.is_empty() && !distribution_list.is_empty() {
had_unregistered_devices = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Flag partial 406 SKDM failures as stale

This sets had_unregistered_devices only when actually_encrypted.is_empty(), so a mixed case is missed: if some devices already have sessions (and encrypt succeeds) while others hit 406 during prekey fetch, stale_device_users stays empty. In that scenario the caller never invalidates those users’ device registry entries, so stale devices remain cached and every later group send keeps re-hitting the same 406 path instead of triggering a refresh.

Useful? React with 👍 / 👎.

@@ -0,0 +1,2 @@
-- SQLite doesn't support DROP COLUMN in older versions, but newer SQLite (3.35+) does.
ALTER TABLE device_registry DROP COLUMN raw_id;

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 Rewrite down migration without DROP COLUMN

The rollback migration uses ALTER TABLE ... DROP COLUMN, which fails on SQLite versions prior to 3.35.0; this repo’s other down migrations use table-rebuild patterns specifically for that compatibility. As written, diesel migration revert can fail in older runtime environments, blocking downgrade/reset workflows.

Useful? React with 👍 / 👎.

…s, tests

1. clear_device_record: now calls clear_all_sender_key_devices() on DB
   backend so stale has_key=true rows don't survive restart. Added
   clear_all_sender_key_devices to ProtocolStore trait.

2. Batch 406 prekey fetch: on 406, retry per-device to salvage valid
   companions. Explicit had_406 flag propagated through EncryptResult
   instead of inferring from actually_encrypted.is_empty().

3. DeviceListSpec::parse_response: added companion-without-signedKeyIndexBytes
   guard (defense-in-depth, matching the guard in wacore/src/usync.rs).

4. is_key_index_valid: fixed misleading comment, added 4 unit tests
   covering valid set, not-in-set, newer-than-current, and None rejection.
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 31, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 4m 29s —— View job


Code Review: PR #469 — Stale Device Registry Fix

  • Read core changes (adv.rs, device_registry.rs, usync.rs, send.rs)
  • Read supporting changes (traits, storage, notifications, context_impl, request.rs)
  • Check prior review findings against current code
  • Provide review feedback

This is a well-structured, multi-phase fix. The WA Web cross-references are valuable and the phased approach (graceful error handling → root cause fix → security hardening → type safety) is sound. Most prior review findings have been addressed. Here's what I found on the current state:

Previously reported issues — now fixed

  • Batch 406 blanking all bundles — Fixed with per-device retry fallback (wacore/src/send.rs:429-459). Clean implementation.
  • clear_device_record not clearing persisted SKDM — Fixed via clear_all_sender_key_devices() call (device_registry.rs:305-312).
  • Filtered return pathsrc/usync.rs now correctly returns fetched_devices built from the filtered devices list, not raw response.
  • ADV-gated device addpatch_device_add now checks is_key_index_valid before adding (device_registry.rs:238-239).
  • existing_key_indices.clear() on raw_id mismatch — Properly clears old identity key indexes (src/usync.rs:104).
  • Preserving raw_id in account_sync — Correctly loads and preserves existing raw_id (notification.rs:492-496).
  • Down migration SQLite compatibility — Rewritten with table-rebuild pattern (down.sql).
  • is_key_index_valid comment accuracy — Comment now correctly describes the validIndexes || > currentIndex logic (adv.rs:77-78).
  • is_key_index_valid unit tests — Four new focused tests added (adv.rs:156-198).
  • Companion guard in IQ usync parser — Present at wacore/src/iq/usync.rs:620-627.

Remaining issues

1. resolved_devices_for_phash includes devices that failed encryptionwacore/src/send.rs:1108

resolved_devices_for_phash is set to the full distribution_list before SKDM encryption runs. If some devices fail (406, encryption error, etc.), the phash still reflects the full intended list rather than the actually-encrypted set. This means the server receives a phash that doesn't match the actual <participants> nodes. In practice the server may log a mismatch but likely doesn't reject — but it's technically incorrect and could cause issues if the server starts enforcing phash correctness.

Consider deriving phash from skdm_encrypted_devices (the actually-encrypted set) instead of the full distribution list.

2. stale_device_users detection relies on comparison between distribution_list and skdm_encrypted_deviceswacore/src/send.rs:1246-1259

The had_unregistered_devices flag is set from result.had_unregistered_device (which tracks 406 specifically via had_406), so this is actually correct — it only fires when there was a real 406. However, the stale user identification then uses the diff between distribution_list and encrypted_devices, which could include devices that failed for non-406 reasons (e.g., encryption errors at send.rs:632-638). This is a minor imprecision — on a 406 path it's fine to invalidate broadly, but worth noting.

3. raw_id preservation heuristic in usyncsrc/usync.rs:108-110

if raw_id.is_none() && !existing_key_indices.is_empty() {
    raw_id = existing_record.as_ref().and_then(|r| r.raw_id);
}

The condition !existing_key_indices.is_empty() is used as a proxy for "no mismatch occurred" (since it's cleared on mismatch). This works but is indirect — a comment explaining this intent would help future readers. The condition is: "we have an existing record with devices, no decoded key index was received (so raw_id is None), and no mismatch cleared the indices, so preserve the stored raw_id."

4. Device key_index not populated from usync responsesrc/usync.rs:112-122

When building DeviceInfo from usync, key_index is only populated from existing_key_indices (previously stored values). The usync response itself doesn't carry per-device key_index values — they come from ADV valid_indexes. This means freshly-fetched companion devices start with key_index: None, which will be rejected by is_key_index_valid if patch_device_add runs before the key_index is learned via notification. However, filter_devices_by_key_index in the same block handles this correctly (primary kept, companions with None are filtered out by the ADV list), so this is consistent but worth understanding.

Things that look good

  • Per-device retry on batch 406 (wacore/src/send.rs:437-459) — Clean implementation that salvages valid bundles.
  • ADV module (wacore/src/adv.rs) — Well-tested with 9 unit tests covering all edge cases.
  • Cross-crate error pattern (ServerErrorCode + context_impl.rs wrapping) — Scalable design for future error codes.
  • invalidate_device_cache also deletes from DB (device_registry.rs:186) — Prevents stale persisted data from being treated as a cache hit.
  • clear_device_record clearing both in-memory and persisted SKDM state — Properly handles identity changes.
  • Down migration using table-rebuild — SQLite < 3.35 compatible.
  • Test coverage — Good coverage of ADV filtering, is_key_index_valid, 406 detection, and usync parsing.

Minor nits

  • wacore/src/send.rs:454 — When a non-406 error occurs during per-device retry, the device is silently skipped with a warning. This is fine for SKDM (try/catch pattern), but in encrypt_for_devices used for the main message encryption path, silently dropping a device might be worth surfacing.
  • The HashSet vs Vec::contains difference between filter_devices_by_key_index and is_key_index_valid is fine given valid_indexes lists are typically tiny (< 10 entries).

Overall: solid PR with good WA Web compliance. The phash issue (finding #1) is the most actionable remaining item.


@jlucaso1 jlucaso1 changed the title fix: decode key-index-list to filter stale devices from device registry fix!: decode key-index-list to filter stale devices from device registry Mar 31, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e4b130d41

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +227 to +228
self.clear_device_record(user, &device.jid.server, &record)
.await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Pass canonical JID when clearing sessions on add raw_id reset

On raw_id mismatch in patch_device_add, this call uses the notification alias (user + device.jid.server) instead of the canonical identity stored in the record, so clear_device_record can delete only PN-addressed sessions for mapped users while LID sessions remain. Because send encryption is LID-first, those stale LID sessions can continue to be selected after an identity reset, causing ongoing identity/session inconsistencies instead of a clean re-establishment.

Useful? React with 👍 / 👎.

clear_device_record used only the notification's server address, missing
sessions stored under the alternate address (e.g., LID sessions when
notification arrived via PN). Now resolves both LID and PN aliases via
resolve_lookup_keys and deletes sessions for all combinations.
…ment

phash was computed from the full distribution_list before encryption,
including devices that might fail. Now derived from skdm_encrypted_devices
so phash matches the actual <participants> nodes in the stanza.

Also clarified the raw_id preservation heuristic comment in usync.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f1aafb3a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +8 to +12
CREATE TABLE device_registry (
user_id TEXT NOT NULL,
devices_json TEXT NOT NULL,
timestamp INTEGER NOT NULL,
phash TEXT,

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 Restore prior device_registry schema in down migration

The down migration rebuilds device_registry with a different schema than the one it is supposed to restore: it changes updated_at to DEFAULT 0 and never recreates the idx_device_registry_timestamp, idx_device_registry_device, and idx_device_registry_updated_at indexes that were introduced in 2025-12-24-000000_add_whatsapp_web_alignment/up.sql. After diesel migration revert, this leaves the database in a non-equivalent state, which can change default-row behavior and degrade lookup performance in downgrade/reset workflows.

Useful? React with 👍 / 👎.

WA Web computes phash from the target distribution list at send time,
not the actual encryption outcome. Reverted the phash change that used
skdm_encrypted_devices — this caused phash mismatch vs server expectation.

Also added TODO for advAccountType mismatch check (WA Web BizCoex feature
gated behind bizHostedDevicesEnabled — not yet implemented).
Down migration was missing the strftime default for updated_at and
the three indexes from the original 2025-12-24 migration.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: stale devices persist in registry — key-index-list valid_indexes not decoded on device add notifications

1 participant