Skip to content

fix(receipt): downgrade delivery ack to "sent" on lid feature-incapable error - #708

Merged
jlucaso1 merged 2 commits into
mainfrom
fix/receipt-feature-incapable-downgrade
Jun 4, 2026
Merged

fix(receipt): downgrade delivery ack to "sent" on lid feature-incapable error#708
jlucaso1 merged 2 commits into
mainfrom
fix/receipt-feature-incapable-downgrade

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

WA Web's receipt parser inspects an <error> child of <receipt>: when reason="lid" and type="feature-incapable", it downgrades the ack from RECEIVED to SENT, signalling the message was NOT delivered (the LID peer is feature-incapable). handle_receipt never read that child and always dispatched Event::Receipt with the raw type attribute, so a <receipt type="delivery"> carrying that error was reported as ReceiptType::Delivered: a false delivery tick for a message the server explicitly said wasn't delivered.

WA Web (MsgReceiptParser): a = e.maybeChild("error"); a.reason === "lid" && a.type === "feature-incapable" && (r.ack = ACK.SENT).

Fix

  • Add ReceiptType::Sent (sent but not delivered), a derived incoming-only state.
  • New pure helper wacore::stanza::receipt::downgrade_for_feature_incapable(node, parsed_type) returns Sent when a Delivered receipt carries <error reason="lid" type="feature-incapable">. Scoped to Delivered (the only type that carries this error), which also keeps retry / enc-rekey receipts from being rerouted.
  • handle_receipt applies the downgrade right after parsing the type, so it flows into both the simple and aggregated dispatch.
  • send_protocol_receipt's hand-rolled ReceiptType -> wire match (which duplicated as_wire_str) is replaced by as_wire_str(), keeping a single source of truth and covering the new variant.

Tests

New feature_incapable_error_downgrades_delivery_to_sent: builds the receipt node and asserts the lid/feature-incapable error downgrades Delivered -> Sent, while a missing error, a different error type, and a non-Delivered type are all left unchanged.

cargo fmt --all
cargo clippy -p whatsapp-rust -p wacore --all-targets -- -D warnings   # clean
cargo test -p wacore --lib            # 872 pass (incl. 1 new)
cargo test -p whatsapp-rust --lib     # 670 pass

Breaking

ReceiptType gains a Sent variant (pre-1.0; the enum already lacks #[non_exhaustive]). Consumers with exhaustive matches must add a Sent arm. Sent only appears on incoming delivery receipts the server flagged as undelivered.

…le error

WA Web downgrades a delivery receipt's ack to SENT when it carries <error reason=lid type=feature-incapable> (the LID peer can't receive the message). handle_receipt never read that child and dispatched the receipt as Delivered, a false delivery tick.

Add ReceiptType::Sent and a pure downgrade_for_feature_incapable() helper, apply it in handle_receipt after parsing the type, and replace the duplicated ReceiptType->wire match in send_protocol_receipt with as_wire_str().
@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b38c951e-b1bb-4b9c-a7d3-dd78658f1503

📥 Commits

Reviewing files that changed from the base of the PR and between 8a41b7c and a1a8f23.

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

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a new "sent" receipt status to better represent downgraded delivery states.
  • Bug Fixes

    • Receipt processing now treats certain incoming error-marked receipts as "sent" to avoid incorrectly re-emitting delivered-like statuses, improving cross-platform delivery consistency.

Walkthrough

Adds ReceiptType::Sent and a pure downgrade_for_feature_incapable that turns DeliveredSent when a receipt carries an <error reason="lid" type="feature-incapable">. handle_receipt applies the downgrade to stanza and per-participant types. send_protocol_receipt uses ReceiptType::as_wire_str() for serialization.

Changes

Receipt type downgrade for feature-incapable errors

Layer / File(s) Summary
ReceiptType enum extension with wire mapping
wacore/src/types/presence.rs
New ReceiptType::Sent variant is added with wire mapping support. The from_known parser and as_wire_str serializer are updated to handle the "sent" wire string.
Downgrade helper function and tests
wacore/src/stanza/receipt.rs
downgrade_for_feature_incapable is introduced as a pure function that downgrades ReceiptType::Delivered to ReceiptType::Sent when the receipt contains a feature-incapable LID error. Unit tests validate the downgrade path, error-less receipts, mismatched error types, and non-Delivered types.
Receipt handler downgrade integration
src/receipt.rs
handle_receipt post-processes parsed receipts through the downgrade helper, updating the type when feature-incapable errors are detected, including per-participant type overrides.
Client wire serialization refactoring
src/client.rs
send_protocol_receipt replaces its explicit variant match with a call to ReceiptType::as_wire_str(), centralizing the wire mapping and clarifying that Sent is derived/incoming-only.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

api-design, breaking-change

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: downgrading delivery receipts to 'sent' when a feature-incapable error is present, which aligns with the core functionality across all modified files.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the problem, fix, tests, and breaking changes with clear technical detail.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/receipt-feature-incapable-downgrade

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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: 8a41b7c575

ℹ️ 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/receipt.rs
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

Benchmark Results

