From e88ec0a5d5f9e564e38dca9a2fcec0796662b3cb Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Wed, 15 Jul 2026 12:47:05 +0100 Subject: [PATCH 1/3] Honor group-admin moderation deletes in timeline ingest --- crates/marmot-app/src/client/mod.rs | 4 +- crates/marmot-app/src/client/projection.rs | 30 ++++- crates/marmot-app/src/client/sync.rs | 8 +- crates/marmot-app/src/conversions.rs | 4 + crates/marmot-app/src/groups.rs | 103 +++++++++++++++++ crates/marmot-app/src/lib.rs | 5 + crates/marmot-app/src/projection/tests.rs | 1 + crates/marmot-app/src/tests.rs | 4 + .../src/account_projection/tests.rs | 5 + crates/storage-sqlite/src/chat_list/tests.rs | 2 + crates/storage-sqlite/src/migrations.rs | 7 ++ .../0027_app_event_moderation_grant.rs | 12 ++ crates/storage-sqlite/src/timeline.rs | 30 +++-- crates/storage-sqlite/src/timeline/tests.rs | 104 ++++++++++++++++++ 14 files changed, 305 insertions(+), 14 deletions(-) create mode 100644 crates/storage-sqlite/src/migrations/0027_app_event_moderation_grant.rs diff --git a/crates/marmot-app/src/client/mod.rs b/crates/marmot-app/src/client/mod.rs index bcf89468..d7ffdcbe 100644 --- a/crates/marmot-app/src/client/mod.rs +++ b/crates/marmot-app/src/client/mod.rs @@ -1120,7 +1120,7 @@ impl AppClient { let should_project_locally = !notifications::is_push_gossip_kind(event.kind); if should_project_locally { let update = self.record_local_app_event_projection( - &group_id_hex, + group_id, &sender, &event, None, @@ -1187,7 +1187,7 @@ impl AppClient { .map(|report| hex::encode(report.message_id.as_slice())); if should_project_locally { let update = self.record_local_app_event_projection( - &group_id_hex, + group_id, &sender, &event, source_message_id_hex, diff --git a/crates/marmot-app/src/client/projection.rs b/crates/marmot-app/src/client/projection.rs index 21bbbfb1..07b7bd46 100644 --- a/crates/marmot-app/src/client/projection.rs +++ b/crates/marmot-app/src/client/projection.rs @@ -4,7 +4,9 @@ use cgka_traits::app_components::{ GROUP_BLOSSOM_IMAGE_COMPONENT_ID, GROUP_ENCRYPTED_MEDIA_COMPONENT_ID, GROUP_MESSAGE_RETENTION_COMPONENT_ID, NOSTR_ROUTING_COMPONENT_ID, }; -use cgka_traits::app_event::{MARMOT_APP_EVENT_KIND_CHAT, MarmotAppEvent as MarmotInnerEvent}; +use cgka_traits::app_event::{ + MARMOT_APP_EVENT_KIND_CHAT, MARMOT_APP_EVENT_KIND_DELETE, MarmotAppEvent as MarmotInnerEvent, +}; use crate::groups::{EventGroupProjection, GroupConfirmationProjection, add_group}; use crate::{ @@ -23,18 +25,24 @@ impl AppClient { /// post-publish success projection advances it. pub(crate) fn record_local_app_event_projection( &self, - group_id_hex: &str, + group_id: &GroupId, sender: &str, event: &MarmotInnerEvent, source_message_id_hex: Option, source_epoch: Option, advance_read_marker: bool, ) -> Result { + let group_id_hex = hex::encode(group_id.as_slice()); + // Stamped on the sender's own store too, so a moderation delete of + // another member's message survives local reprojection instead of + // resurrecting the target. + let moderation_grant = event.kind == MARMOT_APP_EVENT_KIND_DELETE + && self.delete_moderation_grant(group_id, sender); let message_projection = AppMessageProjection { message_id_hex: event.id.clone(), source_message_id_hex, direction: "sent".to_owned(), - group_id_hex: group_id_hex.to_owned(), + group_id_hex: group_id_hex.clone(), sender: sender.to_owned(), plaintext: event.content.clone(), kind: event.kind, @@ -44,6 +52,7 @@ impl AppClient { // Only synthesized kind-1210 system rows carry an origin commit; // ordinary sent app events do not. origin_commit_id: None, + moderation_grant, }; let update = self .app @@ -51,7 +60,7 @@ impl AppClient { if advance_read_marker && event.kind == MARMOT_APP_EVENT_KIND_CHAT { let read_marker = self.app - .mark_timeline_message_read(&self.state.label, group_id_hex, &event.id); + .mark_timeline_message_read(&self.state.label, &group_id_hex, &event.id); if let Err(err) = read_marker { let error_code = read_marker_error_code(&err); tracing::warn!( @@ -65,6 +74,18 @@ impl AppClient { Ok(update) } + /// Fail-closed: a group or admin-policy lookup failure yields no grant, so + /// the delete degrades to self-retraction semantics. + pub(crate) fn delete_moderation_grant(&self, group_id: &GroupId, sender_hex: &str) -> bool { + let Ok(group) = self.runtime.group_record(group_id) else { + return false; + }; + let Ok(admins) = self.runtime.admin_pubkeys(group_id) else { + return false; + }; + crate::groups::delete_moderation_grant(&group, &admins, sender_hex) + } + pub(crate) fn state_group_record(&self, group_id: &GroupId) -> Option { let group_id_hex = hex::encode(group_id.as_slice()); self.state @@ -342,6 +363,7 @@ fn build_group_system_projection( tags: material.tags, source_epoch: Some(epoch), recorded_at: Some(recorded_at), + moderation_grant: false, // Non-unique link to the origin commit so a losing-branch rollback can // invalidate every row this commit synthesized (1:N). origin_commit_id, diff --git a/crates/marmot-app/src/client/sync.rs b/crates/marmot-app/src/client/sync.rs index 1217682f..57499338 100644 --- a/crates/marmot-app/src/client/sync.rs +++ b/crates/marmot-app/src/client/sync.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use cgka_traits::TransportAdapter; -use cgka_traits::app_event::MARMOT_APP_EVENT_KIND_CHAT; +use cgka_traits::app_event::{MARMOT_APP_EVENT_KIND_CHAT, MARMOT_APP_EVENT_KIND_DELETE}; use cgka_traits::ingest::{IngestOutcome, StaleReason}; use storage_sqlite::clamp_to_max_future_skew; use tokio::time::timeout; @@ -493,6 +493,11 @@ impl AppClient { ); } self.app.remember_directory_message_sender(&message)?; + // Evaluated against the signed MLS group state while the + // delete's epoch context is live, then persisted with the + // event so later admin-set changes cannot flip the verdict. + let moderation_grant = message.kind == MARMOT_APP_EVENT_KIND_DELETE + && self.delete_moderation_grant(&message.group_id, &message.sender); let message_projection = AppMessageProjection { message_id_hex: message.message_id_hex.clone(), source_message_id_hex: Some(message.source_message_id_hex.clone()), @@ -506,6 +511,7 @@ impl AppClient { recorded_at: Some(source_recorded_at), // Received app messages are not synthesized system rows. origin_commit_id: None, + moderation_grant, }; let projection_update = self .app diff --git a/crates/marmot-app/src/conversions.rs b/crates/marmot-app/src/conversions.rs index 24da8b5f..e17bb1e9 100644 --- a/crates/marmot-app/src/conversions.rs +++ b/crates/marmot-app/src/conversions.rs @@ -240,6 +240,7 @@ pub(crate) fn stored_app_event_from_projection( recorded_at: message.recorded_at.unwrap_or(received_at), received_at, origin_commit_id: message.origin_commit_id.clone(), + moderation_grant: message.moderation_grant, } } @@ -257,6 +258,9 @@ pub(crate) fn stored_app_event_from_message_record(record: &AppMessageRecord) -> recorded_at: record.recorded_at, received_at: record.received_at, origin_commit_id: None, + // Legacy-import records predate moderation deletes, so none carries a + // grant. + moderation_grant: false, } } diff --git a/crates/marmot-app/src/groups.rs b/crates/marmot-app/src/groups.rs index d69ca857..91c2d483 100644 --- a/crates/marmot-app/src/groups.rs +++ b/crates/marmot-app/src/groups.rs @@ -836,6 +836,109 @@ pub(crate) enum GroupConfirmationProjection { }, } +/// Whether a delete may tombstone other members' messages: its authenticated +/// sender must be in the group's current admin set, and the group must not be +/// a direct (two-member, unnamed) conversation — direct chats stay +/// self-retraction only, so neither peer can moderate the other. +pub(crate) fn delete_moderation_grant( + group: &Group, + admins: &[[u8; 32]], + sender_hex: &str, +) -> bool { + let direct = group.members.len() == 2 && group.name.trim().is_empty(); + !direct && admins.iter().any(|admin| hex::encode(admin) == sender_hex) +} + +#[cfg(test)] +mod delete_moderation_grant_tests { + use super::*; + use cgka_traits::group::Member; + use cgka_traits::types::{EpochId, MemberId}; + + fn group_with(name: &str, member_count: usize) -> Group { + Group { + id: GroupId::new(vec![1u8; 16]), + name: name.to_owned(), + description: String::new(), + epoch: EpochId(3), + members: (0..member_count) + .map(|index| Member { + id: MemberId::new(vec![index as u8; 32]), + credential: Vec::new(), + }) + .collect(), + required_capabilities: Default::default(), + removed: false, + join_epoch: EpochId(0), + } + } + + const ADMIN: [u8; 32] = [7u8; 32]; + + #[test] + fn admin_in_named_group_gets_grant() { + let group = group_with("ops", 3); + assert!(delete_moderation_grant( + &group, + &[ADMIN], + &hex::encode(ADMIN) + )); + } + + #[test] + fn non_admin_sender_gets_no_grant() { + let group = group_with("ops", 3); + assert!(!delete_moderation_grant( + &group, + &[ADMIN], + &hex::encode([9u8; 32]) + )); + } + + #[test] + fn direct_conversation_never_grants_even_to_admin() { + let group = group_with("", 2); + assert!(!delete_moderation_grant( + &group, + &[ADMIN], + &hex::encode(ADMIN) + )); + // A whitespace-only name is still an unnamed direct conversation. + let group = group_with(" ", 2); + assert!(!delete_moderation_grant( + &group, + &[ADMIN], + &hex::encode(ADMIN) + )); + } + + #[test] + fn two_member_named_group_is_not_direct() { + let group = group_with("pair", 2); + assert!(delete_moderation_grant( + &group, + &[ADMIN], + &hex::encode(ADMIN) + )); + } + + #[test] + fn unnamed_larger_group_is_not_direct() { + let group = group_with("", 3); + assert!(delete_moderation_grant( + &group, + &[ADMIN], + &hex::encode(ADMIN) + )); + } + + #[test] + fn empty_admin_set_grants_nothing() { + let group = group_with("ops", 3); + assert!(!delete_moderation_grant(&group, &[], &hex::encode(ADMIN))); + } +} + /// Strictly decode the inner Marmot app event from MLS plaintext and bind it to /// the MLS-authenticated sender. Returns `None` (rejecting the message) when the /// canonical id does not match or the inner `pubkey` is not the authenticated diff --git a/crates/marmot-app/src/lib.rs b/crates/marmot-app/src/lib.rs index 6ecbe509..d0d781f7 100644 --- a/crates/marmot-app/src/lib.rs +++ b/crates/marmot-app/src/lib.rs @@ -779,6 +779,11 @@ pub(crate) struct AppMessageProjection { /// system row, so the row can be invalidated by origin commit if that commit /// loses a fork. `None` for all other projections. pub(crate) origin_commit_id: Option, + /// True only for a delete whose authenticated sender may moderate other + /// members' messages (group admin, non-direct group), evaluated against + /// the signed MLS group state when the delete is recorded and persisted + /// with the event. `false` for every other projection. + pub(crate) moderation_grant: bool, } fn generate_telemetry_install_id() -> String { diff --git a/crates/marmot-app/src/projection/tests.rs b/crates/marmot-app/src/projection/tests.rs index 6eac8ffa..a4563a5e 100644 --- a/crates/marmot-app/src/projection/tests.rs +++ b/crates/marmot-app/src/projection/tests.rs @@ -276,6 +276,7 @@ fn prune_group_messages_before_removes_only_expired_group_rows() { source_epoch: None, recorded_at: Some(recorded_at), origin_commit_id: None, + moderation_grant: false, }) .unwrap(); } diff --git a/crates/marmot-app/src/tests.rs b/crates/marmot-app/src/tests.rs index 9c42336c..1767c313 100644 --- a/crates/marmot-app/src/tests.rs +++ b/crates/marmot-app/src/tests.rs @@ -1215,6 +1215,7 @@ fn legacy_account_projection_imports_once_into_account_storage() { source_epoch: None, recorded_at: Some(1_700_000_101), origin_commit_id: None, + moderation_grant: false, }) .unwrap(); legacy @@ -1280,6 +1281,7 @@ fn legacy_account_projection_imports_once_into_account_storage() { source_epoch: None, recorded_at: Some(1_700_000_102), origin_commit_id: None, + moderation_grant: false, }) .unwrap(); assert_eq!(app.messages("alice").unwrap().len(), 1); @@ -2286,6 +2288,7 @@ fn secure_prune_account_app_events_before_returns_media_hashes_above_storage_lay source_epoch: None, recorded_at: Some(10), origin_commit_id: None, + moderation_grant: false, }, ) .unwrap(); @@ -2358,6 +2361,7 @@ fn group_state_invalidated_event_tombstones_origin_commit_system_rows() { source_epoch: Some(2), recorded_at: Some(10), origin_commit_id, + moderation_grant: false, }; // The losing commit synthesized this row (the "B renamed the group" lie). app.record_account_app_event( diff --git a/crates/storage-sqlite/src/account_projection/tests.rs b/crates/storage-sqlite/src/account_projection/tests.rs index 0a34ec0d..5ede7506 100644 --- a/crates/storage-sqlite/src/account_projection/tests.rs +++ b/crates/storage-sqlite/src/account_projection/tests.rs @@ -60,6 +60,7 @@ fn app_event(id: &str, group_id_hex: &str, at: u64) -> StoredAppEvent { recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } @@ -82,6 +83,7 @@ fn agent_stream_start_event( recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } @@ -102,6 +104,7 @@ fn reply_event(id: &str, group_id_hex: &str, target: &str, at: u64) -> StoredApp recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } @@ -119,6 +122,7 @@ fn reaction_event(id: &str, group_id_hex: &str, target: &str, at: u64) -> Stored recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } @@ -142,6 +146,7 @@ fn delete_event( recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } diff --git a/crates/storage-sqlite/src/chat_list/tests.rs b/crates/storage-sqlite/src/chat_list/tests.rs index f1cda032..bf3bf595 100644 --- a/crates/storage-sqlite/src/chat_list/tests.rs +++ b/crates/storage-sqlite/src/chat_list/tests.rs @@ -61,6 +61,7 @@ fn chat_with_tags( recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } @@ -95,6 +96,7 @@ fn reaction(id: &str, sender: &str, target: &str, at: u64) -> StoredAppEvent { recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } diff --git a/crates/storage-sqlite/src/migrations.rs b/crates/storage-sqlite/src/migrations.rs index 6aa2d9fb..f3f0b4e5 100644 --- a/crates/storage-sqlite/src/migrations.rs +++ b/crates/storage-sqlite/src/migrations.rs @@ -50,6 +50,8 @@ mod migration_0024_pending_welcome_delivery; mod migration_0025_chat_notification_settings; #[path = "migrations/0026_message_drafts.rs"] mod migration_0026_message_drafts; +#[path = "migrations/0027_app_event_moderation_grant.rs"] +mod migration_0027_app_event_moderation_grant; use crate::SqliteResultExt; use cgka_traits::storage::{StorageError, StorageResult}; @@ -192,6 +194,11 @@ const MIGRATIONS: &[Migration] = &[ name: "0026_message_drafts", apply: migration_0026_message_drafts::apply, }, + Migration { + version: 27, + name: "0027_app_event_moderation_grant", + apply: migration_0027_app_event_moderation_grant::apply, + }, ]; pub(crate) fn run_all(connection: &mut Connection) -> StorageResult<()> { diff --git a/crates/storage-sqlite/src/migrations/0027_app_event_moderation_grant.rs b/crates/storage-sqlite/src/migrations/0027_app_event_moderation_grant.rs new file mode 100644 index 00000000..611c2645 --- /dev/null +++ b/crates/storage-sqlite/src/migrations/0027_app_event_moderation_grant.rs @@ -0,0 +1,12 @@ +use crate::SqliteResultExt; +use cgka_traits::storage::StorageResult; +use rusqlite::Transaction; + +pub(crate) fn apply(tx: &Transaction<'_>) -> StorageResult<()> { + tx.execute_batch( + r#" +ALTER TABLE app_events ADD COLUMN moderation_grant INTEGER NOT NULL DEFAULT 0; +"#, + ) + .storage() +} diff --git a/crates/storage-sqlite/src/timeline.rs b/crates/storage-sqlite/src/timeline.rs index e7801b41..95e3571e 100644 --- a/crates/storage-sqlite/src/timeline.rs +++ b/crates/storage-sqlite/src/timeline.rs @@ -55,6 +55,13 @@ pub struct StoredAppEvent { /// can produce many rows. Enables `invalidate_app_events_by_origin_commit` /// when that commit loses a fork. `None` for all other event kinds. pub origin_commit_id: Option, + /// True only for a delete whose authenticated sender held group-admin + /// moderation authority in a non-direct group when the delete was + /// recorded. The verdict is frozen at the event's first record — an upsert + /// keeps the stored value — so reprojection and relay echoes arriving + /// after an admin-set change can never flip an honored tombstone. `false` + /// for every other event. + pub moderation_grant: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -227,6 +234,7 @@ struct RawAppEvent { received_at: u64, invalidated: bool, invalidation_reason: Option, + moderation_grant: bool, } #[derive(Clone, Debug)] @@ -328,9 +336,10 @@ impl SqliteAccountStorage { conn.execute( "INSERT INTO app_events ( group_id_hex, message_id_hex, source_message_id_hex, source_epoch, direction, sender, - plaintext, kind, tags_json, recorded_at, received_at, origin_commit_id + plaintext, kind, tags_json, recorded_at, received_at, origin_commit_id, + moderation_grant ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) ON CONFLICT(group_id_hex, message_id_hex) DO UPDATE SET source_message_id_hex = excluded.source_message_id_hex, source_epoch = excluded.source_epoch, @@ -342,6 +351,7 @@ impl SqliteAccountStorage { recorded_at = excluded.recorded_at, received_at = excluded.received_at, origin_commit_id = COALESCE(excluded.origin_commit_id, app_events.origin_commit_id), + moderation_grant = app_events.moderation_grant, invalidated = 0, invalidation_reason = NULL", params![ @@ -357,6 +367,7 @@ impl SqliteAccountStorage { u64_to_i64(event.recorded_at)?, u64_to_i64(event.received_at)?, &event.origin_commit_id, + event.moderation_grant, ], ) .storage()?; @@ -927,7 +938,7 @@ fn project_single_message_timeline_tx( .query_row( "SELECT group_id_hex, message_id_hex, source_message_id_hex, source_epoch, direction, sender, plaintext, kind, tags_json, recorded_at, received_at, - invalidated, invalidation_reason + invalidated, invalidation_reason, moderation_grant FROM app_events WHERE group_id_hex = ?1 AND message_id_hex = ?2", params![group_id_hex, message_id_hex], @@ -1061,7 +1072,10 @@ fn apply_targeted_modifiers_tx(tx: &Connection, row: &mut TimelineRow) -> Storag &row.message_id_hex, )?; for delete in deletes { - if row.sender != delete.sender { + // Self-retraction, or an admin moderation delete whose grant was + // authenticated and frozen at ingest (never granted in direct + // conversations). Anything else is a forged cross-sender delete. + if row.sender != delete.sender && !delete.moderation_grant { continue; } row.deleted = true; @@ -1158,7 +1172,8 @@ fn app_events_targeting_message_tx( app_events.source_epoch, app_events.direction, app_events.sender, app_events.plaintext, app_events.kind, app_events.tags_json, app_events.recorded_at, app_events.received_at, - app_events.invalidated, app_events.invalidation_reason + app_events.invalidated, app_events.invalidation_reason, + app_events.moderation_grant FROM message_modifier_edges AS edges JOIN app_events ON app_events.group_id_hex = edges.group_id_hex @@ -1271,7 +1286,7 @@ fn app_events_for_rebuild_tx( .prepare( "SELECT group_id_hex, message_id_hex, source_message_id_hex, source_epoch, direction, sender, plaintext, kind, tags_json, recorded_at, received_at, - invalidated, invalidation_reason + invalidated, invalidation_reason, moderation_grant FROM app_events WHERE group_id_hex = ?1 ORDER BY recorded_at, message_id_hex, insert_order", @@ -2107,7 +2122,7 @@ fn project_group_events(events: Vec) -> (Vec, Vec) -> rusqlite::Result received_at: row.get::<_, i64>(10)?.try_into().unwrap_or_default(), invalidated: row.get::<_, i64>(11)? != 0, invalidation_reason: row.get(12)?, + moderation_grant: row.get::<_, i64>(13)? != 0, }) } diff --git a/crates/storage-sqlite/src/timeline/tests.rs b/crates/storage-sqlite/src/timeline/tests.rs index c98526fe..0c218894 100644 --- a/crates/storage-sqlite/src/timeline/tests.rs +++ b/crates/storage-sqlite/src/timeline/tests.rs @@ -14,6 +14,7 @@ fn chat(id: &str, sender: &str, at: u64, plaintext: &str) -> StoredAppEvent { recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } @@ -31,6 +32,7 @@ fn reaction(id: &str, sender: &str, target: &str, at: u64, emoji: &str) -> Store recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } @@ -49,6 +51,7 @@ fn agent_operation(id: &str, sender: &str, target: &str, at: u64) -> StoredAppEv recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } @@ -69,6 +72,7 @@ fn reply(id: &str, sender: &str, target: &str, at: u64, plaintext: &str) -> Stor recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } @@ -86,9 +90,16 @@ fn delete(id: &str, sender: &str, target: &str, at: u64) -> StoredAppEvent { recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } +fn moderated_delete(id: &str, sender: &str, target: &str, at: u64) -> StoredAppEvent { + let mut event = delete(id, sender, target, at); + event.moderation_grant = true; + event +} + fn edit(id: &str, sender: &str, target: &str, at: u64, plaintext: &str) -> StoredAppEvent { StoredAppEvent { group_id_hex: "11".repeat(32), @@ -103,6 +114,7 @@ fn edit(id: &str, sender: &str, target: &str, at: u64, plaintext: &str) -> Store recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } @@ -136,6 +148,7 @@ fn group_system(id: &str, system_type: &str, at: u64) -> StoredAppEvent { recorded_at: at, received_at: at, origin_commit_id: None, + moderation_grant: false, } } @@ -977,6 +990,7 @@ fn stream_start_and_final_are_materialized_as_linked_timeline_records() { recorded_at: 1, received_at: 1, origin_commit_id: None, + moderation_grant: false, }; let final_event = StoredAppEvent { group_id_hex: "11".repeat(32), @@ -994,6 +1008,7 @@ fn stream_start_and_final_are_materialized_as_linked_timeline_records() { recorded_at: 2, received_at: 2, origin_commit_id: None, + moderation_grant: false, }; store.record_app_event(&start).unwrap(); @@ -1532,6 +1547,95 @@ fn message_delete_by_sender_clears_content_and_reactions_but_other_sender_does_n assert!(message.reactions.by_emoji.is_empty()); } +#[test] +fn moderation_grant_delete_tombstones_other_members_message() { + let store = SqliteAccountStorage::in_memory().unwrap(); + store + .record_app_event(&chat("target", "alice", 1, "secret")) + .unwrap(); + store + .record_app_event(&reaction("reaction-1", "bob", "target", 2, "+")) + .unwrap(); + store + .record_app_event(&moderated_delete("admin-delete", "carol", "target", 3)) + .unwrap(); + + let message = list(&store).pop().unwrap(); + + assert!(message.deleted); + assert_eq!( + message.deleted_by_message_id_hex.as_deref(), + Some("admin-delete") + ); + assert_eq!(message.plaintext, ""); + assert!(message.reactions.user_reactions.is_empty()); + assert!(message.reactions.by_emoji.is_empty()); +} + +#[test] +fn moderation_grant_delete_tombstones_target_arriving_later() { + // Out-of-order delivery: the moderation delete lands before its target. + let store = SqliteAccountStorage::in_memory().unwrap(); + store + .record_app_event(&moderated_delete("admin-delete", "carol", "target", 1)) + .unwrap(); + store + .record_app_event(&chat("target", "alice", 2, "secret")) + .unwrap(); + + let message = list(&store).pop().unwrap(); + + assert!(message.deleted); + assert_eq!(message.plaintext, ""); +} + +#[test] +fn moderation_grant_survives_full_timeline_rebuild() { + // The grant is persisted with the delete event, so rebuilding the + // projection from raw app events must keep honoring the moderated + // tombstone even though the admin set is long gone from this layer. + let store = SqliteAccountStorage::in_memory().unwrap(); + store + .record_app_event(&chat("target", "alice", 1, "secret")) + .unwrap(); + store + .record_app_event(&moderated_delete("admin-delete", "carol", "target", 2)) + .unwrap(); + + store + .rebuild_message_timeline_for_group(&"11".repeat(32)) + .unwrap(); + + let message = list(&store).pop().unwrap(); + assert!(message.deleted); + assert_eq!( + message.deleted_by_message_id_hex.as_deref(), + Some("admin-delete") + ); + assert_eq!(message.plaintext, ""); +} + +#[test] +fn moderation_grant_does_not_retract_other_members_reaction() { + // Reaction retraction keeps its sender-equality forged-delete guard; + // moderation authority applies to messages, not to reaction removal. + let store = SqliteAccountStorage::in_memory().unwrap(); + store + .record_app_event(&chat("target", "alice", 1, "hello")) + .unwrap(); + store + .record_app_event(&reaction("reaction-1", "bob", "target", 2, "+")) + .unwrap(); + store + .record_app_event(&moderated_delete("admin-delete", "carol", "reaction-1", 3)) + .unwrap(); + + let message = list(&store).pop().unwrap(); + + assert!(!message.deleted); + assert_eq!(message.reactions.user_reactions.len(), 1); +} + #[test] fn re_recording_reaction_does_not_duplicate_edges_or_reaction() { let store = SqliteAccountStorage::in_memory().unwrap(); From a15072b2cf5b35d8818a1c18ee01154ced6a9f76 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Wed, 15 Jul 2026 23:07:01 +0100 Subject: [PATCH 2/3] Let a moderation delete's post-sync grant supersede the optimistic pre-send one --- crates/marmot-app/src/client/projection.rs | 17 +++++-- crates/marmot-app/src/lib.rs | 18 +++++++ crates/storage-sqlite/src/timeline.rs | 36 +++++++++++-- crates/storage-sqlite/src/timeline/tests.rs | 56 +++++++++++++++++++++ 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/crates/marmot-app/src/client/projection.rs b/crates/marmot-app/src/client/projection.rs index 07b7bd46..c3a0d8fe 100644 --- a/crates/marmot-app/src/client/projection.rs +++ b/crates/marmot-app/src/client/projection.rs @@ -54,9 +54,20 @@ impl AppClient { origin_commit_id: None, moderation_grant, }; - let update = self - .app - .record_account_app_event(&self.state.label, &message_projection)?; + // The reconciling post-publish projection (advance_read_marker) runs + // after group sync, so its recomputed moderation grant supersedes the + // optimistic pre-send one; the pre-send projection keeps the default + // freeze so a later no-op re-record can't downgrade it. + let update = if advance_read_marker { + self.app + .record_account_app_event_refreshing_moderation_grant( + &self.state.label, + &message_projection, + )? + } else { + self.app + .record_account_app_event(&self.state.label, &message_projection)? + }; if advance_read_marker && event.kind == MARMOT_APP_EVENT_KIND_CHAT { let read_marker = self.app diff --git a/crates/marmot-app/src/lib.rs b/crates/marmot-app/src/lib.rs index d0d781f7..4ca0f96c 100644 --- a/crates/marmot-app/src/lib.rs +++ b/crates/marmot-app/src/lib.rs @@ -2747,6 +2747,24 @@ impl MarmotApp { self.app_projection_update(label, storage_update) } + /// As [`Self::record_account_app_event`], but a conflicting row's + /// `moderation_grant` is replaced rather than frozen. Used by the local + /// sender's post-publish reconciling projection so a moderation grant + /// recomputed after group sync supersedes the optimistic pre-send value. + pub(crate) fn record_account_app_event_refreshing_moderation_grant( + &self, + label: &str, + message: &AppMessageProjection, + ) -> Result { + let now = unix_now_seconds(); + let storage_update = self + .account_storage(label)? + .record_app_event_refreshing_moderation_grant(&stored_app_event_from_projection( + message, now, + ))?; + self.app_projection_update(label, storage_update) + } + pub(crate) fn invalidate_timeline_source_message( &self, label: &str, diff --git a/crates/storage-sqlite/src/timeline.rs b/crates/storage-sqlite/src/timeline.rs index 95e3571e..5f149257 100644 --- a/crates/storage-sqlite/src/timeline.rs +++ b/crates/storage-sqlite/src/timeline.rs @@ -57,10 +57,13 @@ pub struct StoredAppEvent { pub origin_commit_id: Option, /// True only for a delete whose authenticated sender held group-admin /// moderation authority in a non-direct group when the delete was - /// recorded. The verdict is frozen at the event's first record — an upsert - /// keeps the stored value — so reprojection and relay echoes arriving - /// after an admin-set change can never flip an honored tombstone. `false` - /// for every other event. + /// recorded. On the default [`record_app_event`] path a conflicting row's + /// value is frozen, so reprojection and relay echoes arriving after an + /// admin-set change can never flip an honored tombstone. The local + /// sender's post-publish reconciling projection uses + /// [`record_app_event_refreshing_moderation_grant`] instead, so the grant + /// recomputed after group sync supersedes the optimistic pre-send one. + /// `false` for every other event. pub moderation_grant: bool, } @@ -300,6 +303,28 @@ impl SqliteAccountStorage { pub fn record_app_event( &self, event: &StoredAppEvent, + ) -> StorageResult { + // Default path freezes an existing row's moderation_grant on conflict + // (see the upsert below): a re-received or relay-echoed delete must not + // have its honored verdict recomputed against later admin state. + self.record_app_event_inner(event, false) + } + + /// Like [`record_app_event`], but a conflicting row's `moderation_grant` is + /// replaced with this event's value instead of frozen. Used only by the + /// local sender's post-publish reconciling projection, where the grant + /// recomputed after group sync supersedes the optimistic pre-send one. + pub fn record_app_event_refreshing_moderation_grant( + &self, + event: &StoredAppEvent, + ) -> StorageResult { + self.record_app_event_inner(event, true) + } + + fn record_app_event_inner( + &self, + event: &StoredAppEvent, + refresh_moderation_grant: bool, ) -> StorageResult { self.connection.with_transaction(|| { let conn = self.lock()?; @@ -351,7 +376,7 @@ impl SqliteAccountStorage { recorded_at = excluded.recorded_at, received_at = excluded.received_at, origin_commit_id = COALESCE(excluded.origin_commit_id, app_events.origin_commit_id), - moderation_grant = app_events.moderation_grant, + moderation_grant = CASE WHEN ?14 THEN excluded.moderation_grant ELSE app_events.moderation_grant END, invalidated = 0, invalidation_reason = NULL", params![ @@ -368,6 +393,7 @@ impl SqliteAccountStorage { u64_to_i64(event.received_at)?, &event.origin_commit_id, event.moderation_grant, + refresh_moderation_grant, ], ) .storage()?; diff --git a/crates/storage-sqlite/src/timeline/tests.rs b/crates/storage-sqlite/src/timeline/tests.rs index 0c218894..c6446c84 100644 --- a/crates/storage-sqlite/src/timeline/tests.rs +++ b/crates/storage-sqlite/src/timeline/tests.rs @@ -1636,6 +1636,62 @@ fn moderation_grant_does_not_retract_other_members_reaction() { assert_eq!(message.reactions.user_reactions.len(), 1); } +#[test] +fn re_recording_a_delete_freezes_the_stored_moderation_grant() { + // The default path must keep an honored grant: a re-received / echoed + // delete recomputed as non-moderated after an admin-set change cannot + // resurrect the tombstoned message. + let store = SqliteAccountStorage::in_memory().unwrap(); + store + .record_app_event(&chat("target", "alice", 1, "secret")) + .unwrap(); + store + .record_app_event(&moderated_delete("admin-delete", "carol", "target", 2)) + .unwrap(); + // Echo of the same delete id, now without a grant. + store + .record_app_event(&delete("admin-delete", "carol", "target", 2)) + .unwrap(); + + let message = list(&store).pop().unwrap(); + assert!(message.deleted, "frozen grant must keep the tombstone"); +} + +#[test] +fn refreshing_variant_replaces_the_stored_moderation_grant() { + // The local sender's post-publish reconciling projection supersedes the + // optimistic pre-send grant: a grant recomputed as non-moderated after + // group sync must drop the tombstone (and vice versa). + let store = SqliteAccountStorage::in_memory().unwrap(); + store + .record_app_event(&chat("target", "alice", 1, "secret")) + .unwrap(); + store + .record_app_event(&moderated_delete("admin-delete", "carol", "target", 2)) + .unwrap(); + assert!(list(&store).pop().unwrap().deleted); + + // Post-sync recompute: no longer a moderator → grant withdrawn. + store + .record_app_event_refreshing_moderation_grant(&delete("admin-delete", "carol", "target", 2)) + .unwrap(); + assert!( + !list(&store).pop().unwrap().deleted, + "refresh must withdraw the tombstone when the grant is recomputed away", + ); + + // Post-sync recompute the other way: moderator confirmed → grant restored. + store + .record_app_event_refreshing_moderation_grant(&moderated_delete( + "admin-delete", + "carol", + "target", + 2, + )) + .unwrap(); + assert!(list(&store).pop().unwrap().deleted); +} + #[test] fn re_recording_reaction_does_not_duplicate_edges_or_reaction() { let store = SqliteAccountStorage::in_memory().unwrap(); From f5757ff922c57773bb14f02e5b2b51f6090f683d Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Thu, 16 Jul 2026 15:13:59 +0100 Subject: [PATCH 3/3] Document moderation-grant heuristic limits and the no-backfill decision --- crates/marmot-app/src/client/projection.rs | 10 ++++++++++ crates/marmot-app/src/groups.rs | 16 +++++++++++++--- .../0027_app_event_moderation_grant.rs | 7 +++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/marmot-app/src/client/projection.rs b/crates/marmot-app/src/client/projection.rs index c3a0d8fe..554bf6d4 100644 --- a/crates/marmot-app/src/client/projection.rs +++ b/crates/marmot-app/src/client/projection.rs @@ -87,6 +87,16 @@ impl AppClient { /// Fail-closed: a group or admin-policy lookup failure yields no grant, so /// the delete degrades to self-retraction semantics. + /// + /// Accepted trade-off: the grant is evaluated against the admin set this + /// device sees now (current signed group state), not the admin set as of + /// the delete's epoch, and it is then frozen at first record. Two devices + /// that first observe the same delete at different points in their own sync + /// — one already past an admin-adding commit, the other not, or one while + /// the group is quarantined — can therefore disagree permanently on whether + /// it is honored, a milder echo of the cross-device divergence #873 + /// addresses. This is the deliberate fail-closed / frozen posture; an + /// epoch-anchored admin evaluation is future work. pub(crate) fn delete_moderation_grant(&self, group_id: &GroupId, sender_hex: &str) -> bool { let Ok(group) = self.runtime.group_record(group_id) else { return false; diff --git a/crates/marmot-app/src/groups.rs b/crates/marmot-app/src/groups.rs index 91c2d483..0269ab96 100644 --- a/crates/marmot-app/src/groups.rs +++ b/crates/marmot-app/src/groups.rs @@ -837,9 +837,19 @@ pub(crate) enum GroupConfirmationProjection { } /// Whether a delete may tombstone other members' messages: its authenticated -/// sender must be in the group's current admin set, and the group must not be -/// a direct (two-member, unnamed) conversation — direct chats stay -/// self-retraction only, so neither peer can moderate the other. +/// sender must be in the group's current admin set, and the group must not +/// look like a direct (two-member, unnamed) conversation. +/// +/// Known limitation: "direct" is a heuristic over mutable state +/// (`members.len() == 2 && name.is_empty()`), not an immutable conversation +/// kind. The creator is always an implicit admin, and the only +/// create/rename API takes a free-form name for any member count, so an admin +/// can name — or later rename — a two-member conversation to escape this gate +/// and gain moderation over the peer's messages, with no signal to the peer. +/// Closing that fully needs a conversation-kind fixed at creation time +/// (protocol/engine plus client work); until then this is a deliberate, +/// documented limitation rather than a guarantee that 1:1 chats can never be +/// moderated. pub(crate) fn delete_moderation_grant( group: &Group, admins: &[[u8; 32]], diff --git a/crates/storage-sqlite/src/migrations/0027_app_event_moderation_grant.rs b/crates/storage-sqlite/src/migrations/0027_app_event_moderation_grant.rs index 611c2645..937b0361 100644 --- a/crates/storage-sqlite/src/migrations/0027_app_event_moderation_grant.rs +++ b/crates/storage-sqlite/src/migrations/0027_app_event_moderation_grant.rs @@ -3,6 +3,13 @@ use cgka_traits::storage::StorageResult; use rusqlite::Transaction; pub(crate) fn apply(tx: &Transaction<'_>) -> StorageResult<()> { + // No backfill, by deliberate decision: existing rows default to 0 (no + // grant), which is exactly the pre-migration behavior. Every historical + // delete was honored only as a self-retraction (author == target), never + // as admin moderation, so `false` is the correct historical verdict and + // reprojecting old deletes keeps their original outcome. Backfilling `true` + // for old cross-sender deletes would retroactively honor deletes that no + // client ever applied. tx.execute_batch( r#" ALTER TABLE app_events ADD COLUMN moderation_grant INTEGER NOT NULL DEFAULT 0;