mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user