fix(unread): expose removed-group-suppressed account unread total (#573)#575
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (9)
🚧 Files skipped from review as they are similar to previous changes (8)
WalkthroughAdds a ChangesSelf-membership unread suppression
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
|
Ready to review this PR? Stage has broken it down into 6 individual chapters for you: Chapters generated by Stage for commit 4bd908d on Jun 24, 2026 1:04am UTC. |
|
Adversarial review: changes requested. Blocking findings: 1 Blocking:
Suggested fix: factor the self-membership projection into a helper that is applied to every Verification run locally:
|
c7e5af7 to
3300e60
Compare
|
Addressed the blocking review finding (local self-departure paths did not update the new Fix: Regression test: added Rebased onto current master and squashed to a single commit. Local verification (CI-exact commands): |
erskingardner
left a comment
There was a problem hiding this comment.
Adversarial review: changes requested.
I found two correctness issues around the new self_membership projection. The forward-looking removal paths are covered well and the targeted tests passed locally, but the migration/backfill story still leaves pre-existing affected users wrong, and the core membership projection writes are currently allowed to fail silently.
Local verification:
cargo test -p storage-sqlite account_unread_total_ --locked— 7 passedcargo test -p storage-sqlite account_group_self_membership_migration_defaults_existing_rows_to_member --locked— 1 passedcargo test -p marmot-app self_removal_suppresses_account_unread_while_peer_removal_preserves_it --locked— 1 passedcargo test -p marmot-app local_leave_suppresses_account_unread_total --locked— 1 passed
3300e60 to
7d7bb03
Compare
Expose a removed-group-suppressed account unread projection from Marmot so account_unread_summary() / the underlying account_unread_total already excludes groups where the local account has left or been removed, without loading per-row rosters. Android can retire its re-derived suppression for background-account unread dots (darkmatter-android#672, matching the active account #625 semantics). - Migration 0018 adds account_groups.self_membership TEXT NOT NULL DEFAULT 'member'; uncertainty defaults to preserving (never suppresses). - account_unread_total LEFT JOINs account_groups and excludes rows where self_membership = 'removed'; COALESCE preserves unknown/unmatched rows. - The sync membership-change path flips self_membership to 'removed' only when the removed/left subject is the local account (case-insensitive id compare), and re-affirms 'member' on GroupJoined/GroupCreated so a re-add un-suppresses. save_account_projection_state leaves the flag untouched. - A locally initiated leave/decline departs the group without an inbound sync echo, so leave_group_with_audit_context() also suppresses directly (covers leave_group and decline_group_invite). Address adversarial review (erskingardner) blocking findings: 1. Upgrade backfill gap. The SQLite migration cannot see decrypted MLS roster state, so defaulting pre-0018 rows to 'member' would leave groups the account already left/was removed from before upgrading inflating the aggregate forever (no future removal event to flip the flag). Add a one-time open/upgrade backfill (AppClient::backfill_self_membership_once, run from client open) that derives self_membership from the engine roster (runtime.members, the authoritative Marmot post-merge member set): rows still defaulting to 'member' whose roster no longer contains the local account flip to 'removed'. Engine errors are skipped (uncertainty never suppresses) and the work is gated by a once-only account-import marker, so the hot path stays projection-only. 2. Silent error swallowing. The self_membership projection write is the source of truth for the aggregate, so propagate its error (matching nearby timeline/message projection writes) in both sync.rs (remote self-removal + re-add) and client/mod.rs (local leave) instead of `let _ =`-swallowing it; a failed update no longer reports success with a stale badge. Regression coverage: storage-sqlite cases (suppress/preserve/re-join/ orphan-row/projection-resave, backfill-candidate selection, error propagation) plus a pure local_account_removed_from_roster decision with unit tests, an open-path backfill no-op/idempotency integration test, and the existing self-removal/peer-removal/local-leave relay_runtime tests. Closes #573
7d7bb03 to
4bd908d
Compare
Closes marmot-protocol/mdk#461
Summary
Exposes a removed-group-suppressed account unread projection from Marmot, so
accountUnreadSummary()/ the underlyingaccount_unread_totalalready excludes groups where the local account has left or been removed — without loading per-row rosters on the hot path. This lets Android retire its re-derived suppression inrefreshEffectiveAccountUnreadCount(...)(the darkmatter-android#672 stopgap) and go back to consuming the authoritative summary for background-account unread dots, matching the active-account #625 semantics.Design
Persisted self-membership flag on the cheap projection (Approach A):
account_groups.self_membership TEXT NOT NULL DEFAULT 'member'. The default preserves every existing row and every still-joined group — uncertainty never suppresses, only an observed self-removal flips it to'removed'.account_unread_totalnowLEFT JOINsaccount_groupsand excludes rows whereself_membership = 'removed'.COALESCE(ag.self_membership, 'member')preserves unknown/unmatched rows (defensive for the transient FK-cascade window).observe_account_device_effects) flipsself_membershipto'removed'only when the removed/left subject is the local account (MLS member ids are the Nostr account pubkey hex here; compared case-insensitively). A peer removal never touches our own flag.GroupJoined/GroupCreatedre-affirm'member'so a re-add after removal un-suppresses.save_account_projection_statealready omitsself_membershipfrom its UPSERT, so routine metadata re-projection never clobbers the sync-owned flag (proven by a regression test).Acceptance criteria
'member'/COALESCE).Verification (CI's exact commands, run locally)
cargo fmt --all --check— cleanRUSTFLAGS='-D warnings' cargo check --workspace --all-targets --locked(and--all-features) — passcargo clippy --workspace --all-targets --locked -- -D warnings— passcargo test -p storage-sqlite --locked— 137 passed (incl. suppress/preserve/re-join/orphan-row/projection-resave + migration default test)cargo test -p marmot-app --locked— 243 + 11 + 57 passed (incl. the newself_removal_suppresses_account_unread_while_peer_removal_preserves_itrelay_runtime integration test, stress-verified 8× under serial--test-threads=1)Sensitive paths
crates/storage-sqlite— schema migration 0018 (additive, safe default),account_unread_totalquery, newset_group_self_membershipstorage method.crates/marmot-app— sync membership-change path.No account/group/member ids, pubkeys, or payloads are logged in any added code (observability invariant preserved).
Summary by CodeRabbit