diff --git a/desktop/src-tauri/src/managed_agents/migration/mod.rs b/desktop/src-tauri/src/managed_agents/migration/mod.rs index 3c0c0a859a..9e4bd68f4a 100644 --- a/desktop/src-tauri/src/managed_agents/migration/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -157,6 +157,42 @@ pub struct PromotionEvidence { pub definition_revision: u64, } +/// Build the next minimal deleted aggregate from verified relay-head evidence. +/// +/// A tombstone carries no public projections or private active body. It advances +/// the same CAS chain by exactly one and is signed by the owner, so deletion can +/// use the aggregate route rather than legacy kind:5 mutation. +#[allow(dead_code)] // Consumed by the aggregate deletion driver in the next slice. +pub fn build_tombstone_event( + owner_keys: &Keys, + agent_pubkey: &str, + current_generation: u64, + current_private_event_id: &str, + timestamp: &str, + created_at: u64, +) -> Result { + let agent = parse_pubkey("agent_pubkey", agent_pubkey)?; + let previous = nostr::EventId::from_hex(current_private_event_id) + .map_err(|e| MigrationError::InvalidInput(format!("private event id: {e}")))?; + let generation = current_generation + .checked_add(1) + .ok_or_else(|| MigrationError::InvalidInput("aggregate generation overflow".into()))?; + let payload = Payload { + format: pma::FORMAT.to_string(), + version: pma::VERSION, + agent_pubkey: agent.to_hex(), + owner_pubkey: owner_keys.public_key().to_hex(), + generation, + previous_event_id: Some(previous.to_hex()), + state: State::Deleted, + updated_at: timestamp.to_string(), + active: None, + deleted_at: Some(timestamp.to_string()), + extensions: Default::default(), + }; + pma::build_event(owner_keys, &payload, created_at).map_err(MigrationError::from) +} + /// Build a signed, inert kind:30179 migration candidate for one agent. /// /// Assembles the encrypted aggregate payload from the record + keys + exact diff --git a/desktop/src-tauri/src/managed_agents/migration/tests.rs b/desktop/src-tauri/src/managed_agents/migration/tests.rs index fcacc1a2e5..feb79cd6ec 100644 --- a/desktop/src-tauri/src/managed_agents/migration/tests.rs +++ b/desktop/src-tauri/src/managed_agents/migration/tests.rs @@ -447,6 +447,67 @@ fn verify_rejects_wrong_definition_revision() { assert!(matches!(err, MigrationError::VerificationFailed(_))); } +#[test] +fn tombstone_advances_verified_head_without_active_payload() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let previous = EventBuilder::new(Kind::TextNote, "previous") + .sign_with_keys(&owner) + .unwrap(); + let timestamp = "2026-08-05T18:00:00Z"; + + let event = build_tombstone_event( + &owner, + &agent.public_key().to_hex(), + 7, + &previous.id.to_hex(), + timestamp, + CREATED_AT, + ) + .unwrap(); + let (envelope, payload) = + buzz_core_pkg::private_managed_agent::validate_and_decrypt(&event, &owner).unwrap(); + assert_eq!(envelope.generation, 8); + assert_eq!( + envelope.previous_event_id.map(|id| id.to_hex()), + Some(previous.id.to_hex()) + ); + assert_eq!( + payload.state, + buzz_core_pkg::private_managed_agent::State::Deleted + ); + assert!(payload.active.is_none()); + assert_eq!(payload.deleted_at.as_deref(), Some(timestamp)); + assert_eq!(payload.updated_at, timestamp); +} + +#[test] +fn tombstone_rejects_invalid_coordinate_or_generation_overflow() { + let owner = Keys::generate(); + assert!(matches!( + build_tombstone_event( + &owner, + "invalid", + 1, + &"11".repeat(32), + "2026-08-05T18:00:00Z", + CREATED_AT + ), + Err(MigrationError::InvalidInput(_)) + )); + assert!(matches!( + build_tombstone_event( + &owner, + &Keys::generate().public_key().to_hex(), + u64::MAX, + &"11".repeat(32), + "2026-08-05T18:00:00Z", + CREATED_AT, + ), + Err(MigrationError::InvalidInput(_)) + )); +} + #[test] fn verify_rejects_stale_generation_head() { let fx = Fixture::new();