From a3deff07adaa1f8438d9f4c1218347999b9d3522 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 18 Jun 2026 16:12:45 +0000 Subject: [PATCH] feat(deletion): rollback active announcement e-target deletes --- src/nostr/policy/deletion.rs | 158 +++++++++++++++++++++++++++++++---- tests/nip09_state_cascade.rs | 58 +++++++++++++ 2 files changed, 201 insertions(+), 15 deletions(-) diff --git a/src/nostr/policy/deletion.rs b/src/nostr/policy/deletion.rs index dc36c79..5bd932f 100644 --- a/src/nostr/policy/deletion.rs +++ b/src/nostr/policy/deletion.rs @@ -83,7 +83,8 @@ pub struct DeletionPolicy { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct StateRollbackPlan { +struct ReplaceableRollbackPlan { + kind: u16, coordinate: String, identifier: String, deleted_event_id: EventId, @@ -154,7 +155,7 @@ impl DeletionPolicy { } } - let rollback_plans = self.collect_state_rollback_plans(event).await; + let rollback_plans = self.collect_replaceable_rollback_plans(event).await; let mut identifiers_to_realign = Self::state_a_tag_identifiers_for_author(event); // Process purgatory removals (synchronous, in-memory). @@ -165,8 +166,10 @@ impl DeletionPolicy { self.delete_main_db_targets(event, &mut moved_ids).await; for plan in &rollback_plans { - identifiers_to_realign.insert(plan.identifier.clone()); - self.rollback_deleted_active_state(plan).await; + self.rollback_deleted_active_replaceable(plan).await; + if plan.kind == Kind::RepoState.as_u16() { + identifiers_to_realign.insert(plan.identifier.clone()); + } } for identifier in identifiers_to_realign { @@ -199,7 +202,10 @@ impl DeletionPolicy { .collect() } - async fn collect_state_rollback_plans(&self, deletion: &Event) -> Vec { + async fn collect_replaceable_rollback_plans( + &self, + deletion: &Event, + ) -> Vec { let mut plans = Vec::new(); for target_id in Self::e_tag_ids(deletion) { @@ -207,7 +213,11 @@ impl DeletionPolicy { continue; }; - if target.kind != Kind::RepoState || target.pubkey != deletion.pubkey { + if target.pubkey != deletion.pubkey { + continue; + } + + if target.kind != Kind::RepoState && target.kind != Kind::GitRepoAnnouncement { continue; } @@ -216,7 +226,7 @@ impl DeletionPolicy { }; let Some(active) = self - .latest_state_for_coordinate(&target.pubkey, &identifier, None) + .latest_event_for_coordinate(target.kind, &target.pubkey, &identifier, None) .await else { continue; @@ -226,8 +236,14 @@ impl DeletionPolicy { continue; } - plans.push(StateRollbackPlan { - coordinate: format!("30618:{}:{}", target.pubkey.to_hex(), identifier), + plans.push(ReplaceableRollbackPlan { + kind: target.kind.as_u16(), + coordinate: format!( + "{}:{}:{}", + target.kind.as_u16(), + target.pubkey.to_hex(), + identifier + ), identifier, deleted_event_id: target.id, cutoff: target.created_at, @@ -284,19 +300,20 @@ impl DeletionPolicy { Some((Kind::from(kind_num), pubkey, parts[2].to_string())) } - async fn latest_state_for_coordinate( + async fn latest_event_for_coordinate( &self, + kind: Kind, author: &PublicKey, identifier: &str, until: Option, ) -> Option { - let mut filter = Filter::new() - .kind(Kind::RepoState) - .author(*author) - .custom_tag( + let mut filter = Filter::new().kind(kind).author(*author); + if kind.is_addressable() { + filter = filter.custom_tag( SingleLetterTag::lowercase(Alphabet::D), identifier.to_string(), ); + } if let Some(cutoff) = until { filter = filter.until(cutoff); } @@ -312,7 +329,15 @@ impl DeletionPolicy { }) } - async fn rollback_deleted_active_state(&self, plan: &StateRollbackPlan) { + async fn rollback_deleted_active_replaceable(&self, plan: &ReplaceableRollbackPlan) { + match Kind::from(plan.kind) { + Kind::RepoState => self.rollback_deleted_active_state(plan).await, + Kind::GitRepoAnnouncement => self.rollback_deleted_active_announcement(plan).await, + _ => {} + } + } + + async fn rollback_deleted_active_state(&self, plan: &ReplaceableRollbackPlan) { let Some(record) = self .ctx .history @@ -417,6 +442,109 @@ impl DeletionPolicy { ); } + async fn rollback_deleted_active_announcement(&self, plan: &ReplaceableRollbackPlan) { + let Some(record) = self + .ctx + .history + .latest_superseded_before(&plan.coordinate, plan.cutoff) + .await + else { + tracing::info!( + coordinate = %plan.coordinate, + deleted_event_id = %plan.deleted_event_id.to_hex(), + cutoff = plan.cutoff.as_secs(), + "No announcement rollback candidate in history" + ); + return; + }; + + let Some(candidate_id) = record.superseded_event_id else { + tracing::warn!( + coordinate = %plan.coordinate, + metadata_event_id = %record.metadata_event_id.to_hex(), + "History record missing superseded announcement event id; cannot rollback" + ); + return; + }; + + let candidate = match self.ctx.history.event_by_id(&candidate_id).await { + Ok(Some(event)) => event, + Ok(None) => { + tracing::warn!( + coordinate = %plan.coordinate, + candidate_id = %candidate_id.to_hex(), + "History payload missing for announcement rollback candidate" + ); + return; + } + Err(e) => { + tracing::warn!( + coordinate = %plan.coordinate, + candidate_id = %candidate_id.to_hex(), + error = %e, + "History lookup failed for announcement rollback candidate" + ); + return; + } + }; + + if candidate.kind != Kind::GitRepoAnnouncement { + tracing::warn!( + coordinate = %plan.coordinate, + candidate_id = %candidate.id.to_hex(), + kind = candidate.kind.as_u16(), + "Rollback candidate is not an announcement" + ); + return; + } + + let Some((_kind, coord_pubkey, coord_identifier)) = + Self::parse_coordinate(&plan.coordinate) + else { + tracing::warn!( + coordinate = %plan.coordinate, + "Invalid rollback coordinate while restoring announcement" + ); + return; + }; + + if candidate.pubkey != coord_pubkey { + tracing::warn!( + coordinate = %plan.coordinate, + candidate_id = %candidate.id.to_hex(), + "Announcement rollback candidate author does not match coordinate" + ); + return; + } + + if identifier_from_event(&candidate).as_deref() != Some(coord_identifier.as_str()) { + tracing::warn!( + coordinate = %plan.coordinate, + candidate_id = %candidate.id.to_hex(), + expected_identifier = %coord_identifier, + "Announcement rollback candidate identifier mismatch" + ); + return; + } + + if let Err(e) = self.ctx.database.save_event(&candidate).await { + tracing::warn!( + coordinate = %plan.coordinate, + candidate_id = %candidate.id.to_hex(), + error = %e, + "Failed to restore announcement rollback candidate into main DB" + ); + return; + } + + tracing::info!( + coordinate = %plan.coordinate, + deleted_event_id = %plan.deleted_event_id.to_hex(), + restored_event_id = %candidate.id.to_hex(), + "Restored deleted active announcement from replaceable history" + ); + } + async fn realign_identifier_state(&self, identifier: &str) { let repo_data = match fetch_repository_data_excluding_purgatory(&self.ctx.database, identifier).await { diff --git a/tests/nip09_state_cascade.rs b/tests/nip09_state_cascade.rs index 71313d4..cccca73 100644 --- a/tests/nip09_state_cascade.rs +++ b/tests/nip09_state_cascade.rs @@ -271,6 +271,64 @@ async fn test_e_delete_active_state_rolls_back_and_realigns_refs() { relay.stop().await; } +#[tokio::test] +async fn test_e_delete_active_announcement_rolls_back_previous_version() { + let relay = TestRelay::start().await; + let client = AuditClient::new(relay.url(), AuditConfig::isolated()) + .await + .expect("create audit client"); + + let (v1, repo_id) = publish_served_repo(&client, "announcement-rollback-e").await; + let base = Timestamp::from_secs(Timestamp::now().as_secs() + 60); + let v2 = EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(v1.tags.clone()) + .custom_created_at(base) + .finalize(client.keys()) + .expect("build replacement announcement"); + + client + .send_event(v2.clone()) + .await + .expect("send replacement announcement"); + tokio::time::sleep(Duration::from_millis(500)).await; + + assert!( + client + .is_event_on_relay(v2.id) + .await + .expect("query replacement announcement before deletion"), + "replacement announcement must be active before deletion" + ); + + client + .send_event(build_deletion(&client, &[v2.id], &[])) + .await + .expect("send e-tag deletion for active announcement"); + tokio::time::sleep(Duration::from_millis(700)).await; + + assert!( + client + .is_event_on_relay(v1.id) + .await + .expect("query v1 after announcement rollback"), + "previous announcement must be restored" + ); + assert!( + !client + .is_event_on_relay(v2.id) + .await + .expect("query v2 after announcement rollback"), + "deleted active announcement must stay deleted" + ); + + assert!( + announcement_served_by_coordinate(&client, &v1, &repo_id).await, + "announcement coordinate must remain served after rollback" + ); + + relay.stop().await; +} + #[tokio::test] async fn test_a_delete_coordinate_old_cutoff_keeps_newer_active_state() { let relay = TestRelay::start().await;