Skip to content

feat(msg-secret)!: bound messageSecret retention by policy and event-time horizon - #668

Merged
jlucaso1 merged 13 commits into
mainfrom
feat/msg-secret-retention-policy
May 31, 2026
Merged

feat(msg-secret)!: bound messageSecret retention by policy and event-time horizon#668
jlucaso1 merged 13 commits into
mainfrom
feat/msg-secret-retention-policy

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented May 31, 2026

Copy link
Copy Markdown
Collaborator

What

Bounds the per-message messageSecret store, which a recent change (#665) made grow without limit: it seeds a secret for every non-forwarded history-sync message and captures one for every live message and every send, and nothing ever prunes them. On a freshly paired headless account that produced ~95k rows accounting for ~99% of the SQLite file.

Retention is now one policy plus a per-row, event-time deadline, all on CacheConfig:

  • msg_secret_policy: MsgSecretPolicyManaged (default), BotOnly, Full, Disabled — unifies the three previously-scattered decisions (capture on live receive, seed from history, prune).
  • Per-row expires_at (absolute unix seconds, 0 = never) replaces the old created_at-based TTL. created_at was insert time and was overwritten on every conflict-update and edit re-persist, so no age-based prune was ever sound. The deadline is computed from the parent message's own event time plus a per-add-on-kind horizon, and on conflict the later deadline wins (0 = never wins) so a redelivery or edit re-persist never shortens a window.
  • msg_secret_retention horizons: text/MESSAGE_EDIT 30d, poll/event 90d, bot 30d. An edit is only valid when authored within 20 min of the parent, but the offline queue can deliver that (validly-authored) edit up to ~30 days later, so 30d is the floor that keeps a 30-day-offline receiver able to decrypt. PollAddOption/EventEdit have no window and ride the parent's secret, so poll/event parents get the longer 90d horizon.
  • Edit-processing window on receive — the parent's event time is now stored (message_ts) so the receive path can drop a secret-encrypted MESSAGE_EDIT authored outside the window (editTs >= parentTs + 1200s), matching WhatsApp Web's ProcessEditProtocolMsgs. The check is on authored time (not "now"), so a validly-authored edit still applies after an offline gap; it is permissive when the parent's time is unknown (resolver-supplied secrets carry none).
  • Age/type-filtered seed — history secrets whose parent is already past its horizon are dropped at seed time. For a multi-month history this collapses the pairing burst to the recent, still-relevant slice.
  • seed_msg_secrets_from_history: bool (default true) — live capture (covering everything received after connect) is independent from history seeding. Seeding only matters for the boundary case: an add-on that arrives live yet references a parent delivered via history sync. Headless consumers that only react to new messages can flip this off.
  • original_message_resolver (+ msg_secret_resolver_timeout, default 5s) — an app-supplied fallback consulted when an add-on's parent secret is absent from the store (and its LID/PN alternates), bounded by a timeout because it runs in the per-chat receive lane. This is what lets the Disabled policy still decrypt: an app that keeps its own message store can own retention entirely and answer secret lookups on demand. (Send + Sync is dropped on wasm32 so a wasm app can supply a !Send resolver.)

Newsletters arrive as plaintext and never carry a secret_encrypted_message, so there is no newsletter retention class (documented inline to avoid a future dead branch).

Why

The secrets only unlock add-ons that reference a parent by id — secret-encrypted edits, msmsg bot replies, and poll/event edits — all of which are bounded in time. The send path never reads the store, and poll votes/reactions take an app-supplied secret rather than reading it, so the only real consumers are five inbound add-on kinds with finite useful lifetimes. Keeping a secret for every message forever (and re-seeding the entire history on pairing) is therefore pure storage cost with no decrypt benefit beyond the horizon.

The redesign is grounded in verified behavior: the edit window is 1200s (20 min), enforced on both send and receive as an authored-time check (editTs < parentTs + window) — not relative to "now" — so an offline-delivered but validly-authored edit still applies, which is exactly why retention is sized to the ~30-day delivery window rather than the 20-minute authoring window. PollAddOption/EventEdit have no window at all, and edits by other users (newsletter admins) still key the lookup on the original author's secret, so the existing keying already covers them.

Breaking Change

BREAKING CHANGE: CacheConfig.msg_secret_ttl_secs: u64 is removed. Replace it with msg_secret_policy: MsgSecretPolicy (default Managed) plus the additive msg_secret_retention, seed_msg_secrets_from_history (default true), original_message_resolver, and msg_secret_resolver_timeout. The default behavior changes from "seed and keep everything forever" to "seed only the still-relevant slice and prune by event-time horizon".

Migration:

  • To keep the previous behavior exactly, set msg_secret_policy: MsgSecretPolicy::Full.
  • To restore pre-fix(message)!: decrypt secret encrypted edits on receive #665 capture (bot contexts only — including group bot invocations), use MsgSecretPolicy::BotOnly.
  • To persist nothing and own retention in the app, use MsgSecretPolicy::Disabled and register an original_message_resolver.
  • MsgSecretStore::put_msg_secrets is now the required method and carries MsgSecretEntry { expires_at, message_ts }; the scalar put_msg_secret is a never-expiring default wrapper, and get_msg_secret_with_ts has a default returning 0 for the parent time. Custom backends that implemented the scalar should move to put_msg_secrets and honor expires_at (keep the later deadline on conflict, 0 wins) and message_ts (keep the non-zero value).

Existing installs are not mass-deleted: the SQLite migration backfills legacy rows to created_at + 30d, so the prior pairing-burst seed ages out gradually once pruning runs (which now includes Disabled, so switching to it also reclaims legacy rows — they drain as each row hits its backfilled deadline, completing within ~30 days, not all at once on the first sweep).

Tests

New coverage:

  • seed filter: Managed drops text past 30d but keeps recent text and polls within 90d; Full seeds an ancient secret; Disabled seeds nothing; the opt-out flag skips seeding under Managed
  • seeded rows carry an expires_at derived from the parent's messageTimestamp — a prune past msg_ts + 30d removes the row while an earlier prune keeps it
  • edit-processing window: an in-window edit applies, an out-of-window edit is dropped, and an unknown parent timestamp stays permissive; get_msg_secret_with_ts round-trips and keeps the parent ts across a 0-ts redelivery
  • BotOnly keeps a group bot prompt (botMetadata) but skips a plain message; unit cover for is_bot_context / classify_from_flags / policy predicates
  • OriginalMessageResolver: a secret-encrypted edit decrypts via the resolver when the store is empty under Disabled
  • SQLite: conflict keeps the later deadline (0/never wins); prune deletes only passed deadlines

Validation:

  • cargo fmt --all
  • cargo clippy -p wacore -p whatsapp-rust -p whatsapp-rust-sqlite-storage --all-targets -- -D warnings clean
  • cargo test -p wacore -p whatsapp-rust -p whatsapp-rust-sqlite-storage — 819 (wacore lib) + 644 (whatsapp-rust lib) + 31 (sqlite) passing

jlucaso1 added 8 commits May 31, 2026 09:46
…dline

The msg_secrets store dated rows by insert time (created_at), which is
overwritten on every conflict-update and edit re-persist, so no age-based
TTL was sound. Replace it with an absolute per-row expires_at deadline
(0 = never) carried on MsgSecretEntry and computed by the caller from the
parent message's event time plus a per-add-on-kind horizon.

- MsgSecretEntry gains expires_at; merge_msg_secret_expiry keeps the later
  deadline on conflict (0 wins) so redelivery/re-persist never shrinks a window
- put_msg_secret becomes a never-expiring convenience wrapper over put_msg_secrets
- in-memory backend prunes by expires_at instead of created_at
- history-sync records carry the parent messageTimestamp (proto tag 3, added to
  the partial-decode struct) and an is_poll_or_event flag for horizon selection
Single source of truth for the three previously-scattered msg_secret
decisions (capture / seed / prune):

- MsgSecretPolicy { Managed (default), BotOnly, Full, Disabled }
- MsgSecretRetention with verified per-kind horizons: text/edit 30d (offline
  queue can deliver a 20-min-window edit up to 30d late), poll/event 90d
  (PollAddOption/EventEdit have no sender-side window), bot 30d
- classify() + expires_at() compute the per-row deadline from the parent's
  event time; within_seed_horizon() drives the seed-time age filter
- OriginalMessageResolver trait: app-supplied parent-secret fallback that
  makes the Disabled tier workable for a headless core that keeps no messages
- migration adds expires_at column and an (expires_at, device_id) index,
  replacing the created_at index
- legacy rows are backfilled to created_at + 30d so the pre-existing
  pairing-burst seed ages out under the default text horizon once pruning
  runs, rather than living forever or being mass-deleted on the first sweep
- put_msg_secrets writes the per-row deadline and, on conflict, keeps the
  later deadline (0 = never wins), so redelivery/re-persist never shortens
  a window; the scalar put_msg_secret override is dropped in favor of the
  never-expiring trait default
- delete_expired_msg_secrets prunes rows whose non-zero deadline has passed
Replaces the bare msg_secret_ttl_secs knob (which keyed off insert time) with:

- CacheConfig.msg_secret_policy (default Managed), msg_secret_retention, and
  original_message_resolver, re-exported from the crate root
- keepalive prune now runs whenever the policy prunes (Managed/BotOnly) and
  deletes by absolute per-row deadline (cutoff = now), logging the reclaim count

Capture/seed still write the prior never-expire deadline; the real per-row
deadline + seed filter land in the following commits so each step compiles.
Capture, outbound persist, and the edit re-persist now compute expires_at
from the parent's event time and the message's retention class, and honor the
policy:

- persist_msg_secret_bytes / persist_outbound_msg_secret take a RetentionClass,
  skip writes under Disabled, and restrict to bot contexts under BotOnly
- live capture stamps the message's delivery time and classifies poll/event vs
  text (bot chats always bot); the edit re-persist keys the next add-on's
  deadline off the parent kind
- the never-expire scalar put path is gone from these sites
…lines

This is where the pairing-burst bloat is removed. The seed now:

- skips entirely under Disabled, seeds everything (never-expire) under Full
- under Managed/BotOnly, drops any history secret whose parent is already past
  its retention horizon (30d text / 90d poll-event / 30d bot) — those parents
  can no longer receive a decryptable add-on, so seeding them is pure waste
- stamps each seeded row with expires_at from the parent's own messageTimestamp,
  so it ages out by message age rather than seed time
- BotOnly seeds only bot-context secrets (pre-#665 behavior)

For a multi-month history this collapses the ~95k-row burst to the recent,
still-relevant slice.
…miss

When the in-core store and its LID/PN alternate both miss, the edit and msmsg
bot decrypt paths now ask the app-supplied resolver (if configured) for the
parent secret, trying the primary then the alternate sender. This is what makes
the Disabled policy able to decrypt add-ons whose parent the core never kept,
and lets a stateful app own retention via its own message store.

- bounded by a 5s timeout since it runs inside the per-chat receive lane
- preserves the existing graceful-miss behavior: edits surface the raw envelope
  with no NACK, msmsg still NACKs 495 terminally when nothing resolves
- documents that newsletter chats carry no secret (no retention class)
…lver

- seed filter: Managed drops text past 30d but keeps recent text and polls
  within 90d; Full seeds an ancient secret; Disabled seeds nothing
- seeded rows carry an expires_at derived from the parent's messageTimestamp,
  so a prune past msg_ts + 30d removes them while an earlier prune keeps them
- OriginalMessageResolver: a secret-encrypted edit decrypts via the resolver
  when the store is empty under the Disabled policy
- adds create_test_client_with_config to inject a custom CacheConfig
@coderabbitai

coderabbitai Bot commented May 31, 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: 70185534-c273-4ad7-ae88-2945f4a5fec4

📥 Commits

Reviewing files that changed from the base of the PR and between 7525f30 and 4165b9a.

📒 Files selected for processing (5)
  • src/history_sync.rs
  • storages/sqlite-storage/migrations/2026-05-31-000000_msg_secret_expires_at/up.sql
  • wacore/src/history_sync.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/traits.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Per-message retention policies (Managed/BotOnly/Full/Disabled) with classification (bot/poll/text), optional resolver for missing parent secrets, history seeding controls, and edit-aware decryption with re-capture.
  • Chores

    • Storage and backends now persist per-message expiration and parent timestamps, use batch upserts, and prune by per-message deadlines (supports “never expire”).
  • Tests

    • Expanded coverage for policies, seeding/pruning horizons, expiry merge rules, resolver flows, and edit/decryption scenarios.

Walkthrough

Adds policy-driven message-secret retention: policy types, per-row expires_at/message_ts, batch put_msg_secrets API, backend merge semantics (in-memory and sqlite), history-sync seeding, resolver fallback for misses, send/inbound persistence changes, DB migration, and cleanup wiring.

Changes

Message Secret Retention Policy System

Layer / File(s) Summary
Core retention policy definitions
wacore/src/msg_secret.rs, wacore/src/lib.rs
New MsgSecretPolicy, MsgSecretRetention, RetentionClass, classification helpers, expires_at/within_seed_horizon, OriginalMessageResolver, and public msg_secret module.
Store trait and helpers
wacore/src/store/traits.rs
MsgSecretEntry gains expires_at/message_ts; put_msg_secrets becomes required (batch primitive); put_msg_secret is a wrapper; get_msg_secret_with_ts default added; delete_expired_msg_secrets clarified and merge_msg_secret_expiry/merge_msg_secret_message_ts added.
In-memory backend
wacore/src/store/in_memory.rs
put_msg_secrets merges incoming expires_at/message_ts with existing via merge helpers; stores (secret, expires_at, message_ts); get_msg_secret_with_ts added; pruning uses expires_at; tests updated.
SQLite backend & migration
storages/sqlite-storage/src/schema.rs, storages/sqlite-storage/migrations/.../up.sql, storages/sqlite-storage/migrations/.../down.sql, storages/sqlite-storage/src/sqlite_store.rs
Add expires_at and message_ts columns, backfill legacy rows, replace created_at index with device-scoped (device_id, expires_at), implement ON CONFLICT merge (0 wins, else MAX), delete by expires_at, add get_msg_secret_with_ts, and update tests.
Cache configuration & crate exports
src/cache_config.rs, src/lib.rs
Replace msg_secret_ttl_secs with msg_secret_policy, msg_secret_retention, seed_msg_secrets_from_history, optional original_message_resolver, and msg_secret_resolver_timeout; update Debug/Default and re-export new types.
History sync metadata & seeding
wacore/src/history_sync.rs, src/history_sync.rs
Extract message_timestamp, is_poll_or_event, and bot-invocation flags; gate seeding by policy/seed flag; classify records, enforce bot-only, optional horizon-based pruning, compute per-entry expires_at/message_ts, and persist seeded entries; tests added.
Inbound decrypt & capture paths
src/message.rs
Gate capture by policy, classify retention class and message_ts, build retention-aware MsgSecretEntry (expires_at) and batch-persist aliases; on store miss consult OriginalMessageResolver under timeout; re-persist edit secrets with class/message_ts; tests updated.
Send-side persistence
src/send.rs
Classify outbound messages, accept RetentionClass in persist_outbound_msg_secret, gate by policy (including bot_only), compute expires_at/message_ts, and call backend().put_msg_secrets(vec![entry]); tests updated to pass class.
Cleanup scheduler
src/keepalive.rs
Run retention cleanup when msg_secret_policy.prunes() is true; call backend.delete_expired_msg_secrets(now) and log pruned counts when positive; errors logged at debug.
Test helpers & mocks
src/test_utils.rs, src/appstate_sync.rs
create_test_client_with_config added and callers updated; MockBackend test mock updated to implement bulk put_msg_secrets returning entry count; tests adjusted for new APIs and expires_at/message_ts semantics.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: introducing bounded messageSecret retention via policy and event-time horizon instead of unlimited storage.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the problem, solution, and migration path for the breaking change.
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 feat/msg-secret-retention-policy

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.

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

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


Code Review

Solid PR. The design — one MsgSecretPolicy + per-row event-time expires_at — is the right shape for the problem, and the test coverage (seed filter, conflict-merge keeping the later deadline, prune semantics, resolver fallback under Disabled) genuinely exercises the new contract rather than just smoke-testing. The migration's age-out backfill (created_at + 30d) is a thoughtful midpoint between "mass-delete on first sweep" and "never reclaim." Findings below, ordered roughly by severity.


Findings

1. Inconsistent bot-chat detection between call sites (minor bug)
The live-capture and outbound-send paths gate on chat.server == wacore_binary::Server::Bot (src/message.rs:219, src/send.rs:1490), but the history-sync seed path uses chat.is_bot() (src/history_sync.rs:271, :279, :295). JidExt::is_bot() (wacore/binary/src/jid.rs:351) returns true for Server::Bot or for Server::Pn whose user starts with 1313555 / 131655500 (legacy bot numbers).

Consequence: for a chat against one of those legacy bot phone numbers, classification and BotOnly filtering diverge between paths — history seeds as Bot/passes the bot_only filter, but the same chat's live captures classify as Text and get dropped under BotOnly. Same secret store, two different policies depending on who wrote the row. Picking one (is_bot() is the canonical helper) and using it everywhere would close this. Fix this →

2. BotOnly is bypassed on edit re-persist (minor bug)
maybe_capture_inbound_msg_secret short-circuits on policy.bot_only() && !chat_is_bot (src/message.rs:221), but the re-persist site inside handle_secret_encrypted_message calls persist_msg_secret_bytes directly (src/message.rs:505-524), and persist_msg_secret_bytes itself only checks policy.persists(), not bot_only() (src/message.rs:267-270). So under BotOnly, a secret-encrypted edit on a non-bot chat — decrypted via the resolver, since the live capture was dropped — will end up persisting the recaptured secret anyway. Either add the bot_only check inside persist_msg_secret_bytes (single chokepoint) or at the re-persist call site. The blast radius is small but it violates the documented BotOnly invariant.

3. Disabled never prunes legacy rows (minor)
spawn_retention_cleanup only runs the sweep when policy.prunes() is true (src/keepalive.rs:220), and prunes() excludes Disabled (wacore/src/msg_secret.rs:52). A pre-existing install that switches to Disabled (to delegate retention to the app and reduce DB size, which is one of the documented motivations) will keep every legacy row indefinitely — the migration backfilled expires_at = created_at + 30d, but nothing reaps it. Consider either always running the sweep (the cost is one indexed DELETE on a shrinking set) or documenting that Disabled requires the app to issue its own DELETE FROM msg_secrets after switching.

4. Duplicated retention classification (cleanup)
wacore::msg_secret::classify (wacore/src/msg_secret.rs:105) takes a wa::Message and classifies via get_base_message, but store_history_sync_msg_secrets (src/history_sync.rs:271-277) reimplements the same three-way decision against HistoryMsgSecretRecord.is_poll_or_event because it doesn't have a wa::Message in hand. The classification rule is the load-bearing detail here; having two copies invites them drifting (e.g. if a future SchedulerMessage ever joins poll/event). Could move both to a small helper that takes (is_bot_chat: bool, is_poll_or_event: bool).

5. Resolver timeout is a hardcoded 5s (informational)
resolve_msg_secret_via_app (src/message.rs:~839) wraps the resolver in a fixed Duration::from_secs(5). That's a sensible default for not stalling the per-chat lane, but it's now a contract the app implementor has to guess at — they can't see why their callback is silently being timed out. Either surface it as a CacheConfig field or document the bound on the OriginalMessageResolver trait.

6. Schema index column order (informational)
The new idx_msg_secrets_expires (expires_at, device_id) matches the prior (created_at, device_id) for consistency, but the prune query is WHERE expires_at != 0 AND expires_at <= ? AND device_id = ?. For a multi-account DB, (device_id, expires_at) would localize the range scan to one device. Not blocking; the per-device path was already this shape pre-PR.

7. Migration magic number (nit)
UPDATE msg_secrets SET expires_at = created_at + 2592000 (up.sql:11) is decoupled from MsgSecretRetention::text (30 days in Rust). That's fine — migrations are frozen-in-time by design — but a one-line comment naming the source of 2592000 would save the next reader a grep.


Things that work well

  • merge_msg_secret_expiry (wacore/src/store/traits.rs:438) is the single source of truth for the "0 wins, else max" rule, and both the SQLite CASE and the InMemory backend route through the same intent — easy to audit.
  • The put_msg_secret default method on the trait (wacore/src/store/traits.rs:397) keeps the break narrow: existing custom backends only need to implement the batched method.
  • Newsletter exclusion is documented inline (src/message.rs:~876) rather than left as a dead branch — prevents a future reader from "fixing" it.
  • The 5s resolver timeout running inside the per-chat receive lane is the right place to bound it; an app callback that hangs would otherwise wedge the chat.

  • Read PR diff and changed files
  • Analyze wacore/src/msg_secret.rs (policy/horizon core)
  • Analyze src/history_sync.rs and wacore/src/history_sync.rs (seed path)
  • Analyze src/message.rs (live capture + resolver fallback)
  • Analyze src/send.rs (send-time capture)
  • Analyze SQLite store + migration (conflict, prune, backfill)
  • Analyze trait surface, prune sweep, in-memory backend
  • Post review
    · branch feat/msg-secret-retention-policy

@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: 9a54571d55

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

github-actions Bot commented May 31, 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() 3,933 3,933 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 12,038 12,038 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,514 43,514 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 19,365 19,365 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 68,579 68,579 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,679 76,679 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,230 2,230 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,988 5,988 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 183,212 183,231 -0.0%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 2,205,397 2,204,889 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 888,647 888,794 -0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 1,027,949 1,027,869 +0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,761,117 1,761,030 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 1,132,980 1,132,349 +0.1%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 2,133,395 2,139,916 -0.3%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 7,279,499 7,261,980 +0.2%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,568,759 12,462,874 +0.8%
binary_benchmark::marshal_group::bench_marshal_allocating 71,326 71,326 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 71,379 71,379 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 98,446 98,446 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 78,826 78,826 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 71,426 71,426 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 7,593 7,593 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 7,636 7,636 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 9,348 9,348 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 530,583 530,583 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 530,151 530,151 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 531,506 531,506 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 8,506,239 8,506,239 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 8,450,491 8,450,491 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 19,678,026 19,678,026 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 2,468 2,468 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 33,558 33,558 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 787 787 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 526,732 526,732 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 4,986 4,986 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 5,315 5,315 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 61,874 61,874 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 5,347 5,347 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 61,942 61,942 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 6,734 6,734 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 85,585 85,585 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 477,570 477,570 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 11,563 11,563 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 396 396 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 120 120 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 439 439 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 153 153 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 499 499 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 162 162 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 44,624 44,624 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 16,424 16,424 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,294,163 17,333,270 -0.2%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 157,179 157,179 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,513,975 5,513,962 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 157,539 157,539 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 296,631 296,767 -0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 706,282 706,282 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,578,401 12,545,808 +0.3%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,552,956 27,568,010 -0.1%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 3,467,011 3,467,011 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 125,000,613 124,325,433 +0.5%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,830,452 2,830,452 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 46,566 46,566 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 5,197,012 5,197,012 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 360,648 360,648 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,255,917 14,255,917 +0.0%
No significant changes detected.

Live capture (post-connect add-ons) is the high-value, low-cost path and stays
on. Seeding from history only helps the boundary case — an add-on that arrives
live yet references a parent delivered via history sync (edits of just-
pre-pairing messages, add-options on still-open polls, offline-reconnect
replays). Headless consumers that only react to new messages can now skip the
pairing-time seed via CacheConfig.seed_msg_secrets_from_history (default true,
so behavior is unchanged unless opted out).

@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: 3

Caution

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

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

628-679: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't skip the app resolver when alternate store lookup errors.

Right now Line 673 goes straight to nack on alternate-lookup failure. That makes original_message_resolver useless in the exact degraded-store case where it should still save the decrypt, and it diverges from the secret-encrypted edit path above, which falls through to the resolver on store errors.

🤖 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 `@src/message.rs` around lines 628 - 679, The code currently bails with a nack
when alternate_msg_secret_lookup(...) returns Err(e), which prevents
resolve_msg_secret_via_app(...) from being attempted on degraded-store errors;
change the Err branch in the Ok(None) arm so that instead of immediately logging
and calling spawn_nack(info, NackReason::MissingMessageSecret, None) you log the
error (as a warn/debug), then proceed to call alternate_msg_secret_jid(...) and
resolve_msg_secret_via_app(...) exactly like the Ok(None) path does; only if the
resolver returns None should you then log the missing-secret and call
spawn_nack(...). Ensure you reference and reuse the same variables used there
(alternate_msg_secret_jid, resolve_msg_secret_via_app, target_sender,
target_sender_str, target_id, info) so the behavior matches the secret-encrypted
edit path.
🤖 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.

Inline comments:
In `@src/message.rs`:
- Around line 227-249: The code currently calls persist_msg_secret_bytes twice
with separate awaits (in the block using dm_sender_identity_for and the similar
block at 505-523), which can leave only one alias persisted if the second write
fails; change this to build both secret-alias entries (the alias for
info.source.sender and, when chat_is_bot and dm_sender_identity_for(...) yields
a different sender, the alias for that sender) and call a single
put_msg_secrets(...) (or the atomic batch write function) once with both entries
instead of two awaited persist_msg_secret_bytes calls so both aliases are
written in one atomic operation.
- Around line 219-225: The early return is gating on chat_is_bot instead of the
classified retention class; move or change the check so BotOnly consults the
result of wacore::msg_secret::classify(msg, chat_is_bot) (the local variable
class) and only returns when policy.bot_only() && class !=
wacore::msg_secret::RetentionClass::Bot; update the logic around
policy.bot_only(), chat_is_bot, and the classify() call so the retention class
(not chat_is_bot) determines whether to skip storing the secret.

In `@wacore/src/msg_secret.rs`:
- Around line 171-174: Replace the single unconditional trait declaration for
OriginalMessageResolver with two cfg-gated variants: one compiled for wasm32
that uses #[cfg(target_arch = "wasm32")] and #[async_trait(?Send)] and does NOT
require Send + Sync, and a second compiled for non-wasm targets using
#[cfg(not(target_arch = "wasm32"))] and #[async_trait] that retains the Send +
Sync supertraits; keep the async fn resolve_msg_secret signature identical in
both variants so existing callers and CacheConfig::original_message_resolver:
Option<Arc<dyn OriginalMessageResolver>> continue to work.

---

Outside diff comments:
In `@src/message.rs`:
- Around line 628-679: The code currently bails with a nack when
alternate_msg_secret_lookup(...) returns Err(e), which prevents
resolve_msg_secret_via_app(...) from being attempted on degraded-store errors;
change the Err branch in the Ok(None) arm so that instead of immediately logging
and calling spawn_nack(info, NackReason::MissingMessageSecret, None) you log the
error (as a warn/debug), then proceed to call alternate_msg_secret_jid(...) and
resolve_msg_secret_via_app(...) exactly like the Ok(None) path does; only if the
resolver returns None should you then log the missing-secret and call
spawn_nack(...). Ensure you reference and reuse the same variables used there
(alternate_msg_secret_jid, resolve_msg_secret_via_app, target_sender,
target_sender_str, target_id, info) so the behavior matches the secret-encrypted
edit path.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7bda4dc9-4d5b-4394-9dc9-4ec7e668b36d

📥 Commits

Reviewing files that changed from the base of the PR and between 304d332 and 9a54571.

📒 Files selected for processing (17)
  • src/appstate_sync.rs
  • src/cache_config.rs
  • src/history_sync.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/send.rs
  • src/test_utils.rs
  • storages/sqlite-storage/migrations/2026-05-31-000000_msg_secret_expires_at/down.sql
  • storages/sqlite-storage/migrations/2026-05-31-000000_msg_secret_expires_at/up.sql
  • storages/sqlite-storage/src/schema.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/history_sync.rs
  • wacore/src/lib.rs
  • wacore/src/msg_secret.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/traits.rs

Comment thread src/message.rs Outdated
Comment thread src/message.rs Outdated
Comment thread wacore/src/msg_secret.rs Outdated
…r on error

- Unify bot-chat detection on JidExt::is_bot() across capture/send/seed (was a
  mix of is_bot() and == Server::Bot, which diverged for legacy bot phone
  numbers), and classify group bot invocations (botMetadata / bot mention) as
  the Bot class so BotOnly keeps them and the later reply can decrypt.
- Enforce BotOnly at a single chokepoint (build_msg_secret_entry / class != Bot)
  instead of an early return keyed on chat alone, so the edit re-persist path
  can no longer bypass it.
- Centralize the three-way classification (classify_from_flags) so the seed and
  live paths can't drift.
- Write the primary + alias secret rows in one atomic batch on capture and edit
  re-persist, so a partial failure can't leave only one alias stored.
- On the msmsg bot path, treat a store error as a miss and still try the
  OriginalMessageResolver (mirrors the edit path) before nacking.

Tests: BotOnly keeps a group bot prompt but skips a plain message; unit cover
for is_bot_context / classify_from_flags.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

- #3 Disabled now reaps legacy rows: prunes() returns true for everything
  except Full, so switching a pre-existing install to Disabled still ages out
  the backfilled rows. expires_at gates on a new bounds_retention() (Managed/
  BotOnly only) so Full/Disabled keep returning 0.
- #5 resolver timeout is now CacheConfig.msg_secret_resolver_timeout (default
  5s) instead of a hardcoded constant, and the bound is documented on the trait.
- #6 idx_msg_secrets_expires is keyed (device_id, expires_at) so the prune's
  per-device range scan localizes to one account.
- #7 migration comments name 2592000 as 30d (= MsgSecretRetention::text).
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 3m 48s —— View job


Code Review

  • Read PR diff and understand scope
  • Analyze wacore/src/msg_secret.rs (policy/horizon core)
  • Analyze src/message.rs (live capture, resolver fallback, edit re-persist)
  • Analyze src/send.rs (send-time capture)
  • Analyze src/history_sync.rs (seed path)
  • Analyze SQLite store, migration, traits, prune sweep
  • Verify previous review findings against current code
  • Post review

The previous review pass has been substantively addressed in commits 887b3f1..cb8d055: bot-context detection is now consistent across paths (all sites consult JidExt::is_bot()), BotOnly is chokepointed in a single helper, both identity aliases are written in one batched put_msg_secrets, the alternate-lookup error branch now falls through to the resolver, Disabled participates in the prune sweep, the migration's magic number is documented, the SQLite index is reordered to (device_id, expires_at) so the per-device prune scan is localized, and the resolver timeout is configurable via msg_secret_resolver_timeout. The new test coverage exercises the load-bearing invariants (event-time deadline, conflict keeps later/0-wins, seed horizon, resolver under Disabled) — not just smoke-tests.

Remaining findings, ordered by severity:


Findings

1. OriginalMessageResolver: Send + Sync is unconditional, but async_trait is wasm-gated (low — wasm-only, pre-existing pattern)
wacore/src/msg_secret.rs:214-217 declares the trait with #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] yet the supertrait bound is unconditionally Send + Sync. On wasm32 the ?Send relaxation does nothing because the supertrait still requires Send + Sync on the trait object, so a resolver backed by Rc/JS handles cannot be supplied. The repo already has this same inconsistency on MsgSecretStore (wacore/src/store/traits.rs:386), so this PR is faithful to an existing pattern rather than introducing the issue — but since OriginalMessageResolver is the only knob in this PR that an application (not a backend implementor) hands in, it's the one most likely to be tripped over from wasm. Cfg-gating the supertrait the same way async_trait is gated would fix it cleanly. Fix this →

2. Edit re-persist uses the edit's arrival time as the parent timestamp (informational)
src/message.rs:523 reads info.timestamp of the edit and feeds it into expires_at(..., message_ts, ...) as if it were the parent's event time. For the common live-edit case (within ~20 min) this is fine, but the PR body itself notes the offline queue can deliver an edit ~30 days late, in which case the deadline becomes arrival_time + 30dparent_time + 60d — bounded over-retention rather than a bug, but the inline comment at :518 describing this as "within ~20min of the parent, a bounded over-retention" understates it. Either keep the math and update the comment to reflect the ~60d worst case, or carry the parent's event time through the re-persist path (the decrypted inner doesn't have it cheaply, so the comment fix is the lighter touch).

3. Disabled sweep is gated on prunes() returning true, but the sweep itself uses now as cutoff (informational)
src/keepalive.rs:220-235 runs the sweep when policy.prunes() is true (which includes Disabled — good, legacy rows get reclaimed). The cutoff is wacore::time::now_secs(), so an existing install that switches to Disabled reclaims any legacy row whose backfilled deadline (created_at + 30d) has already passed by the time the first sweep runs. That matches the migration's intent and resolves what I'd flagged in the prior review. Worth noting in release notes that switching to Disabled doesn't immediately drain the table — it drains as rows hit their backfilled deadline, with the legacy seed completing within 30 days of the last write under the old behavior.

4. store_history_sync_msg_secrets early-returns 0 silently on the opt-out flag and on Disabled (nit)
src/history_sync.rs:251-258: both early returns skip with no log line. For a user diagnosing "why isn't the seed populating," a one-line debug at startup time (or once-per-sync) saying which path was taken would save a grep. Tiny ergonomics thing.

5. In-memory backend returns entries.len() as the stored count (nit)
wacore/src/store/in_memory.rs:598-608 returns stored = entries.len() before the loop, so conflict-updates and fresh inserts count identically. SQLite does the same (stored += diesel::insert_into(...).execute(...)? counts affected rows including updates). Consistent between backends, so this is fine — flagging only because if a future caller wants to distinguish "new rows" from "updates" for metrics, neither backend supports it.


Things that work well

  • The retention surface is small and tightly designed: one MsgSecretPolicy + one MsgSecretRetention + per-row expires_at, with merge_msg_secret_expiry (wacore/src/store/traits.rs:438) as the single source of truth for the 0-wins/else-max rule. Both SQLite (CASE WHEN ... THEN 0 ELSE MAX(...) END) and InMemory route through the same intent — easy to audit.
  • The migration's age-out backfill (created_at + 30d for legacy rows) is the right midpoint between mass-deletion-on-first-sweep and "live forever," and the inline comment names both the magic number and the source of truth in Rust.
  • put_msg_secret is kept as a never-expiring default wrapper over put_msg_secrets (wacore/src/store/traits.rs:397), so the trait break is narrow — only custom backends that don't use the default need to migrate, and they're explicitly called out in the PR description.
  • is_bot_context correctly treats a group @MetaAI prompt as a bot context via botMetadata, so BotOnly does not break group bot flows — the previous round's correctness issue.
  • The resolver runs inside the per-chat receive lane and is bounded by msg_secret_resolver_timeout (default 5s, configurable), which is the right place to bound it — an app callback that hangs would otherwise wedge the chat.
  • Newsletter exclusion is documented in the design rather than papered over with a dead branch, and the policy paths use is_bot() (which does not include newsletter).

LGTM modulo the trait-bound cfg fix; the rest are nits.
· branch feat/msg-secret-retention-policy

Resolves the WA-Web-fidelity gap surfaced in review: WhatsApp Web's receive
path (ProcessEditProtocolMsgs) drops an edit authored outside the parent's
edit-processing window, and we decrypted/dispatched secret-encrypted edits
unconditionally.

- store the parent message's event time (new message_ts column / MsgSecretEntry
  field, kept across redeliveries via MAX) and expose get_msg_secret_with_ts
- on a MESSAGE_EDIT decrypt, drop it when editTs >= parentTs + 1200s (the
  authored-time check, matching WA Web); permissive when parent_ts is unknown
  (resolver-supplied secrets carry none)
- the edit re-persist now keys the next add-on's deadline off the parent's own
  event time, not the edit's arrival time (was up to ~60d over-retention)
- wasm-relax the OriginalMessageResolver trait (drop Send + Sync on wasm32, the
  way async_trait is already gated) so a wasm app can supply a !Send resolver
- log when the history seed is skipped (opt-out flag or Disabled), and make the
  text-horizon doc precise that the window is an authored-time check on both
  send and receive

Tests: in-window edit applies, out-of-window edit is dropped, unknown parent_ts
stays permissive; get_msg_secret_with_ts round-trips and keeps the parent ts
across a 0-ts redelivery.

@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: 7525f30e34

ℹ️ 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/history_sync.rs Outdated

@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: 3

Caution

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

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

286-304: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

BotOnly seeding still drops historical group bot prompts.

This path only classifies from chat.is_bot() plus record.is_poll_or_event. So in a normal group, a bot prompt parent is treated as text/poll here, and MsgSecretPolicy::BotOnly skips it even though the live path treats that traffic as bot-context. That means a fresh pair can miss parent secrets for historical bot interactions unless the app also provides a resolver. Please thread a bot-context flag through HistoryMsgSecretRecord and classify from that instead.

🤖 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 `@src/history_sync.rs` around lines 286 - 304, The seeding logic uses
msg_secret::classify_from_flags(chat.is_bot(), record.is_poll_or_event) which
misclassifies historical group bot prompts because it relies on chat.is_bot()
instead of whether the message itself was bot-context; update the type
HistoryMsgSecretRecord to carry a bot_context (or similar) boolean, thread that
field through wherever records are created, and replace the call in
history_sync.rs to classify_from_flags(record.bot_context,
record.is_poll_or_event) so MsgSecretPolicy::bot_only() correctly retains
bot-context secrets during seeding.
wacore/src/store/in_memory.rs (1)

599-649: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add parity coverage for message_ts in the reference backend.

This backend is the reference implementation used by a lot of higher-level tests, but the new parent-timestamp round-trip and non-clobber behavior never gets asserted here. Add the same kind of get_msg_secret_with_ts + 0-ts redelivery check you added for SQLite so edit-window regressions do not hide behind the in-memory backend.

🤖 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/store/in_memory.rs` around lines 599 - 649, The in-memory backend
must match SQLite's behavior for parent-timestamp round-trips and redelivery:
ensure put_msg_secrets merges message_ts using the later non-zero value (use the
max as already intended) and never overwrite a known non-zero existing timestamp
with a zero from an incoming MsgSecretEntry; rely on merge_msg_secret_expiry for
expiry merging and keep inserting (secret, expires_at, message_ts). Also make
get_msg_secret delegate to get_msg_secret_with_ts (or at least return the same
secret logic) so callers that rely on get_msg_secret_with_ts/0-ts redelivery
behavior see identical results; update put_msg_secrets, get_msg_secret, and
get_msg_secret_with_ts accordingly.
🤖 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.

Inline comments:
In `@src/send.rs`:
- Around line 1495-1511: The outbound secret uses a wall-clock time taken after
send_node() finishes; capture the outbound event timestamp earlier (e.g. call
wacore::time::now_secs() once in send_message_impl before invoking send_node())
and thread that value through to where MsgSecretEntry is built so message_ts and
the expires_at calculation use the pre-send timestamp instead of the post-send
now; update the send_message_impl signature (and its callers) to accept an
outbound_message_ts and replace the local now usage in the MsgSecretEntry
construction and in the expires_at call so both fields are deterministically
stamped with the pre-send time.

In
`@storages/sqlite-storage/migrations/2026-05-31-000000_msg_secret_expires_at/up.sql`:
- Around line 6-8: Update the migration comment to state the correct inequality
and variable names used by the receive path: instead of saying "stale edit via
editTs < message_ts + window" mention that a stale edit is dropped when "editTs
>= parentTs + window" (use parentTs to match receive logic) and clarify that
message_ts (the new column on msg_secrets) stores the parent timestamp used in
that comparison; keep the rest of the comment intact.

In `@wacore/src/store/traits.rs`:
- Around line 421-425: The doc for put_msg_secrets is ambiguous about which
non-zero message_ts wins on key conflict; make the merge deterministic by
specifying that when both existing and incoming message_ts are non-zero the
later (max) timestamp wins (i.e., preserve the newest message_ts), and update
the contract comment accordingly; alternatively expose a small helper function
(e.g., merge_msg_secret_message_ts) alongside merge_msg_secret_expiry and
implement backends to call that helper so all implementations use identical
merge rules.

---

Outside diff comments:
In `@src/history_sync.rs`:
- Around line 286-304: The seeding logic uses
msg_secret::classify_from_flags(chat.is_bot(), record.is_poll_or_event) which
misclassifies historical group bot prompts because it relies on chat.is_bot()
instead of whether the message itself was bot-context; update the type
HistoryMsgSecretRecord to carry a bot_context (or similar) boolean, thread that
field through wherever records are created, and replace the call in
history_sync.rs to classify_from_flags(record.bot_context,
record.is_poll_or_event) so MsgSecretPolicy::bot_only() correctly retains
bot-context secrets during seeding.

In `@wacore/src/store/in_memory.rs`:
- Around line 599-649: The in-memory backend must match SQLite's behavior for
parent-timestamp round-trips and redelivery: ensure put_msg_secrets merges
message_ts using the later non-zero value (use the max as already intended) and
never overwrite a known non-zero existing timestamp with a zero from an incoming
MsgSecretEntry; rely on merge_msg_secret_expiry for expiry merging and keep
inserting (secret, expires_at, message_ts). Also make get_msg_secret delegate to
get_msg_secret_with_ts (or at least return the same secret logic) so callers
that rely on get_msg_secret_with_ts/0-ts redelivery behavior see identical
results; update put_msg_secrets, get_msg_secret, and get_msg_secret_with_ts
accordingly.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 323f53f2-2408-4d1b-8e06-f0ddf9a371ef

📥 Commits

Reviewing files that changed from the base of the PR and between cb8d055 and 7525f30.

📒 Files selected for processing (10)
  • src/history_sync.rs
  • src/message.rs
  • src/send.rs
  • storages/sqlite-storage/migrations/2026-05-31-000000_msg_secret_expires_at/down.sql
  • storages/sqlite-storage/migrations/2026-05-31-000000_msg_secret_expires_at/up.sql
  • storages/sqlite-storage/src/schema.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/msg_secret.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/traits.rs

Comment thread src/send.rs
Comment thread storages/sqlite-storage/migrations/2026-05-31-000000_msg_secret_expires_at/up.sql Outdated
Comment thread wacore/src/store/traits.rs Outdated
- BotOnly now retains a historical group bot prompt: the partial history-sync
  decode picks up botMetadata (tag 7), so the seed classifies it as a bot
  context (record.is_bot_invocation) the same way live capture does. Without
  this a later msmsg bot reply to that prompt nacked as MissingMessageSecret.
- add merge_msg_secret_message_ts alongside merge_msg_secret_expiry so both
  backends share one rule (later non-zero parent ts wins; 0 never clobbers);
  in-memory uses it and get_msg_secret now delegates to get_msg_secret_with_ts.
- migration comment states the actual drop condition (editTs >= parentTs +
  window) and that message_ts holds parentTs.

Test: BotOnly seeds a group bot prompt (botMetadata) but skips a plain message.

Skipped the outbound pre-send-timestamp suggestion: post-send vs pre-send differ
by sub-second/seconds on 1200s/30d horizons and only ever err permissive, so it
doesn't justify threading a timestamp through send_message_impl + all callers.

@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/history_sync.rs (1)

561-568: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Carry parent poll/event retention explicitly.

This metadata contract is too weak for the 90-day add-on rule. PollAddOption and EventEdit do not become is_poll_or_event = true here, so the history-seed path downstream will classify them like plain text and skip/prune them after 30 days under Managed/BotOnly. Thread an explicit retention class or parent-retention marker through HistoryMsgSecretRecord instead of only the current-message boolean.

Also applies to: 638-658

🤖 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/history_sync.rs` around lines 561 - 568, The current boolean field
is_poll_or_event on the message metadata is insufficient because add-on messages
like PollAddOption and EventEdit inherit the parent's 90-day retention but are
not marked true; update HistoryMsgSecretRecord to carry an explicit
parent_retention_class (or parent_is_poll_or_event) field and thread it through
the code paths that construct/transform HistoryMsgSecretRecord so downstream
history-seed logic can consult this parent marker; update any places referencing
is_poll_or_event (including creation sites that see PollAddOption, EventEdit,
and the code paths around functions that build/serialize HistoryMsgSecretRecord)
to set the new parent_retention_class appropriately so PollAddOption/EventEdit
get the longer retention.
🤖 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.

Inline comments:
In `@wacore/src/store/traits.rs`:
- Around line 421-425: The docs leave secret bytes merge unspecified, causing
backends to diverge (InMemoryBackend currently replaces secrets). Add a
deterministic secret-merge rule: the stored secret must follow the same
selection as message_ts (use the secret from the row chosen by
merge_msg_secret_message_ts — i.e., the row with the later non-zero message_ts),
and if message_ts ties or both are zero, pick the secret from the row with the
later expires_at; if expires_at also ties, prefer the incoming row's secret.
Update the merge implementation functions (merge_msg_secret_expiry,
merge_msg_secret_message_ts) to apply this rule and change InMemoryBackend
upsert/merge logic to follow it so all backends behave identically.

---

Outside diff comments:
In `@wacore/src/history_sync.rs`:
- Around line 561-568: The current boolean field is_poll_or_event on the message
metadata is insufficient because add-on messages like PollAddOption and
EventEdit inherit the parent's 90-day retention but are not marked true; update
HistoryMsgSecretRecord to carry an explicit parent_retention_class (or
parent_is_poll_or_event) field and thread it through the code paths that
construct/transform HistoryMsgSecretRecord so downstream history-seed logic can
consult this parent marker; update any places referencing is_poll_or_event
(including creation sites that see PollAddOption, EventEdit, and the code paths
around functions that build/serialize HistoryMsgSecretRecord) to set the new
parent_retention_class appropriately so PollAddOption/EventEdit get the longer
retention.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b518102e-4f10-4718-b65d-32ec8ca223c5

📥 Commits

Reviewing files that changed from the base of the PR and between 7525f30 and 4165b9a.

📒 Files selected for processing (5)
  • src/history_sync.rs
  • storages/sqlite-storage/migrations/2026-05-31-000000_msg_secret_expires_at/up.sql
  • wacore/src/history_sync.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/traits.rs

Comment thread wacore/src/store/traits.rs
@jlucaso1
jlucaso1 merged commit 7183412 into main May 31, 2026
13 checks passed
@jlucaso1
jlucaso1 deleted the feat/msg-secret-retention-policy branch May 31, 2026 17:20
jlucaso1 added a commit to zdanysfa/whatsapp-rust that referenced this pull request May 31, 2026
Resolve src/message.rs: keep the envelope-frame fix and the oxidezap#667 dispatch
regression test on top of main's oxidezap#668 msg-secret retention rewrite of
maybe_decrypt_secret_encrypted_message (get_msg_secret_with_ts + edit window)
and its window/resolver tests.
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