diff --git a/desktop/src-tauri/src/managed_agents/migration/mod.rs b/desktop/src-tauri/src/managed_agents/migration/mod.rs index 9e4bd68f4a..da01a6b7ba 100644 --- a/desktop/src-tauri/src/managed_agents/migration/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -157,6 +157,18 @@ pub struct PromotionEvidence { pub definition_revision: u64, } +/// Proof that a deleted aggregate request was committed and read back exactly. +/// +/// Deleted heads do not carry public projections or an active private body, so +/// their verification contract is intentionally smaller than promotion's but +/// just as strict about the submitted event and CAS metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeletionEvidence { + pub head_event_id: String, + pub generation: u64, + pub previous_event_id: String, +} + /// Build the next minimal deleted aggregate from verified relay-head evidence. /// /// A tombstone carries no public projections or private active body. It advances @@ -470,6 +482,69 @@ pub fn verify_promotion( }) } +/// Verify a relay read-back for an exact deleted aggregate request. +/// +/// The submitted event is the immutable retry input retained by Desktop. The +/// response must return that byte-identical event and echo its deleted CAS head; +/// deleted heads must not grow public projections or a definition revision. +pub fn verify_deletion( + submitted_event: &Event, + response: &AggregateResponse, + owner_keys: &Keys, +) -> Result { + let (envelope, payload) = pma::validate_and_decrypt(submitted_event, owner_keys) + .map_err(|e| MigrationError::VerificationFailed(format!("submitted tombstone: {e}")))?; + + let previous_event_id = payload.previous_event_id.clone().ok_or_else(|| { + MigrationError::VerificationFailed("submitted deletion has no predecessor".into()) + })?; + if payload.state != State::Deleted || payload.active.is_some() || payload.deleted_at.is_none() { + return Err(MigrationError::VerificationFailed( + "submitted aggregate is not a valid deletion".into(), + )); + } + if !response.accepted { + return Err(MigrationError::VerificationFailed( + "relay did not accept aggregate deletion".into(), + )); + } + if &response.private_event != submitted_event { + return Err(MigrationError::VerificationFailed( + "read-back deletion head differs from submitted event".into(), + )); + } + verify_eq( + "response.event_id", + &response.event_id, + &submitted_event.id.to_hex(), + )?; + verify_eq( + "response.generation", + &response.generation, + &payload.generation, + )?; + verify_eq("response.state", &response.state.as_str(), &"deleted")?; + if response.definition_event.is_some() + || response.instance_event.is_some() + || response.definition_revision.is_some() + { + return Err(MigrationError::VerificationFailed( + "deleted read-back unexpectedly contains active projections".into(), + )); + } + verify_eq( + "envelope.generation", + &envelope.generation, + &payload.generation, + )?; + + Ok(DeletionEvidence { + head_event_id: submitted_event.id.to_hex(), + generation: payload.generation, + previous_event_id, + }) +} + fn verify_binding_definition( source: &ActivePayload, roundtrip: &ActivePayload, diff --git a/desktop/src-tauri/src/managed_agents/migration/tests.rs b/desktop/src-tauri/src/managed_agents/migration/tests.rs index feb79cd6ec..592856f368 100644 --- a/desktop/src-tauri/src/managed_agents/migration/tests.rs +++ b/desktop/src-tauri/src/managed_agents/migration/tests.rs @@ -508,6 +508,98 @@ fn tombstone_rejects_invalid_coordinate_or_generation_overflow() { )); } +#[test] +fn verify_deleted_readback_accepts_exact_tombstone_and_rejects_active_residue() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let previous_id = "11".repeat(32); + let event = build_tombstone_event( + &owner, + &agent.public_key().to_hex(), + 7, + &previous_id, + "2026-08-05T18:00:00Z", + CREATED_AT, + ) + .unwrap(); + let mut response = AggregateResponse { + event_id: event.id.to_hex(), + generation: 8, + state: "deleted".into(), + accepted: true, + inserted: true, + private_event: event.clone(), + definition_event: None, + instance_event: None, + definition_revision: None, + }; + + assert_eq!( + verify_deletion(&event, &response, &owner).unwrap(), + DeletionEvidence { + head_event_id: event.id.to_hex(), + generation: 8, + previous_event_id: previous_id, + } + ); + + response.definition_revision = Some(7); + assert!(matches!( + verify_deletion(&event, &response, &owner), + Err(MigrationError::VerificationFailed(_)) + )); +} + +#[test] +fn verify_deleted_readback_rejects_swapped_or_drifted_head() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let event = build_tombstone_event( + &owner, + &agent.public_key().to_hex(), + 2, + &"22".repeat(32), + "2026-08-05T18:00:00Z", + CREATED_AT, + ) + .unwrap(); + let other = build_tombstone_event( + &owner, + &agent.public_key().to_hex(), + 2, + &"22".repeat(32), + "2026-08-05T18:00:01Z", + CREATED_AT + 1, + ) + .unwrap(); + let base = AggregateResponse { + event_id: event.id.to_hex(), + generation: 3, + state: "deleted".into(), + accepted: true, + inserted: false, + private_event: event.clone(), + definition_event: None, + instance_event: None, + definition_revision: None, + }; + + for mutate in 0..4 { + let mut response = base.clone(); + match mutate { + 0 => response.private_event = other.clone(), + 1 => response.event_id = other.id.to_hex(), + 2 => response.generation += 1, + 3 => response.accepted = false, + _ => unreachable!(), + } + assert!(matches!( + verify_deletion(&event, &response, &owner), + Err(MigrationError::VerificationFailed(_)) + )); + } +} + #[test] fn verify_rejects_stale_generation_head() { let fx = Fixture::new();