From 2d107740e216145f5bb4845ed1d13d1ebf4a1eff Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 15:48:12 -0600 Subject: [PATCH] Persist a durable tombstone generation floor for private managed agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inbound private managed-agent (PMA) tombstone could be resurrected locally. The deleted branch of reconcile_inbound_private_managed_agent persisted nothing when no local record existed, and the generation guards only run when a record IS present. So a delayed stale active head at the tombstone's generation (or lower) — a fresh-device hydration/live race, or a post-erase replay — found no record, skipped every guard, and hit the no-match insert path, reconstructing the deleted agent including its nsec. Fix: give every coordinate a DURABLE generation floor, keyed by the retained managed_agent_aggregates rows (the MAX generation across active AND deleted rows). The deleted branch now seeds a confirmed tombstone row even when no local record exists, and both branches gate on the floor before touching the store: an inbound head at <= the floor generation is dropped, while a genuine re-creation at a strictly higher generation still passes. - seed_confirmed_managed_agent_tombstone: idempotent (INSERT OR IGNORE) confirmed deleted-row seed; never downgrades a flush-authored row. - reconcile_inbound_private_managed_agent: open retention once for both branches, read the floor, reject stale heads, seed the tombstone floor before erasing the local record. Regression: the exact active-N / deleted-N+1 / replay-active-N interleaving stays floored (agent absent), a re-created N+2 clears the floor, plus the seed's idempotency and synced-deleted-head contract. Full desktop/src-tauri workspace: 2310 passed / 0 failed / 13 ignored; fmt and clippy -D warnings clean. Addresses PR #4940 review thread discussion_r3724146156. Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../src/commands/personas/inbound.rs | 60 ++++++++-- .../retention/managed_agent_aggregates.rs | 40 +++++++ .../src/managed_agents/retention/tests.rs | 103 ++++++++++++++++++ 3 files changed, 196 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 25e427dbcc..55c16f3d2f 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -235,8 +235,61 @@ fn reconcile_inbound_private_managed_agent( } } + // Open the retention store once for both branches. It carries the DURABLE + // generation floor per coordinate — the deleted branch persists a tombstone + // row here even when no local record exists, and the active branch gates its + // no-match insert on it. The in-memory `relay_authority` guard above only + // fires for a record that is still present; a delayed stale active head that + // arrives after a tombstone erased (or preceded) the record finds no record, + // skips that guard, and would otherwise resurrect the deleted agent — nsec + // included. The persisted floor closes that window. + let scope = + crate::managed_agents::retention::arrival_retention_scope(app, state, arrival_relay_url)? + .ok_or_else(|| "private aggregate arrival scope changed".to_string())?; + let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?; + let durable_floor = crate::managed_agents::retention::get_retained_managed_agent_aggregate( + &conn, + &payload.owner_pubkey, + &payload.agent_pubkey, + )?; + // A retained row (active OR deleted) at this generation or newer means this + // head is stale relative to durable local authority — drop it. A legitimate + // re-creation advances to a strictly higher generation and passes. + if durable_floor + .as_ref() + .is_some_and(|floor| payload.generation <= floor.generation) + { + return Ok(()); + } + match payload.state { State::Deleted => { + // Persist the tombstone as a durable floor FIRST, so even with no + // local record a later stale active head at <= this generation is + // rejected by the floor gate above. Seeded confirmed (already on the + // relay); `local_authority_applied` reflects the record/key erase + // this branch performs (trivially true when no record exists). + let request_json = serde_json::to_string(&serde_json::json!({ + "private_event": event, + "definition_event": Option::<&nostr::Event>::None, + "instance_event": Option::<&nostr::Event>::None, + "expected_definition_revision": Option::::None, + })) + .map_err(|error| format!("failed to retain inbound private tombstone: {error}"))?; + crate::managed_agents::retention::seed_confirmed_managed_agent_tombstone( + &conn, + &crate::managed_agents::retention::RetainedManagedAgentAggregate { + owner_pubkey: payload.owner_pubkey.clone(), + agent_pubkey: payload.agent_pubkey.clone(), + generation: payload.generation, + private_event_id: event.id.to_hex(), + state: "deleted".to_string(), + request_json, + pending_sync: false, + last_error: None, + local_authority_applied: true, + }, + )?; if let Some(index) = existing_index { agents.remove(index); crate::managed_agents::delete_agent_key(&payload.agent_pubkey); @@ -253,13 +306,6 @@ fn reconcile_inbound_private_managed_agent( "expected_definition_revision": payload.active.as_ref().map(|active| active.definition.revision), })) .map_err(|error| format!("failed to retain inbound private aggregate: {error}"))?; - let scope = crate::managed_agents::retention::arrival_retention_scope( - app, - state, - arrival_relay_url, - )? - .ok_or_else(|| "private aggregate arrival scope changed".to_string())?; - let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?; crate::managed_agents::retention::seed_confirmed_managed_agent_aggregate( &conn, &crate::managed_agents::retention::RetainedManagedAgentAggregate { diff --git a/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs b/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs index e05b9ee643..80f2ff5430 100644 --- a/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs +++ b/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs @@ -73,6 +73,46 @@ pub fn seed_confirmed_managed_agent_aggregate( Ok(()) } +/// Persist a confirmed deleted tombstone row as a durable generation floor. +/// +/// Called from the inbound reconcile deleted branch so a verified tombstone +/// leaves a durable floor at its coordinate EVEN WHEN no local record exists — +/// the record-present guards upstream never run in that case, so without this +/// floor a delayed stale active head at the same-or-lower generation would +/// reconstruct the deleted agent (including its nsec). The tombstone is already +/// durable on the relay, so it is seeded confirmed (`pending_sync = 0`) and the +/// flush lane never touches it. `local_authority_applied` records whether the +/// local record/key erase was applied here (trivially satisfied when no record +/// existed). `INSERT OR IGNORE` keeps a re-received tombstone idempotent and +/// never downgrades a row the flush lane authored. +pub fn seed_confirmed_managed_agent_tombstone( + conn: &Connection, + aggregate: &RetainedManagedAgentAggregate, +) -> Result<(), String> { + if aggregate.pending_sync || aggregate.state != "deleted" { + return Err( + "confirmed managed-agent tombstone seed must be a synced deleted head".to_string(), + ); + } + conn.execute( + "INSERT OR IGNORE INTO managed_agent_aggregates + (owner_pubkey, agent_pubkey, generation, private_event_id, state, + request_json, pending_sync, last_error, local_authority_applied) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)", + params![ + aggregate.owner_pubkey, + aggregate.agent_pubkey, + aggregate.generation as i64, + aggregate.private_event_id, + aggregate.state, + aggregate.request_json, + aggregate.local_authority_applied as i32, + ], + ) + .map_err(|error| format!("failed to seed confirmed managed-agent tombstone: {error}"))?; + Ok(()) +} + /// Insert or idempotently refresh a retained aggregate generation. /// /// Generation may advance by exactly one. A byte-identical rewrite of the diff --git a/desktop/src-tauri/src/managed_agents/retention/tests.rs b/desktop/src-tauri/src/managed_agents/retention/tests.rs index 70b157c6b3..030a9c4fed 100644 --- a/desktop/src-tauri/src/managed_agents/retention/tests.rs +++ b/desktop/src-tauri/src/managed_agents/retention/tests.rs @@ -701,3 +701,106 @@ fn deletion_marker_refuses_a_non_deleted_row() { .unwrap(); assert!(!row.local_authority_applied); } + +/// The durable generation floor for a coordinate: the MAX retained generation +/// across BOTH active and deleted rows. Mirrors the gate the inbound reconcile +/// applies before it inserts a no-match active head — `get_retained_...` +/// returns the latest generation regardless of state, so a persisted tombstone +/// raises the floor exactly like a persisted active head. +fn floored(conn: &Connection, incoming_generation: u64) -> bool { + get_retained_managed_agent_aggregate(conn, "owner", "agent") + .unwrap() + .is_some_and(|floor| incoming_generation <= floor.generation) +} + +/// A confirmed tombstone seed persisted with no prior local record raises the +/// durable floor, so the exact resurrection interleaving is closed: +/// snapshot active N → live deleted N+1 → replayed active N must stay floored, +/// while a genuine re-creation at N+2 clears the floor. +#[test] +fn confirmed_tombstone_seed_floors_a_replayed_stale_active_head() { + let conn = test_db(); + + // Snapshot active N=1 seeds a confirmed active floor at 1. + let mut active_n = aggregate(1, "event-1", "active", r#"{"generation":1}"#); + active_n.pending_sync = false; + seed_confirmed_managed_agent_aggregate(&conn, &active_n).unwrap(); + assert!(floored(&conn, 1), "the replay of active N is floored"); + assert!( + !floored(&conn, 2), + "a genuine advance to N+1 is not floored" + ); + + // Live deleted N+1 seeds a confirmed tombstone floor at 2 — the crux: this + // path runs even when NO local record exists, so the floor is durable and a + // later stale active head cannot resurrect the deleted agent. + let mut tombstone = aggregate(2, "event-2", "deleted", r#"{"generation":2}"#); + tombstone.pending_sync = false; + tombstone.local_authority_applied = true; + seed_confirmed_managed_agent_tombstone(&conn, &tombstone).unwrap(); + let floor = get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap(); + assert_eq!(floor.generation, 2); + assert_eq!(floor.state, "deleted"); + assert!(floor.local_authority_applied); + + // Replayed active N=1: floored — the agent stays absent. + assert!( + floored(&conn, 1), + "replayed active N is rejected by the tombstone floor" + ); + // Deleted N+1 replayed: also floored (idempotent re-receive is a no-op). + assert!(floored(&conn, 2), "re-received tombstone N+1 is floored"); + // Genuine re-creation at N+2 clears the floor and is admitted. + assert!( + !floored(&conn, 3), + "a fresh re-creation at N+2 is not floored" + ); +} + +/// A confirmed tombstone seed with no prior record is idempotent and never +/// downgrades a row the flush lane authored (`INSERT OR IGNORE`). +#[test] +fn confirmed_tombstone_seed_is_idempotent_and_never_downgrades() { + let conn = test_db(); + let mut tombstone = aggregate(2, "event-2", "deleted", r#"{"generation":2}"#); + tombstone.pending_sync = false; + tombstone.local_authority_applied = true; + seed_confirmed_managed_agent_tombstone(&conn, &tombstone).unwrap(); + // Re-receive: no error, no duplicate row, marker preserved. + seed_confirmed_managed_agent_tombstone(&conn, &tombstone).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM managed_agent_aggregates + WHERE owner_pubkey = 'owner' AND agent_pubkey = 'agent'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + let row = get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap(); + assert!(row.local_authority_applied); + assert!(!row.pending_sync); +} + +/// The confirmed tombstone seed refuses anything that is not a synced deleted +/// head — a pending row or an active state would corrupt the floor's meaning. +#[test] +fn confirmed_tombstone_seed_refuses_pending_or_non_deleted() { + let conn = test_db(); + + let mut pending = aggregate(2, "event-2", "deleted", r#"{"generation":2}"#); + pending.pending_sync = true; + assert!(seed_confirmed_managed_agent_tombstone(&conn, &pending) + .unwrap_err() + .contains("synced deleted head")); + + let mut active = aggregate(2, "event-2", "active", r#"{"generation":2}"#); + active.pending_sync = false; + assert!(seed_confirmed_managed_agent_tombstone(&conn, &active) + .unwrap_err() + .contains("synced deleted head")); +}