Persist a durable tombstone generation floor for private managed agents

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 <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-08-05 15:48:12 -06:00
committed by Carl
parent d22d587004
commit 2d107740e2
3 changed files with 196 additions and 7 deletions

View File

@@ -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::<u64>::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 {

View File

@@ -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

View File

@@ -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"));
}