67 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 2,838 2,838 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,272 8,272 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 31,317 31,317 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 13,827 13,827 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 49,398 49,398 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 54,827 54,827 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 1,592 1,592 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 4,219 4,219 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 112,939 113,083 -0.1%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,234 1,656,234 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 650,224 650,192 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 873,745 873,813 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,081,602 2,081,591 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 747,428 747,431 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,330,805 1,330,761 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,372,558 4,374,226 -0.0%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 514,351 517,458 -0.6%
binary_benchmark::marshal_group::bench_marshal_allocating 45,401 45,401 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 45,451 45,451 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 66,354 66,354 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 43,512 43,512 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 45,507 45,507 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 4,930 4,930 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 4,961 4,961 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,732 6,732 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,529 528,529 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 528,150 528,150 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,396 529,396 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 5,417,786 5,417,786 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 5,362,043 5,362,043 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 13,276,336 13,276,336 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 1,850 1,850 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 29,217 29,217 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,890 672,890 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 3,736 3,736 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 3,840 3,840 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 48,283 48,283 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,866 3,866 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 48,344 48,344 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 5,206 5,206 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 66,668 66,668 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 310,312 310,312 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,286 8,286 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 254 254 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 91 91 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 292 292 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 137 137 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 317 317 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 145 145 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 27,425 27,425 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 10,725 10,725 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 4,139,826 4,144,534 -0.1%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,131 100,133 -0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 4,263,702 4,263,702 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 99,803 99,803 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 210,262 210,238 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 496,921 496,921 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 507,672 511,990 -0.8%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,974,586 11,975,365 -0.0%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 2,466,138 2,466,138 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 4,938,302 4,845,382 +1.9%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,397 2,043,397 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,950 37,950 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 3,616,076 3,616,076 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 223,140 223,150 -0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 9,980,959 9,980,959 +0.0%
No significant changes detected.

@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.

Caution

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

⚠️ Outside diff range comments (2)
wacore/src/types/presence.rs (2)

137-153: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add Sent to the round-trip invariant test set.

ReceiptType::Sent was added to parse/serialize paths, but the test matrix skips it. That leaves the new wire mapping unguarded.

Suggested test patch
         let variants = [
             ReceiptType::Delivered,
+            ReceiptType::Sent,
             ReceiptType::Sender,
             ReceiptType::Retry,
             ReceiptType::EncRekeyRetry,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wacore/src/types/presence.rs` around lines 137 - 153, The test
as_wire_str_round_trips_through_parse() omits ReceiptType::Sent from its
variants array, leaving the new parse/serialize mapping untested; update the
variants list in that function to include ReceiptType::Sent so the round-trip
invariant covers the Sent case (ensure the array alongside
ReceiptType::Delivered, ReceiptType::Sender, etc. includes ReceiptType::Sent).

26-33: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Unify ReceiptType wire mapping under WireEnum (stop split-brain).

wacore/src/types/presence.rs keeps ReceiptType’s wire mapping split between hand-written from_known/as_wire_str and #[derive(Serialize, Deserialize)] on the enum. Refactor ReceiptType to #[derive(WireEnum)] and encode the existing mapping via explicit #[wire = "..."] / #[wire_alias = "..."] / #[wire_fallback] (for the catch-all Other(String)), then remove the manual serde derives so WireEnum is the single source of truth.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wacore/src/types/presence.rs` around lines 26 - 33, Replace the ad-hoc serde
mapping on the ReceiptType enum with a single WireEnum-driven mapping: remove
#[derive(Serialize, Deserialize)] and any manual conversion helpers (e.g.
from_known, as_wire_str) and instead #[derive(WireEnum, Debug, Clone, PartialEq,
Eq)] on pub enum ReceiptType; add explicit #[wire = "..."] / #[wire_alias =
"..."] attributes on the existing variants (e.g. Delivered, Sent) to encode
their wire strings and add a catch-all variant Other(String) annotated with
#[wire_fallback] to capture unknown values; ensure the enum retains existing
variant docs/comments and that all serialization/deserialization now flows
through the WireEnum implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@wacore/src/types/presence.rs`:
- Around line 137-153: The test as_wire_str_round_trips_through_parse() omits
ReceiptType::Sent from its variants array, leaving the new parse/serialize
mapping untested; update the variants list in that function to include
ReceiptType::Sent so the round-trip invariant covers the Sent case (ensure the
array alongside ReceiptType::Delivered, ReceiptType::Sender, etc. includes
ReceiptType::Sent).
- Around line 26-33: Replace the ad-hoc serde mapping on the ReceiptType enum
with a single WireEnum-driven mapping: remove #[derive(Serialize, Deserialize)]
and any manual conversion helpers (e.g. from_known, as_wire_str) and instead
#[derive(WireEnum, Debug, Clone, PartialEq, Eq)] on pub enum ReceiptType; add
explicit #[wire = "..."] / #[wire_alias = "..."] attributes on the existing
variants (e.g. Delivered, Sent) to encode their wire strings and add a catch-all
variant Other(String) annotated with #[wire_fallback] to capture unknown values;
ensure the enum retains existing variant docs/comments and that all
serialization/deserialization now flows through the WireEnum implementation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b8c422f4-7faf-470c-8876-277e2123f3dc

📥 Commits

Reviewing files that changed from the base of the PR and between 0e3c598 and 8a41b7c.

📒 Files selected for processing (4)
  • src/client.rs
  • src/receipt.rs
  • wacore/src/stanza/receipt.rs
  • wacore/src/types/presence.rs

…d types

An aggregated-by-message receipt re-parsed each <user type=delivery> back to Delivered, undoing the receipt-level downgrade. Apply downgrade_for_feature_incapable to the per-user type too, so a feature-incapable LID participant in a group receipt no longer emits a false delivered tick.
@jlucaso1
jlucaso1 merged commit 7a86959 into main Jun 4, 2026
12 checks passed
@jlucaso1
jlucaso1 deleted the fix/receipt-feature-incapable-downgrade branch June 4, 2026 11:34
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.

1 participant