From 03bc9dd8d0cd9199465d3c370c693a059b2de68c Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 30 Jul 2026 10:15:49 -0400 Subject: [PATCH 1/8] fix(desktop): order retained agent events by the relay's own comparator The retention cache resolved an equal-`created_at` collision by skipping the inbound event unconditionally, while the relay breaks that tie by lowest event id. The two could therefore disagree about which same-second event is the head, and every later disk-vs-head compare inherited the error. Retained rows now carry the event id as a real column so the local compare can match `replace_parameterized_event`. A pending row still wins its tie regardless of id: it is durable local intent, arbitrated against a writer-consistent head by the boot pass rather than dropped by a cache compare. The id is re-derived from a parsed and verified event, never read from the JSON's self-asserted `id` field, so a legacy row whose bytes do not verify stays unresolved instead of receiving a key it could win a tie with. `RetainedEvent::pending`/`::inbound` replace the hand-built literals at every call site, which makes it structurally impossible for a row's ordering key to describe different bytes than its own `raw_event`. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/commands/agents.rs | 34 +- .../src/commands/personas/inbound.rs | 27 +- .../src/commands/personas/pending.rs | 25 +- .../src/commands/personas/snapshot/import.rs | 16 +- .../src-tauri/src/commands/team_snapshot.rs | 16 +- desktop/src-tauri/src/commands/teams.rs | 25 +- desktop/src-tauri/src/event_sync.rs | 23 +- .../managed_agents/persona_events/tests.rs | 1 + .../src-tauri/src/managed_agents/reconcile.rs | 16 +- .../src/managed_agents/reconcile/tests.rs | 1 + .../src-tauri/src/managed_agents/retention.rs | 611 ++++------------ .../retention/legacy_migration.rs | 9 +- .../retention/legacy_migration/tests.rs | 3 + .../src/managed_agents/retention/schema.rs | 119 +++ .../managed_agents/retention/schema/tests.rs | 200 +++++ .../src/managed_agents/retention/tests.rs | 690 ++++++++++++++++++ 16 files changed, 1196 insertions(+), 620 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/retention/schema.rs create mode 100644 desktop/src-tauri/src/managed_agents/retention/schema/tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/retention/tests.rs diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0758fc3aac..fd437fd8b5 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -83,7 +83,6 @@ pub(super) fn tombstone_managed_agent_pending( }, }; use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; const KIND_DELETE: u32 = 5; @@ -97,17 +96,12 @@ pub(super) fn tombstone_managed_agent_pending( delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; retain_event( &conn, - &RetainedEvent { - kind: KIND_DELETE, - pubkey: owner_pubkey, - // Key by the target coordinate so cross-kind d-tag tombstones - // occupy distinct rows (F2c). - d_tag: tombstone_retention_d_tag(KIND_MANAGED_AGENT, agent_pubkey), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, + &RetainedEvent::pending( + KIND_DELETE, + owner_pubkey, + tombstone_retention_d_tag(KIND_MANAGED_AGENT, agent_pubkey), + &event, + ), ) })(); if let Err(e) = result { @@ -176,7 +170,6 @@ pub(super) fn build_agent_archive_request( pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubkey: &str) { use crate::managed_agents::retention::{open_retention_db, retain_event, RetainedEvent}; use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST; - use nostr::JsonUtil; let result = (|| -> Result<(), String> { let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; @@ -185,15 +178,12 @@ pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, a let conn = open_retention_db(&scope.db_path)?; retain_event( &conn, - &RetainedEvent { - kind: KIND_IA_ARCHIVE_REQUEST, - pubkey: owner_pubkey, - d_tag: agent_pubkey.to_string(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, + &RetainedEvent::pending( + KIND_IA_ARCHIVE_REQUEST, + owner_pubkey, + agent_pubkey.to_string(), + &event, + ), ) })(); if let Err(e) = result { diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d..a2b9be7edd 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -78,7 +78,6 @@ fn reconcile_inbound_persona_event_blocking( team_events::team_content_from_event, }; use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; - use nostr::JsonUtil; let state = app.state::(); let event = parse_verified_inbound_event(&event_json)?; @@ -133,15 +132,7 @@ fn reconcile_inbound_persona_event_blocking( let conn = open_retention_db(&scope.db_path)?; let outcome = retain_inbound_event( &conn, - &RetainedEvent { - kind, - pubkey: event.pubkey.to_hex(), - d_tag: d_tag.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: false, - }, + &RetainedEvent::inbound(kind, event.pubkey.to_hex(), d_tag.clone(), &event), )?; if outcome == InboundOutcome::Skipped { return Ok(()); @@ -245,7 +236,6 @@ fn reconcile_inbound_tombstone( save_managed_agents, save_teams, }; use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; - use nostr::JsonUtil; let Some((target_kind, target_d_tag)) = parse_deletion_coordinate(event) else { return Ok(()); // no routable coordinate — nothing to delete @@ -272,15 +262,12 @@ fn reconcile_inbound_tombstone( let conn = open_retention_db(&scope.db_path)?; let outcome = retain_inbound_event( &conn, - &RetainedEvent { - kind: KIND_DELETION, - pubkey: event.pubkey.to_hex(), - d_tag: tombstone_retention_d_tag(target_kind, &target_d_tag), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: false, - }, + &RetainedEvent::inbound( + KIND_DELETION, + event.pubkey.to_hex(), + tombstone_retention_d_tag(target_kind, &target_d_tag), + event, + ), )?; if outcome == InboundOutcome::Skipped { return Ok(()); diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index cab5fababc..171a9d6766 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -156,7 +156,6 @@ pub(super) fn prepare_persona_publication_at( retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; use buzz_core_pkg::kind::KIND_PERSONA; - use nostr::JsonUtil; let d_tag = persona_d_tag(persona); let pubkey = keys.public_key().to_hex(); @@ -171,15 +170,7 @@ pub(super) fn prepare_persona_publication_at( )) .sign_with_keys(keys) .map_err(|e| format!("failed to sign persona event: {e}"))?; - let retained = RetainedEvent { - kind: KIND_PERSONA, - pubkey, - d_tag, - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }; + let retained = RetainedEvent::pending(KIND_PERSONA, pubkey, d_tag, &event); retain_event(&conn, &retained)?; Ok((event, retained, scoped_persona)) } @@ -209,7 +200,6 @@ pub(in crate::commands) fn tombstone_persona_pending( }, }; use buzz_core_pkg::kind::KIND_PERSONA; - use nostr::JsonUtil; const KIND_DELETE: u32 = 5; @@ -225,17 +215,14 @@ pub(in crate::commands) fn tombstone_persona_pending( delete_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag)?; retain_event( &conn, - &RetainedEvent { - kind: KIND_DELETE, + &RetainedEvent::pending( + KIND_DELETE, pubkey, // Key by the target coordinate so cross-kind d-tag tombstones // occupy distinct rows (F2c). - d_tag: tombstone_retention_d_tag(KIND_PERSONA, d_tag), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, + tombstone_retention_d_tag(KIND_PERSONA, d_tag), + &event, + ), ) })(); if let Err(e) = result { diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index eccf8ee601..7fcb16ce4f 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -644,7 +644,6 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; let result = (|| -> Result<(), String> { let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; @@ -667,15 +666,12 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent }; retain_event( &conn, - &RetainedEvent { - kind: KIND_MANAGED_AGENT, - pubkey: owner_pubkey, - d_tag: record.pubkey.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, + &RetainedEvent::pending( + KIND_MANAGED_AGENT, + owner_pubkey, + record.pubkey.clone(), + &event, + ), ) })(); if let Err(e) = result { diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..1c3c2ce67f 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -854,7 +854,6 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; let result = (|| -> Result<(), String> { let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; @@ -877,15 +876,12 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent }; retain_event( &conn, - &RetainedEvent { - kind: KIND_MANAGED_AGENT, - pubkey: owner_pubkey, - d_tag: record.pubkey.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, + &RetainedEvent::pending( + KIND_MANAGED_AGENT, + owner_pubkey, + record.pubkey.clone(), + &event, + ), ) })(); if let Err(e) = result { diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index 4377ddaa43..85f15e3ab3 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -44,7 +44,6 @@ pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &Team team_events::build_team_event, }; use buzz_core_pkg::kind::KIND_TEAM; - use nostr::JsonUtil; let result = (|| -> Result<(), String> { let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; @@ -59,15 +58,7 @@ pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &Team .map_err(|e| format!("failed to sign team event: {e}"))?; retain_event( &conn, - &RetainedEvent { - kind: KIND_TEAM, - pubkey, - d_tag: team.id.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, + &RetainedEvent::pending(KIND_TEAM, pubkey, team.id.clone(), &event), ) })(); if let Err(e) = result { @@ -93,7 +84,6 @@ fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { team_events::build_team_delete, }; use buzz_core_pkg::kind::KIND_TEAM; - use nostr::JsonUtil; const KIND_DELETE: u32 = 5; @@ -107,17 +97,14 @@ fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { delete_retained_event(&conn, KIND_TEAM, &pubkey, d_tag)?; retain_event( &conn, - &RetainedEvent { - kind: KIND_DELETE, + &RetainedEvent::pending( + KIND_DELETE, pubkey, // Key by the target coordinate so cross-kind d-tag tombstones // occupy distinct rows (F2c). - d_tag: tombstone_retention_d_tag(KIND_TEAM, d_tag), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, + tombstone_retention_d_tag(KIND_TEAM, d_tag), + &event, + ), ) })(); if let Err(e) = result { diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index ee8e0d8b10..6526fc7d70 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -186,17 +186,7 @@ fn migrate_personas_in_dir_at( continue; } - let retained = RetainedEvent { - kind: KIND_PERSONA, - pubkey: pubkey.clone(), - d_tag, - content: event_content, - // Safety: nostr timestamps are seconds and stay below i64::MAX - // until year 2262. - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }; + let retained = RetainedEvent::pending(KIND_PERSONA, pubkey.clone(), d_tag, &event); // The monotonic bump guarantees `created_at > head`, so the upsert's // `>=` guard always lands the UPDATE — `migrated` counts only real, @@ -259,7 +249,6 @@ fn migrate_teams_in_dir_at( TeamRecord, }; use buzz_core_pkg::kind::KIND_TEAM; - use nostr::JsonUtil; let pubkey = keys.public_key().to_hex(); @@ -312,15 +301,7 @@ fn migrate_teams_in_dir_at( continue; } - let retained = RetainedEvent { - kind: KIND_TEAM, - pubkey: pubkey.clone(), - d_tag, - content: event_content, - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }; + let retained = RetainedEvent::pending(KIND_TEAM, pubkey.clone(), d_tag, &event); // Monotonic bump guarantees the upsert UPDATE lands — `migrated` counts // only real republishes. diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index b9542f9a87..8d99bac2cc 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -828,6 +828,7 @@ mod flush_barrier { content: event.content.to_string(), created_at, raw_event: event.as_json(), + event_id: None, pending_sync: true, }, ) diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 90f05c5750..b0ea4149e2 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -27,7 +27,6 @@ use super::{ ManagedAgentRecord, }; use buzz_core_pkg::kind::KIND_MANAGED_AGENT; -use nostr::JsonUtil; /// Reconcile `managed-agents.json` into kind:30177 events in the retention /// store. Boot-time entry point, called from `event_sync::run_event_sync` @@ -151,15 +150,12 @@ pub(crate) fn retain_agent_record( retain_event( conn, - &RetainedEvent { - kind: KIND_MANAGED_AGENT, - pubkey: owner_pubkey, - d_tag: record.pubkey.clone(), - content, - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, + &RetainedEvent::pending( + KIND_MANAGED_AGENT, + owner_pubkey, + record.pubkey.clone(), + &event, + ), ) .map_err(|e| format!("failed to retain '{}': {e}", record.name))?; Ok(true) diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index c9269dbf00..f2f37447d2 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -255,6 +255,7 @@ fn slimming_republish_wave_is_one_time() { content: fat_content, created_at: 1, raw_event: String::new(), + event_id: None, pending_sync: false, }, ) diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 7e97fa1f56..f17082c0f1 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -15,6 +15,7 @@ use tauri::AppHandle; use crate::app_state::AppState; mod legacy_migration; +mod schema; pub use legacy_migration::migrate_legacy_retention_db; /// Durable event-retention scope for one community relay and owner identity. @@ -116,6 +117,67 @@ pub struct RetainedEvent { pub created_at: i64, pub raw_event: String, pub pending_sync: bool, + /// NIP-01 id of this row's own event, the second half of the ordering key. + /// + /// NIP-33 resolves an equal-`created_at` collision by lowest event id, so a + /// row without an id cannot be ordered against a relay head at all. `None` + /// means exactly that — unresolved: a legacy row whose `raw_event` did not + /// parse and verify during the schema backfill. Writers derive the value + /// from the event they just signed, so it always describes `raw_event`. + pub event_id: Option, +} + +/// Re-derive an event's NIP-01 id from its stored JSON, or `None` when the +/// bytes do not parse or do not verify. +/// +/// Deliberately not `serde_json::from_str::()["id"]`: the id is only an +/// ordering key worth trusting if the stored bytes actually hash and sign to +/// it. A row that fails here stays unresolved rather than being handed a +/// self-asserted key it could win a comparator tie with. +pub fn event_id_from_raw(raw_event: &str) -> Option { + use nostr::JsonUtil; + let event = nostr::Event::from_json(raw_event).ok()?; + event.verify().ok()?; + Some(event.id.to_hex()) +} + +impl RetainedEvent { + /// A row for a locally signed event that still has to reach the relay. + /// + /// Every event-derived field is read from the one event passed in, so a row + /// can never carry an `event_id` that describes different bytes than its own + /// `raw_event` — the ordering key and the payload cannot drift apart. + pub fn pending(kind: u32, pubkey: String, d_tag: String, event: &nostr::Event) -> Self { + Self::from_signed(kind, pubkey, d_tag, event, true) + } + + /// A row for an event that arrived FROM the relay, and so is already + /// published. + pub fn inbound(kind: u32, pubkey: String, d_tag: String, event: &nostr::Event) -> Self { + Self::from_signed(kind, pubkey, d_tag, event, false) + } + + fn from_signed( + kind: u32, + pubkey: String, + d_tag: String, + event: &nostr::Event, + pending_sync: bool, + ) -> Self { + use nostr::JsonUtil; + Self { + kind, + pubkey, + d_tag, + content: event.content.to_string(), + // Safety: nostr timestamps are seconds and stay below i64::MAX + // until year 2262. + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + event_id: Some(event.id.to_hex()), + pending_sync, + } + } } /// Open (or create) the retention database at the given path. @@ -124,7 +186,8 @@ pub struct RetainedEvent { /// flush-loop connection and command-path connections can write concurrently /// without spurious `SQLITE_BUSY` errors. pub fn open_retention_db(path: &Path) -> Result { - let conn = Connection::open(path).map_err(|e| format!("failed to open retention db: {e}"))?; + let mut conn = + Connection::open(path).map_err(|e| format!("failed to open retention db: {e}"))?; conn.pragma_update(None, "busy_timeout", 5000) .map_err(|e| format!("failed to set busy_timeout: {e}"))?; @@ -139,10 +202,14 @@ pub fn open_retention_db(path: &Path) -> Result { created_at INTEGER NOT NULL, raw_event TEXT NOT NULL, pending_sync INTEGER NOT NULL DEFAULT 0, + event_id TEXT, + baseline_event_id TEXT, + baseline_content TEXT, PRIMARY KEY (kind, pubkey, d_tag) );", ) .map_err(|e| format!("failed to create retention table: {e}"))?; + schema::migrate(&mut conn)?; Ok(conn) } @@ -209,13 +276,14 @@ pub fn deferred_behind_failed_tombstone( /// Only replaces if the new event has a newer or equal `created_at` (NIP-33 semantics). pub fn retain_event(conn: &Connection, event: &RetainedEvent) -> Result<(), String> { conn.execute( - "INSERT INTO persona_events (kind, pubkey, d_tag, content, created_at, raw_event, pending_sync) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + "INSERT INTO persona_events (kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT (kind, pubkey, d_tag) DO UPDATE SET content = excluded.content, created_at = excluded.created_at, raw_event = excluded.raw_event, - pending_sync = excluded.pending_sync + pending_sync = excluded.pending_sync, + event_id = excluded.event_id WHERE excluded.created_at >= persona_events.created_at", params![ event.kind, @@ -225,6 +293,7 @@ pub fn retain_event(conn: &Connection, event: &RetainedEvent) -> Result<(), Stri event.created_at, event.raw_event, event.pending_sync as i32, + event.event_id, ], ) .map_err(|e| format!("failed to retain event: {e}"))?; @@ -255,12 +324,20 @@ pub enum InboundOutcome { /// - No local row, or inbound strictly newer (`created_at >`): apply the /// inbound event, clearing `pending_sync`. Inbound wins; a stale local edit /// the relay already superseded stops republishing instead of looping. -/// - Equal `created_at`: skip. Nostr time is seconds-granularity, so a pending -/// local edit and an inbound event can share a timestamp; applying here would -/// clear `pending_sync` and drop the local publish. Skipping leaves the -/// pending row intact so the flush republishes and the relay resolves -/// last-writer-wins. (A re-received echo at equal time is also a no-op.) +/// - Equal `created_at`, local row NOT pending: order by event id, matching the +/// relay's own NIP-33 comparator — lowest id wins (`buzz-db`'s +/// `replace_parameterized_event` rejects an incoming event whose id is `>=` +/// the accepted one at an equal timestamp). Without this the local cache can +/// disagree with the relay about which of two same-second events is the head, +/// and every subsequent compare against that head inherits the error. +/// - Equal `created_at`, local row pending: skip regardless of id. A pending row +/// is durable local intent, and intent is arbitrated by the boot decision +/// pass against a writer-consistent head — never dropped by an id compare +/// here. (A re-received echo at equal time is also a no-op.) /// - Inbound older: skip — nothing to change. +/// +/// An unorderable compare is never resolved by guessing: if either side's +/// `event_id` is `None` at an equal timestamp, the local row stands. pub fn retain_inbound_event( conn: &Connection, event: &RetainedEvent, @@ -270,8 +347,10 @@ pub fn retain_inbound_event( let apply = match &existing { None => true, Some(row) if event.created_at > row.created_at => true, - // Equal or older: skip. Equal time may collide with a pending local - // edit, so we never clear its `pending_sync`; older is stale. + Some(row) if event.created_at == row.created_at => { + !row.pending_sync && inbound_wins_equal_timestamp(event, row) + } + // Older: stale. Some(_) => false, }; @@ -283,13 +362,14 @@ pub fn retain_inbound_event( // `pending_sync`. No upsert guard is needed — the Rust check above already // established that this event wins. conn.execute( - "INSERT INTO persona_events (kind, pubkey, d_tag, content, created_at, raw_event, pending_sync) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0) + "INSERT INTO persona_events (kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7) ON CONFLICT (kind, pubkey, d_tag) DO UPDATE SET content = excluded.content, created_at = excluded.created_at, raw_event = excluded.raw_event, - pending_sync = 0", + pending_sync = 0, + event_id = excluded.event_id", params![ event.kind, event.pubkey, @@ -297,6 +377,7 @@ pub fn retain_inbound_event( event.content, event.created_at, event.raw_event, + event.event_id, ], ) .map_err(|e| format!("failed to retain inbound event: {e}"))?; @@ -304,6 +385,27 @@ pub fn retain_inbound_event( Ok(InboundOutcome::Applied) } +/// Whether an inbound event beats a retained row they share a `created_at` +/// with, under the relay's tie-break: strictly lower event id wins. +/// +/// Mirrors `buzz-db`'s `replace_parameterized_event`, which rejects an incoming +/// event when `created_at == accepted_ts && incoming_id >= accepted_id`. The +/// comparison is over the id's raw bytes there and its lowercase hex here — +/// same ordering, since hex encoding is monotonic over byte strings of equal +/// length and NIP-01 ids are always 32 bytes. +/// +/// A missing id on either side means the pair cannot be ordered the way the +/// relay orders it. That returns `false`: the retained row stands, the inbound +/// event is skipped, and nothing is decided from a fabricated key. The cost is +/// a stale row until a strictly newer event arrives; the alternative is a local +/// head the relay disagrees with. +fn inbound_wins_equal_timestamp(inbound: &RetainedEvent, retained: &RetainedEvent) -> bool { + match (&inbound.event_id, &retained.event_id) { + (Some(inbound_id), Some(retained_id)) => inbound_id < retained_id, + _ => false, + } +} + /// Load all retained persona events for a given pubkey. #[cfg(test)] pub fn get_retained_personas( @@ -312,7 +414,7 @@ pub fn get_retained_personas( ) -> Result, String> { let mut stmt = conn .prepare( - "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id FROM persona_events WHERE pubkey = ?1 ORDER BY d_tag", @@ -329,6 +431,7 @@ pub fn get_retained_personas( created_at: row.get(4)?, raw_event: row.get(5)?, pending_sync: row.get::<_, i32>(6)? != 0, + event_id: row.get(7)?, }) }) .map_err(|e| format!("failed to query retained events: {e}"))?; @@ -348,7 +451,7 @@ pub fn get_retained_personas( pub fn get_pending_sync(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( - "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id FROM persona_events WHERE pending_sync = 1 ORDER BY (kind != 5), created_at ASC", @@ -365,6 +468,7 @@ pub fn get_pending_sync(conn: &Connection) -> Result, String> created_at: row.get(4)?, raw_event: row.get(5)?, pending_sync: row.get::<_, i32>(6)? != 0, + event_id: row.get(7)?, }) }) .map_err(|e| format!("failed to query pending sync events: {e}"))?; @@ -440,7 +544,7 @@ pub fn get_retained_event( d_tag: &str, ) -> Result, String> { conn.query_row( - "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id FROM persona_events WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", params![kind, pubkey, d_tag], @@ -453,6 +557,7 @@ pub fn get_retained_event( created_at: row.get(4)?, raw_event: row.get(5)?, pending_sync: row.get::<_, i32>(6)? != 0, + event_id: row.get(7)?, }) }, ) @@ -461,474 +566,4 @@ pub fn get_retained_event( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn retention_scope_is_stable_and_separates_relay_and_owner() { - let base = Path::new("/tmp/buzz-retention-test"); - let owner_a = "a".repeat(64); - let owner_b = "b".repeat(64); - let community_a = scoped_retention_db_path(base, "wss://a.example/", &owner_a); - assert_eq!( - community_a, - scoped_retention_db_path(base, "wss://a.example", &owner_a) - ); - assert_ne!( - community_a, - scoped_retention_db_path(base, "wss://b.example", &owner_a) - ); - assert_ne!( - community_a, - scoped_retention_db_path(base, "wss://a.example", &owner_b) - ); - } - - #[test] - fn test_arrival_relay_matching_agrees_with_database_identity() { - let base = Path::new("/tmp/buzz-retention-test"); - let keys = nostr::Keys::generate(); - let owner = keys.public_key().to_hex(); - let scope = |relay: &str| RetentionScope { - db_path: scoped_retention_db_path(base, relay, &owner), - relay_url: relay.to_string(), - owner_keys: keys.clone(), - }; - let community_a = scoped_retention_db_path(base, "wss://a.example", &owner); - - // "Same relay" and "same database" must never disagree: every URL the - // match accepts has to hash to the scope's own db path, and every URL it - // rejects has to hash somewhere else. - for equivalent in ["wss://a.example", "wss://a.example/", " wss://a.example "] { - assert_eq!( - scope_for_arrival(scope("wss://a.example"), equivalent).map(|scope| scope.db_path), - Some(community_a.clone()), - "{equivalent}" - ); - assert_eq!( - scoped_retention_db_path(base, equivalent, &owner), - community_a, - "{equivalent}" - ); - } - - assert!( - scope_for_arrival(scope("wss://b.example"), "wss://a.example").is_none(), - "an event from community A must not be filed while community B is active" - ); - assert_ne!( - scoped_retention_db_path(base, "wss://b.example", &owner), - community_a - ); - } - - #[test] - fn concurrent_open_waits_for_initialization_lock() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("retention.db"); - let first = open_retention_db(&path).unwrap(); - first.execute_batch("BEGIN EXCLUSIVE").unwrap(); - - let second_path = path.clone(); - let second = std::thread::spawn(move || open_retention_db(&second_path)); - std::thread::sleep(std::time::Duration::from_millis(100)); - first.execute_batch("COMMIT").unwrap(); - - assert!(second.join().unwrap().is_ok()); - } - - fn test_db() -> Connection { - open_retention_db(Path::new(":memory:")).unwrap() - } - - fn sample_event() -> RetainedEvent { - RetainedEvent { - kind: 30175, - pubkey: "abc123".to_string(), - d_tag: "test-persona".to_string(), - content: r#"{"display_name":"Test"}"#.to_string(), - created_at: 1000, - raw_event: r#"{"id":"..."}"#.to_string(), - pending_sync: true, - } - } - - #[test] - fn retain_and_retrieve() { - let conn = test_db(); - let event = sample_event(); - retain_event(&conn, &event).unwrap(); - - let results = get_retained_personas(&conn, "abc123").unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].d_tag, "test-persona"); - assert_eq!(results[0].created_at, 1000); - assert!(results[0].pending_sync); - } - - #[test] - fn tombstone_retention_keys_are_distinct_across_kinds() { - // A persona slug, team id, and agent pubkey that all happen to equal - // "shared" must occupy DISTINCT kind:5 rows so one tombstone's pending - // publish never clobbers another's (F2c). - let conn = test_db(); - for target_kind in [30175u32, 30176, 30177] { - retain_event( - &conn, - &RetainedEvent { - kind: 5, - pubkey: "owner".to_string(), - d_tag: tombstone_retention_d_tag(target_kind, "shared"), - content: String::new(), - created_at: 1000, - raw_event: format!("{{\"k\":{target_kind}}}"), - pending_sync: true, - }, - ) - .unwrap(); - } - // Three distinct rows survive — no PK collision clobbered any of them. - for target_kind in [30175u32, 30176, 30177] { - let row = get_retained_event( - &conn, - 5, - "owner", - &tombstone_retention_d_tag(target_kind, "shared"), - ) - .unwrap(); - assert!( - row.is_some(), - "tombstone for kind {target_kind} was clobbered" - ); - } - } - - #[test] - fn upsert_replaces_newer() { - let conn = test_db(); - let mut event = sample_event(); - retain_event(&conn, &event).unwrap(); - - event.content = r#"{"display_name":"Updated"}"#.to_string(); - event.created_at = 2000; - retain_event(&conn, &event).unwrap(); - - let results = get_retained_personas(&conn, "abc123").unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].created_at, 2000); - assert!(results[0].content.contains("Updated")); - } - - #[test] - fn upsert_ignores_older() { - let conn = test_db(); - let mut event = sample_event(); - event.created_at = 2000; - retain_event(&conn, &event).unwrap(); - - event.content = r#"{"display_name":"Old"}"#.to_string(); - event.created_at = 1000; - retain_event(&conn, &event).unwrap(); - - let results = get_retained_personas(&conn, "abc123").unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].created_at, 2000); - assert!(!results[0].content.contains("Old")); - } - - #[test] - fn pending_sync_query() { - let conn = test_db(); - let mut event = sample_event(); - event.pending_sync = true; - retain_event(&conn, &event).unwrap(); - - let mut event2 = sample_event(); - event2.d_tag = "other".to_string(); - event2.pending_sync = false; - retain_event(&conn, &event2).unwrap(); - - let pending = get_pending_sync(&conn).unwrap(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].d_tag, "test-persona"); - } - - #[test] - fn test_mark_synced_matching_row_clears_flag() { - let conn = test_db(); - let event = sample_event(); - retain_event(&conn, &event).unwrap(); - - mark_synced(&conn, 30175, "abc123", "test-persona", 1000, &event.content).unwrap(); - - let pending = get_pending_sync(&conn).unwrap(); - assert!(pending.is_empty()); - - let results = get_retained_personas(&conn, "abc123").unwrap(); - assert_eq!(results.len(), 1); - assert!(!results[0].pending_sync); - } - - #[test] - fn test_mark_synced_stale_version_leaves_flag_set() { - let conn = test_db(); - let published = sample_event(); - retain_event(&conn, &published).unwrap(); - - // A newer edit lands at the same coordinate before the flush loop - // clears the version it published. - let mut newer = sample_event(); - newer.content = r#"{"display_name":"Edited"}"#.to_string(); - newer.created_at = 2000; - retain_event(&conn, &newer).unwrap(); - - // Clearing against the OLD version must not touch the newer pending row. - mark_synced( - &conn, - 30175, - "abc123", - "test-persona", - 1000, - &published.content, - ) - .unwrap(); - - let pending = get_pending_sync(&conn).unwrap(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].created_at, 2000); - } - - #[test] - fn test_delete_retained_event_removes_row() { - let conn = test_db(); - retain_event(&conn, &sample_event()).unwrap(); - - delete_retained_event(&conn, 30175, "abc123", "test-persona").unwrap(); - - assert!(get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .is_none()); - } - - #[test] - fn test_delete_retained_event_missing_row_is_noop() { - let conn = test_db(); - delete_retained_event(&conn, 30175, "abc123", "nonexistent").unwrap(); - } - - #[test] - fn has_retained_personas_works() { - let conn = test_db(); - assert!(!has_retained_personas(&conn, "abc123").unwrap()); - - let event = sample_event(); - retain_event(&conn, &event).unwrap(); - - assert!(has_retained_personas(&conn, "abc123").unwrap()); - assert!(!has_retained_personas(&conn, "other").unwrap()); - } - - #[test] - fn get_retained_event_by_coordinate() { - let conn = test_db(); - let event = sample_event(); - retain_event(&conn, &event).unwrap(); - - let found = get_retained_event(&conn, 30175, "abc123", "test-persona").unwrap(); - assert!(found.is_some()); - assert_eq!(found.unwrap().d_tag, "test-persona"); - - let not_found = get_retained_event(&conn, 30175, "abc123", "nonexistent").unwrap(); - assert!(not_found.is_none()); - } - - #[test] - fn idempotent_retain_same_timestamp() { - let conn = test_db(); - let event = sample_event(); - retain_event(&conn, &event).unwrap(); - retain_event(&conn, &event).unwrap(); - - let results = get_retained_personas(&conn, "abc123").unwrap(); - assert_eq!(results.len(), 1); - } - - #[test] - fn inbound_no_local_row_applies() { - let conn = test_db(); - let mut event = sample_event(); - event.pending_sync = false; - - assert_eq!( - retain_inbound_event(&conn, &event).unwrap(), - InboundOutcome::Applied - ); - - let row = get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .unwrap(); - assert_eq!(row.created_at, 1000); - assert!(!row.pending_sync); - } - - #[test] - fn inbound_equal_second_skips_and_preserves_pending() { - let conn = test_db(); - // Pending local edit at t=1000. - let local = sample_event(); - retain_event(&conn, &local).unwrap(); - - // Inbound at the SAME second with different content. - let inbound = RetainedEvent { - content: r#"{"display_name":"Remote"}"#.to_string(), - pending_sync: false, - ..sample_event() - }; - assert_eq!( - retain_inbound_event(&conn, &inbound).unwrap(), - InboundOutcome::Skipped - ); - - // Local pending row is untouched: flag preserved, content unchanged so - // the flush republishes and the relay resolves last-writer-wins. - let row = get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .unwrap(); - assert!(row.pending_sync); - assert!(row.content.contains("Test")); - } - - #[test] - fn inbound_strictly_newer_applies_and_clears_pending() { - let conn = test_db(); - // Pending local edit at t=1000. - let local = sample_event(); - retain_event(&conn, &local).unwrap(); - - // Inbound strictly newer with different content. - let inbound = RetainedEvent { - content: r#"{"display_name":"Remote"}"#.to_string(), - created_at: 2000, - pending_sync: false, - ..sample_event() - }; - assert_eq!( - retain_inbound_event(&conn, &inbound).unwrap(), - InboundOutcome::Applied - ); - - // Inbound wins: content replaced and pending cleared, so the stale - // local edit stops republishing instead of looping. - let row = get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .unwrap(); - assert_eq!(row.created_at, 2000); - assert!(!row.pending_sync); - assert!(row.content.contains("Remote")); - } - - #[test] - fn inbound_older_skips() { - let conn = test_db(); - let mut local = sample_event(); - local.created_at = 2000; - retain_event(&conn, &local).unwrap(); - - let inbound = RetainedEvent { - content: r#"{"display_name":"Stale"}"#.to_string(), - created_at: 1000, - pending_sync: false, - ..sample_event() - }; - assert_eq!( - retain_inbound_event(&conn, &inbound).unwrap(), - InboundOutcome::Skipped - ); - - let row = get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .unwrap(); - assert_eq!(row.created_at, 2000); - assert!(!row.content.contains("Stale")); - } - - #[test] - fn pending_sync_publishes_tombstones_before_replacements() { - // B5 resurrection race: a kind:5 retained in session N and the same - // coordinate's replacement 30175 retained on the next boot can sit - // pending together. The relay's a-tag deletion ignores timestamps, - // so the tombstone MUST publish first or it wipes the replacement. - let conn = test_db(); - let replacement = RetainedEvent { - kind: 30175, - created_at: 2000, - pending_sync: true, - ..sample_event() - }; - retain_event(&conn, &replacement).unwrap(); - let tombstone = RetainedEvent { - kind: 5, - d_tag: tombstone_retention_d_tag(30175, "test-persona"), - content: String::new(), - created_at: 1000, - pending_sync: true, - ..sample_event() - }; - retain_event(&conn, &tombstone).unwrap(); - - let pending = get_pending_sync(&conn).unwrap(); - assert_eq!(pending.len(), 2); - assert_eq!(pending[0].kind, 5, "tombstone first"); - assert_eq!(pending[1].kind, 30175, "replacement second"); - } - - #[test] - fn deferral_predicate_is_kind_and_pubkey_qualified() { - // Mid-sweep barrier semantics: a failed tombstone defers ONLY the - // replacement at its exact coordinate — same target kind, same pubkey. - use std::collections::HashSet; - - let failed: HashSet<(String, String)> = HashSet::from([( - "abc123".to_string(), - tombstone_retention_d_tag(30175, "test-persona"), - )]); - - // The covered replacement defers. - assert!(deferred_behind_failed_tombstone( - 30175, - "abc123", - "test-persona", - &failed - )); - // Kind-qualified: a coinciding slug under a DIFFERENT kind is a - // distinct coordinate (the cross-kind collision the retention d-tag - // encoding exists to prevent) — never deferred. - assert!(!deferred_behind_failed_tombstone( - 30177, - "abc123", - "test-persona", - &failed - )); - // Never crosses pubkeys. - assert!(!deferred_behind_failed_tombstone( - 30175, - "other-key", - "test-persona", - &failed - )); - // Never defers kind:5 rows, even at a "matching" retention key. - assert!(!deferred_behind_failed_tombstone( - 5, - "abc123", - "test-persona", - &failed - )); - // Unrelated d-tags publish normally. - assert!(!deferred_behind_failed_tombstone( - 30175, - "abc123", - "other-persona", - &failed - )); - } -} +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs b/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs index 1975f5d6df..abbb228526 100644 --- a/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs +++ b/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs @@ -113,6 +113,11 @@ pub fn migrate_legacy_retention_db( /// Owner-filtered because the flush loop only publishes rows matching the /// active owner anyway; a different identity's rows belong to that identity's /// scope, not this one. +/// +/// The legacy table predates `event_id`, so the ordering key is re-derived from +/// each row's stored event rather than read from a column. A row whose bytes do +/// not verify carries `None` and is treated as unresolved by the comparator — +/// same rule the in-place schema backfill applies. fn legacy_rows_for_owner( conn: &Connection, owner_pubkey: &str, @@ -128,13 +133,15 @@ fn legacy_rows_for_owner( let rows = stmt .query_map(params![owner_pubkey], |row| { + let raw_event: String = row.get(5)?; Ok(RetainedEvent { kind: row.get(0)?, pubkey: row.get(1)?, d_tag: row.get(2)?, content: row.get(3)?, created_at: row.get(4)?, - raw_event: row.get(5)?, + event_id: super::event_id_from_raw(&raw_event), + raw_event, pending_sync: row.get::<_, i32>(6)? != 0, }) }) diff --git a/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs b/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs index 75da221320..fbfda47928 100644 --- a/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs +++ b/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs @@ -16,6 +16,7 @@ fn pending_tombstone(d_tag: &str) -> RetainedEvent { content: String::new(), created_at: 1_700_000_000, raw_event: format!(r#"{{"kind":5,"d":"{d_tag}"}}"#), + event_id: None, pending_sync: true, } } @@ -141,6 +142,7 @@ fn test_post_upgrade_scoped_row_is_not_overwritten_by_its_legacy_ancestor() { content: r#"{"display_name":"Old"}"#.to_string(), created_at: 1_700_000_000, raw_event: r#"{"content":"old"}"#.to_string(), + event_id: None, pending_sync: true, }; seed_legacy(dir.path(), &[legacy_head]); @@ -157,6 +159,7 @@ fn test_post_upgrade_scoped_row_is_not_overwritten_by_its_legacy_ancestor() { content: r#"{"display_name":"New"}"#.to_string(), created_at: 1_700_000_500, raw_event: r#"{"content":"new"}"#.to_string(), + event_id: None, pending_sync: true, }, ) diff --git a/desktop/src-tauri/src/managed_agents/retention/schema.rs b/desktop/src-tauri/src/managed_agents/retention/schema.rs new file mode 100644 index 0000000000..c0d548aabd --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/schema.rs @@ -0,0 +1,119 @@ +//! Additive schema migration for the retention store. +//! +//! Three columns were added after the original seven-column +//! `persona_events` table shipped, so an upgraded install has to grow its +//! table in place: +//! +//! - `event_id` — the NIP-01 id of the row's own `raw_event`. Ordering has to +//! match the relay's NIP-33 comparator, which breaks an equal `created_at` +//! by lowest event id; without a real column every compare would have to +//! reparse `raw_event`. +//! - `baseline_event_id` / `baseline_content` — provenance for the record +//! currently on disk: which event last wrote it and what that event +//! published. This is what lets the boot pass tell "the user edited this +//! locally" from "this store never learned about the newer relay head", +//! instead of inferring intent from a content diff. +//! +//! # Crash and concurrency safety +//! +//! A cheap read-only probe decides whether anything is missing, and only then +//! does the migration open a `BEGIN EXCLUSIVE` transaction, re-probe inside +//! it, and apply the `ALTER TABLE`s together with the `event_id` backfill. +//! Serializing on the write lock is what makes two processes opening the same +//! database at once safe: the loser waits, re-probes, and finds nothing to do. +//! A crash mid-migration rolls back the whole transaction, so a column can +//! never exist with its backfill half-applied. + +use rusqlite::{Connection, TransactionBehavior}; + +/// Columns added after the initial `persona_events` shape. +const ADDED_COLUMNS: [(&str, &str); 3] = [ + ("event_id", "TEXT"), + ("baseline_event_id", "TEXT"), + ("baseline_content", "TEXT"), +]; + +/// Bring `persona_events` up to the current column set. +/// +/// A no-op on a database created by the current `CREATE TABLE` (and on any +/// database already migrated), so it stays on the cheap path for every open +/// after the first. +pub(super) fn migrate(conn: &mut Connection) -> Result<(), String> { + if missing_columns(conn)?.is_empty() { + return Ok(()); + } + + let transaction = conn + .transaction_with_behavior(TransactionBehavior::Exclusive) + .map_err(|e| format!("failed to open retention schema transaction: {e}"))?; + + // Re-probe under the write lock: a concurrent opener may have migrated the + // database while this one waited for the lock. + for (column, column_type) in missing_columns(&transaction)? { + transaction + .execute_batch(&format!( + "ALTER TABLE persona_events ADD COLUMN {column} {column_type}" + )) + .map_err(|e| format!("failed to add retention column {column}: {e}"))?; + } + backfill_event_ids(&transaction)?; + + transaction + .commit() + .map_err(|e| format!("failed to commit retention schema migration: {e}")) +} + +/// Which of [`ADDED_COLUMNS`] the table does not have yet, in declaration +/// order. +fn missing_columns(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare("SELECT name FROM pragma_table_info('persona_events')") + .map_err(|e| format!("failed to probe retention columns: {e}"))?; + let existing = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| format!("failed to read retention columns: {e}"))? + .collect::, _>>() + .map_err(|e| format!("failed to read retention column row: {e}"))?; + + Ok(ADDED_COLUMNS + .into_iter() + .filter(|(column, _)| !existing.iter().any(|name| name == column)) + .collect()) +} + +/// Recover `event_id` for rows written before the column existed. +/// +/// The id comes from re-deriving it out of the stored event +/// ([`super::event_id_from_raw`]), never from the JSON's own `id` field: a row +/// whose stored bytes do not hash and verify to the id they claim has no +/// trustworthy ordering key, and inventing one would let it win a comparator +/// tie it should lose. Such a row keeps `event_id IS NULL` and the comparator +/// treats it as unresolved instead. +fn backfill_event_ids(conn: &Connection) -> Result<(), String> { + let mut stmt = conn + .prepare("SELECT rowid, raw_event FROM persona_events WHERE event_id IS NULL") + .map_err(|e| format!("failed to prepare retention backfill query: {e}"))?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| format!("failed to read retention backfill rows: {e}"))? + .collect::, _>>() + .map_err(|e| format!("failed to read retention backfill row: {e}"))?; + + for (rowid, raw_event) in rows { + let Some(event_id) = super::event_id_from_raw(&raw_event) else { + continue; // unverifiable: no ordering key rather than a fabricated one + }; + conn.execute( + "UPDATE persona_events SET event_id = ?1 WHERE rowid = ?2", + rusqlite::params![event_id, rowid], + ) + .map_err(|e| format!("failed to backfill retention event id: {e}"))?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/retention/schema/tests.rs b/desktop/src-tauri/src/managed_agents/retention/schema/tests.rs new file mode 100644 index 0000000000..e8a23978f1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/schema/tests.rs @@ -0,0 +1,200 @@ +use super::*; +use crate::managed_agents::retention::open_retention_db; +use nostr::JsonUtil; +use std::path::Path; + +/// The seven-column table as it shipped before `event_id` existed. +const LEGACY_TABLE: &str = "CREATE TABLE persona_events ( + kind INTEGER NOT NULL, + pubkey TEXT NOT NULL, + d_tag TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + raw_event TEXT NOT NULL, + pending_sync INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (kind, pubkey, d_tag) +);"; + +fn legacy_db(path: &Path) -> Connection { + let conn = Connection::open(path).unwrap(); + conn.execute_batch(LEGACY_TABLE).unwrap(); + conn +} + +fn column_names(conn: &Connection) -> Vec { + let mut stmt = conn + .prepare("SELECT name FROM pragma_table_info('persona_events')") + .unwrap(); + let names = stmt + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + names +} + +/// A real signed event, so `raw_event` actually verifies against its id. +fn signed_event() -> nostr::Event { + let keys = nostr::Keys::generate(); + nostr::EventBuilder::text_note("agent config") + .sign_with_keys(&keys) + .unwrap() +} + +fn insert_legacy_row(conn: &Connection, d_tag: &str, raw_event: &str) { + conn.execute( + "INSERT INTO persona_events (kind, pubkey, d_tag, content, created_at, raw_event, pending_sync) + VALUES (30175, 'owner', ?1, '{}', 1000, ?2, 0)", + rusqlite::params![d_tag, raw_event], + ) + .unwrap(); +} + +fn stored_event_id(conn: &Connection, d_tag: &str) -> Option { + conn.query_row( + "SELECT event_id FROM persona_events WHERE d_tag = ?1", + rusqlite::params![d_tag], + |row| row.get::<_, Option>(0), + ) + .unwrap() +} + +#[test] +fn test_legacy_table_gains_every_added_column() { + let dir = tempfile::tempdir().unwrap(); + let mut conn = legacy_db(&dir.path().join("retention.db")); + for (column, _) in ADDED_COLUMNS { + assert!( + !column_names(&conn).contains(&column.to_string()), + "{column} should be absent before migrating" + ); + } + + migrate(&mut conn).unwrap(); + + let columns = column_names(&conn); + for (column, _) in ADDED_COLUMNS { + assert!(columns.contains(&column.to_string()), "missing {column}"); + } +} + +#[test] +fn test_migration_preserves_existing_rows() { + let dir = tempfile::tempdir().unwrap(); + let mut conn = legacy_db(&dir.path().join("retention.db")); + insert_legacy_row(&conn, "reviewer", r#"{"content":"unverifiable"}"#); + + migrate(&mut conn).unwrap(); + + let (content, created_at, pending): (String, i64, i64) = conn + .query_row( + "SELECT content, created_at, pending_sync FROM persona_events WHERE d_tag = 'reviewer'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(content, "{}"); + assert_eq!(created_at, 1000); + assert_eq!(pending, 0); +} + +#[test] +fn test_backfill_recovers_event_id_from_verifiable_row() { + let dir = tempfile::tempdir().unwrap(); + let mut conn = legacy_db(&dir.path().join("retention.db")); + let event = signed_event(); + insert_legacy_row(&conn, "reviewer", &event.as_json()); + + migrate(&mut conn).unwrap(); + + assert_eq!(stored_event_id(&conn, "reviewer"), Some(event.id.to_hex())); +} + +#[test] +fn test_backfill_leaves_unverifiable_row_unresolved() { + let dir = tempfile::tempdir().unwrap(); + let mut conn = legacy_db(&dir.path().join("retention.db")); + // Row shapes that must never be handed a fabricated ordering key: not + // JSON at all, valid JSON that is not an event, and an event whose + // self-asserted id does not match its own bytes. + insert_legacy_row(&conn, "not-json", "}{"); + insert_legacy_row(&conn, "not-an-event", r#"{"content":"old"}"#); + let mut forged: serde_json::Value = serde_json::from_str(&signed_event().as_json()).unwrap(); + forged["content"] = serde_json::json!("tampered after signing"); + insert_legacy_row(&conn, "forged", &forged.to_string()); + + migrate(&mut conn).unwrap(); + + for d_tag in ["not-json", "not-an-event", "forged"] { + assert_eq!(stored_event_id(&conn, d_tag), None, "{d_tag}"); + } +} + +#[test] +fn test_migration_is_idempotent_across_repeated_opens() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("retention.db"); + let event = signed_event(); + { + let conn = legacy_db(&path); + insert_legacy_row(&conn, "reviewer", &event.as_json()); + } + + // Every open after the first must find nothing to do and leave the + // backfilled id in place. + for _ in 0..3 { + let conn = open_retention_db(&path).unwrap(); + assert_eq!(stored_event_id(&conn, "reviewer"), Some(event.id.to_hex())); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM persona_events", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + } +} + +#[test] +fn test_fresh_database_needs_no_migration() { + let dir = tempfile::tempdir().unwrap(); + let mut conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + assert!( + missing_columns(&conn).unwrap().is_empty(), + "CREATE TABLE and ADDED_COLUMNS have diverged" + ); + migrate(&mut conn).unwrap(); +} + +#[test] +fn test_concurrent_open_of_unmigrated_database_migrates_once() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("retention.db"); + let event = signed_event(); + { + let conn = legacy_db(&path); + insert_legacy_row(&conn, "reviewer", &event.as_json()); + } + + // Both openers race the same unmigrated database; the exclusive + // transaction serializes them and the loser re-probes and no-ops. + let handles: Vec<_> = (0..2) + .map(|_| { + let path = path.clone(); + std::thread::spawn(move || open_retention_db(&path)) + }) + .collect(); + for handle in handles { + assert!(handle.join().unwrap().is_ok()); + } + + let conn = Connection::open(&path).unwrap(); + assert_eq!(stored_event_id(&conn, "reviewer"), Some(event.id.to_hex())); + let columns = column_names(&conn); + for (column, _) in ADDED_COLUMNS { + assert_eq!( + columns.iter().filter(|name| *name == column).count(), + 1, + "{column} added twice" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/retention/tests.rs b/desktop/src-tauri/src/managed_agents/retention/tests.rs new file mode 100644 index 0000000000..b06cc98f5f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/tests.rs @@ -0,0 +1,690 @@ +use super::*; + +#[test] +fn retention_scope_is_stable_and_separates_relay_and_owner() { + let base = Path::new("/tmp/buzz-retention-test"); + let owner_a = "a".repeat(64); + let owner_b = "b".repeat(64); + let community_a = scoped_retention_db_path(base, "wss://a.example/", &owner_a); + assert_eq!( + community_a, + scoped_retention_db_path(base, "wss://a.example", &owner_a) + ); + assert_ne!( + community_a, + scoped_retention_db_path(base, "wss://b.example", &owner_a) + ); + assert_ne!( + community_a, + scoped_retention_db_path(base, "wss://a.example", &owner_b) + ); +} + +#[test] +fn test_arrival_relay_matching_agrees_with_database_identity() { + let base = Path::new("/tmp/buzz-retention-test"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let scope = |relay: &str| RetentionScope { + db_path: scoped_retention_db_path(base, relay, &owner), + relay_url: relay.to_string(), + owner_keys: keys.clone(), + }; + let community_a = scoped_retention_db_path(base, "wss://a.example", &owner); + + // "Same relay" and "same database" must never disagree: every URL the + // match accepts has to hash to the scope's own db path, and every URL it + // rejects has to hash somewhere else. + for equivalent in ["wss://a.example", "wss://a.example/", " wss://a.example "] { + assert_eq!( + scope_for_arrival(scope("wss://a.example"), equivalent).map(|scope| scope.db_path), + Some(community_a.clone()), + "{equivalent}" + ); + assert_eq!( + scoped_retention_db_path(base, equivalent, &owner), + community_a, + "{equivalent}" + ); + } + + assert!( + scope_for_arrival(scope("wss://b.example"), "wss://a.example").is_none(), + "an event from community A must not be filed while community B is active" + ); + assert_ne!( + scoped_retention_db_path(base, "wss://b.example", &owner), + community_a + ); +} + +#[test] +fn concurrent_open_waits_for_initialization_lock() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("retention.db"); + let first = open_retention_db(&path).unwrap(); + first.execute_batch("BEGIN EXCLUSIVE").unwrap(); + + let second_path = path.clone(); + let second = std::thread::spawn(move || open_retention_db(&second_path)); + std::thread::sleep(std::time::Duration::from_millis(100)); + first.execute_batch("COMMIT").unwrap(); + + assert!(second.join().unwrap().is_ok()); +} + +fn test_db() -> Connection { + open_retention_db(Path::new(":memory:")).unwrap() +} + +fn sample_event() -> RetainedEvent { + RetainedEvent { + kind: 30175, + pubkey: "abc123".to_string(), + d_tag: "test-persona".to_string(), + content: r#"{"display_name":"Test"}"#.to_string(), + created_at: 1000, + raw_event: r#"{"id":"..."}"#.to_string(), + event_id: None, + pending_sync: true, + } +} + +#[test] +fn retain_and_retrieve() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].d_tag, "test-persona"); + assert_eq!(results[0].created_at, 1000); + assert!(results[0].pending_sync); +} + +#[test] +fn tombstone_retention_keys_are_distinct_across_kinds() { + // A persona slug, team id, and agent pubkey that all happen to equal + // "shared" must occupy DISTINCT kind:5 rows so one tombstone's pending + // publish never clobbers another's (F2c). + let conn = test_db(); + for target_kind in [30175u32, 30176, 30177] { + retain_event( + &conn, + &RetainedEvent { + kind: 5, + pubkey: "owner".to_string(), + d_tag: tombstone_retention_d_tag(target_kind, "shared"), + content: String::new(), + created_at: 1000, + raw_event: format!("{{\"k\":{target_kind}}}"), + event_id: None, + pending_sync: true, + }, + ) + .unwrap(); + } + // Three distinct rows survive — no PK collision clobbered any of them. + for target_kind in [30175u32, 30176, 30177] { + let row = get_retained_event( + &conn, + 5, + "owner", + &tombstone_retention_d_tag(target_kind, "shared"), + ) + .unwrap(); + assert!( + row.is_some(), + "tombstone for kind {target_kind} was clobbered" + ); + } +} + +#[test] +fn upsert_replaces_newer() { + let conn = test_db(); + let mut event = sample_event(); + retain_event(&conn, &event).unwrap(); + + event.content = r#"{"display_name":"Updated"}"#.to_string(); + event.created_at = 2000; + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].created_at, 2000); + assert!(results[0].content.contains("Updated")); +} + +#[test] +fn upsert_ignores_older() { + let conn = test_db(); + let mut event = sample_event(); + event.created_at = 2000; + retain_event(&conn, &event).unwrap(); + + event.content = r#"{"display_name":"Old"}"#.to_string(); + event.created_at = 1000; + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].created_at, 2000); + assert!(!results[0].content.contains("Old")); +} + +#[test] +fn pending_sync_query() { + let conn = test_db(); + let mut event = sample_event(); + event.pending_sync = true; + retain_event(&conn, &event).unwrap(); + + let mut event2 = sample_event(); + event2.d_tag = "other".to_string(); + event2.pending_sync = false; + retain_event(&conn, &event2).unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].d_tag, "test-persona"); +} + +#[test] +fn test_mark_synced_matching_row_clears_flag() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + mark_synced(&conn, 30175, "abc123", "test-persona", 1000, &event.content).unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert!(pending.is_empty()); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert!(!results[0].pending_sync); +} + +#[test] +fn test_mark_synced_stale_version_leaves_flag_set() { + let conn = test_db(); + let published = sample_event(); + retain_event(&conn, &published).unwrap(); + + // A newer edit lands at the same coordinate before the flush loop + // clears the version it published. + let mut newer = sample_event(); + newer.content = r#"{"display_name":"Edited"}"#.to_string(); + newer.created_at = 2000; + retain_event(&conn, &newer).unwrap(); + + // Clearing against the OLD version must not touch the newer pending row. + mark_synced( + &conn, + 30175, + "abc123", + "test-persona", + 1000, + &published.content, + ) + .unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].created_at, 2000); +} + +#[test] +fn test_delete_retained_event_removes_row() { + let conn = test_db(); + retain_event(&conn, &sample_event()).unwrap(); + + delete_retained_event(&conn, 30175, "abc123", "test-persona").unwrap(); + + assert!(get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .is_none()); +} + +#[test] +fn test_delete_retained_event_missing_row_is_noop() { + let conn = test_db(); + delete_retained_event(&conn, 30175, "abc123", "nonexistent").unwrap(); +} + +#[test] +fn has_retained_personas_works() { + let conn = test_db(); + assert!(!has_retained_personas(&conn, "abc123").unwrap()); + + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + assert!(has_retained_personas(&conn, "abc123").unwrap()); + assert!(!has_retained_personas(&conn, "other").unwrap()); +} + +#[test] +fn get_retained_event_by_coordinate() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + let found = get_retained_event(&conn, 30175, "abc123", "test-persona").unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().d_tag, "test-persona"); + + let not_found = get_retained_event(&conn, 30175, "abc123", "nonexistent").unwrap(); + assert!(not_found.is_none()); +} + +#[test] +fn idempotent_retain_same_timestamp() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); +} + +#[test] +fn inbound_no_local_row_applies() { + let conn = test_db(); + let mut event = sample_event(); + event.pending_sync = false; + + assert_eq!( + retain_inbound_event(&conn, &event).unwrap(), + InboundOutcome::Applied + ); + + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert_eq!(row.created_at, 1000); + assert!(!row.pending_sync); +} + +#[test] +fn inbound_equal_second_skips_and_preserves_pending() { + let conn = test_db(); + // Pending local edit at t=1000. + let local = sample_event(); + retain_event(&conn, &local).unwrap(); + + // Inbound at the SAME second with different content. + let inbound = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + pending_sync: false, + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Skipped + ); + + // Local pending row is untouched: flag preserved, content unchanged so + // the flush republishes and the relay resolves last-writer-wins. + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert!(row.pending_sync); + assert!(row.content.contains("Test")); +} + +#[test] +fn inbound_strictly_newer_applies_and_clears_pending() { + let conn = test_db(); + // Pending local edit at t=1000. + let local = sample_event(); + retain_event(&conn, &local).unwrap(); + + // Inbound strictly newer with different content. + let inbound = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + created_at: 2000, + pending_sync: false, + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + + // Inbound wins: content replaced and pending cleared, so the stale + // local edit stops republishing instead of looping. + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert_eq!(row.created_at, 2000); + assert!(!row.pending_sync); + assert!(row.content.contains("Remote")); +} + +#[test] +fn inbound_older_skips() { + let conn = test_db(); + let mut local = sample_event(); + local.created_at = 2000; + retain_event(&conn, &local).unwrap(); + + let inbound = RetainedEvent { + content: r#"{"display_name":"Stale"}"#.to_string(), + created_at: 1000, + pending_sync: false, + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Skipped + ); + + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert_eq!(row.created_at, 2000); + assert!(!row.content.contains("Stale")); +} + +/// Two ids at an equal timestamp, ordered as the relay orders them. +fn lower_and_higher_ids() -> (String, String) { + ("0".repeat(64), "f".repeat(64)) +} + +#[test] +fn test_equal_second_lower_id_inbound_replaces_non_pending_row() { + // The relay resolves an equal-`created_at` collision by lowest event id, so + // a lower-id inbound event IS the relay's head. A local cache that skipped + // it would hold a head the relay disagrees with, and every later + // disk-vs-head compare would inherit that error. + let conn = test_db(); + let (lower, higher) = lower_and_higher_ids(); + retain_event( + &conn, + &RetainedEvent { + pending_sync: false, + event_id: Some(higher), + ..sample_event() + }, + ) + .unwrap(); + + let inbound = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + pending_sync: false, + event_id: Some(lower.clone()), + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert!(row.content.contains("Remote")); + assert_eq!(row.event_id, Some(lower)); +} + +#[test] +fn test_equal_second_higher_id_inbound_loses_to_non_pending_row() { + // The mirror direction: the retained row is the relay's head, so a + // higher-id inbound event at the same second must not displace it. + let conn = test_db(); + let (lower, higher) = lower_and_higher_ids(); + retain_event( + &conn, + &RetainedEvent { + pending_sync: false, + event_id: Some(lower.clone()), + ..sample_event() + }, + ) + .unwrap(); + + let inbound = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + pending_sync: false, + event_id: Some(higher), + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Skipped + ); + + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert!(row.content.contains("Test")); + assert_eq!(row.event_id, Some(lower)); +} + +#[test] +fn test_equal_second_identical_id_is_a_noop() { + // A re-received echo of the row we already hold: `>=` on the relay side, + // so it loses, and nothing changes. + let conn = test_db(); + let (lower, _) = lower_and_higher_ids(); + retain_event( + &conn, + &RetainedEvent { + pending_sync: false, + event_id: Some(lower.clone()), + ..sample_event() + }, + ) + .unwrap(); + + let echo = RetainedEvent { + pending_sync: false, + event_id: Some(lower), + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &echo).unwrap(), + InboundOutcome::Skipped + ); +} + +#[test] +fn test_equal_second_lower_id_inbound_still_loses_to_pending_row() { + // Pending is durable local intent. Even when the inbound event would win + // the relay's id tie-break, applying it here would clear `pending_sync` and + // silently drop the user's unpublished edit — the exact class of loss this + // whole change exists to stop. Intent is arbitrated by the boot decision + // pass against a writer-consistent head, not by an id compare in the cache. + let conn = test_db(); + let (lower, higher) = lower_and_higher_ids(); + retain_event( + &conn, + &RetainedEvent { + pending_sync: true, + event_id: Some(higher.clone()), + ..sample_event() + }, + ) + .unwrap(); + + let inbound = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + pending_sync: false, + event_id: Some(lower), + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Skipped + ); + + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert!(row.pending_sync, "local intent must survive"); + assert!(row.content.contains("Test")); + assert_eq!(row.event_id, Some(higher)); +} + +#[test] +fn test_equal_second_unorderable_pair_leaves_retained_row_standing() { + // A legacy row whose `raw_event` never verified has no ordering key. Both + // directions of the missing-id case must skip rather than invent one: a + // fabricated key could win a tie the row should lose. + let conn = test_db(); + let (lower, _) = lower_and_higher_ids(); + + for (retained_id, inbound_id) in [ + (None, Some(lower.clone())), + (Some(lower.clone()), None), + (None, None), + ] { + delete_retained_event(&conn, 30175, "abc123", "test-persona").unwrap(); + retain_event( + &conn, + &RetainedEvent { + pending_sync: false, + event_id: retained_id.clone(), + ..sample_event() + }, + ) + .unwrap(); + + let inbound = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + pending_sync: false, + event_id: inbound_id.clone(), + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Skipped, + "retained={retained_id:?} inbound={inbound_id:?}" + ); + + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert!(row.content.contains("Test")); + } +} + +#[test] +fn test_strictly_newer_inbound_wins_regardless_of_event_id() { + // The id is only a tie-break. A strictly newer event wins even when its id + // is higher, and even against a pending row — the relay already superseded + // that edit, so republishing it would loop. + let conn = test_db(); + let (lower, higher) = lower_and_higher_ids(); + retain_event( + &conn, + &RetainedEvent { + pending_sync: true, + event_id: Some(lower), + ..sample_event() + }, + ) + .unwrap(); + + let inbound = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + created_at: 2000, + pending_sync: false, + event_id: Some(higher), + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert_eq!(row.created_at, 2000); + assert!(!row.pending_sync); +} + +#[test] +fn pending_sync_publishes_tombstones_before_replacements() { + // B5 resurrection race: a kind:5 retained in session N and the same + // coordinate's replacement 30175 retained on the next boot can sit + // pending together. The relay's a-tag deletion ignores timestamps, + // so the tombstone MUST publish first or it wipes the replacement. + let conn = test_db(); + let replacement = RetainedEvent { + kind: 30175, + created_at: 2000, + pending_sync: true, + ..sample_event() + }; + retain_event(&conn, &replacement).unwrap(); + let tombstone = RetainedEvent { + kind: 5, + d_tag: tombstone_retention_d_tag(30175, "test-persona"), + content: String::new(), + created_at: 1000, + pending_sync: true, + ..sample_event() + }; + retain_event(&conn, &tombstone).unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 2); + assert_eq!(pending[0].kind, 5, "tombstone first"); + assert_eq!(pending[1].kind, 30175, "replacement second"); +} + +#[test] +fn deferral_predicate_is_kind_and_pubkey_qualified() { + // Mid-sweep barrier semantics: a failed tombstone defers ONLY the + // replacement at its exact coordinate — same target kind, same pubkey. + use std::collections::HashSet; + + let failed: HashSet<(String, String)> = HashSet::from([( + "abc123".to_string(), + tombstone_retention_d_tag(30175, "test-persona"), + )]); + + // The covered replacement defers. + assert!(deferred_behind_failed_tombstone( + 30175, + "abc123", + "test-persona", + &failed + )); + // Kind-qualified: a coinciding slug under a DIFFERENT kind is a + // distinct coordinate (the cross-kind collision the retention d-tag + // encoding exists to prevent) — never deferred. + assert!(!deferred_behind_failed_tombstone( + 30177, + "abc123", + "test-persona", + &failed + )); + // Never crosses pubkeys. + assert!(!deferred_behind_failed_tombstone( + 30175, + "other-key", + "test-persona", + &failed + )); + // Never defers kind:5 rows, even at a "matching" retention key. + assert!(!deferred_behind_failed_tombstone( + 5, + "abc123", + "test-persona", + &failed + )); + // Unrelated d-tags publish normally. + assert!(!deferred_behind_failed_tombstone( + 30175, + "abc123", + "other-persona", + &failed + )); +} From 5b467b098516bb8cd7d720685adfa03551a47750 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 30 Jul 2026 11:20:05 -0400 Subject: [PATCH 2/8] fix(desktop): defer inbound events against a pending retention row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pending row is durable local intent — an edit this device made and has not yet published. Resolving inbound events by timestamp let a strictly newer event clear `pending_sync` and patch `personas.json`, destroying that edit. Timestamps are not evidence of newer intent: the laundering vector this change targets re-signs stale content at `max(now, head + 1)`, so every laundered revert arrives strictly newer. That made the untrusted input the one that always won. The pending check now precedes every ordering rule, and the new `Deferred` outcome keeps "lost the compare" distinct from "awaiting arbitration" for the boot decision pass. Callers gate on `Applied` so neither non-apply outcome reaches disk. Until that pass lands a pending row shadows genuinely newer remote edits for its coordinate; flush normally clears it within seconds, and an edit that can still be reconciled is worth more than one that cannot be recovered. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/commands/personas/inbound.rs | 15 ++- .../src-tauri/src/managed_agents/retention.rs | 78 +++++++---- .../src/managed_agents/retention/tests.rs | 122 +++++++++++++++--- 3 files changed, 165 insertions(+), 50 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index a2b9be7edd..846b0648e3 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -39,9 +39,9 @@ mod inbound_tests; /// /// The retention store decides whether the inbound event wins over a pending /// local edit (`retain_inbound_event`): `personas.json` is only patched when the -/// retain reports [`InboundOutcome::Applied`], so an equal-second collision with -/// a pending local edit leaves the local record — and its queued publish — -/// untouched. +/// retain reports [`InboundOutcome::Applied`], so an inbound event colliding +/// with a pending local edit — at any timestamp, newer included — leaves the +/// local record and its queued publish untouched. /// /// `arrival_relay_url` is the relay the calling subscription is bound to. The /// retention store this event belongs to is decided by the community that @@ -134,7 +134,7 @@ fn reconcile_inbound_persona_event_blocking( &conn, &RetainedEvent::inbound(kind, event.pubkey.to_hex(), d_tag.clone(), &event), )?; - if outcome == InboundOutcome::Skipped { + if outcome != InboundOutcome::Applied { return Ok(()); } @@ -250,8 +250,9 @@ fn reconcile_inbound_tombstone( .map_err(|error| error.to_string())?; // Resolve against the retained tombstone row (keyed by the target - // coordinate, F2c) so a re-received tombstone or one older than a pending - // local edit is a no-op. Scoped to the arrival community, so a workspace + // coordinate, F2c) so a re-received tombstone, one that loses the ordering + // compare, or one landing on a pending tombstone of its own is a no-op. + // Scoped to the arrival community, so a workspace // switch since arrival drops the tombstone instead of retaining it — and // deleting a record — in the wrong community's store. let Some(scope) = @@ -269,7 +270,7 @@ fn reconcile_inbound_tombstone( event, ), )?; - if outcome == InboundOutcome::Skipped { + if outcome != InboundOutcome::Applied { return Ok(()); } diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index f17082c0f1..9476b3a468 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -145,8 +145,11 @@ impl RetainedEvent { /// A row for a locally signed event that still has to reach the relay. /// /// Every event-derived field is read from the one event passed in, so a row - /// can never carry an `event_id` that describes different bytes than its own - /// `raw_event` — the ordering key and the payload cannot drift apart. + /// built this way cannot carry an `event_id` describing different bytes + /// than its own `raw_event`. The fields stay public for test construction, + /// so this is the rule every production writer follows rather than a type + /// guarantee: build rows through these constructors and the ordering key + /// and the payload cannot drift apart. pub fn pending(kind: u32, pubkey: String, d_tag: String, event: &nostr::Event) -> Self { Self::from_signed(kind, pubkey, d_tag, event, true) } @@ -303,15 +306,23 @@ pub fn retain_event(conn: &Connection, event: &RetainedEvent) -> Result<(), Stri /// Outcome of an inbound retain — whether the local store now reflects the /// inbound event, so the caller knows whether to patch `personas.json`. +/// +/// Only [`InboundOutcome::Applied`] authorizes a disk write. Both other +/// variants leave every local file untouched; they differ in what the +/// coordinate is waiting for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InboundOutcome { - /// The inbound event was applied (no row, or it was strictly newer than a - /// non-conflicting local row). The caller patches the local record store. + /// The inbound event was applied (no row, or it beat a non-pending local + /// row). The caller patches the local record store. Applied, - /// The inbound event was NOT applied: either it is older than the retained - /// row, or it collides at the same `created_at` with a pending local edit. - /// The local record store is left untouched and the pending edit republishes. + /// The inbound event lost the ordering compare against a non-pending + /// retained row, or the pair could not be ordered at all. Nothing changes. Skipped, + /// The coordinate carries a pending local edit, so the inbound event is + /// not resolved here at any timestamp. The retained row, its content, and + /// its `pending_sync` flag are left exactly as they were, for the boot + /// decision pass to arbitrate against a writer-consistent head. + Deferred, } /// Retain an event arriving FROM the relay, resolving it against any local row. @@ -319,37 +330,58 @@ pub enum InboundOutcome { /// Inbound events are already on the relay, so they are retained with /// `pending_sync = 0`. The resolution is deliberately narrower than /// [`retain_event`]'s blind newer-or-equal upsert, which would clobber a -/// pending local edit's `pending_sync` flag and silently drop its publish: +/// pending local edit's `pending_sync` flag and silently drop its publish. +/// +/// Resolution order — **pending is checked before any timestamp compare**: /// -/// - No local row, or inbound strictly newer (`created_at >`): apply the -/// inbound event, clearing `pending_sync`. Inbound wins; a stale local edit -/// the relay already superseded stops republishing instead of looping. -/// - Equal `created_at`, local row NOT pending: order by event id, matching the +/// - Local row pending, at ANY inbound `created_at`: defer. Nothing is written +/// and the caller does not patch disk. See the invariant below. +/// - No local row, or inbound strictly newer (`created_at >`) than a +/// non-pending row: apply the inbound event. +/// - Equal `created_at`, non-pending row: order by event id, matching the /// relay's own NIP-33 comparator — lowest id wins (`buzz-db`'s /// `replace_parameterized_event` rejects an incoming event whose id is `>=` /// the accepted one at an equal timestamp). Without this the local cache can /// disagree with the relay about which of two same-second events is the head, /// and every subsequent compare against that head inherits the error. -/// - Equal `created_at`, local row pending: skip regardless of id. A pending row -/// is durable local intent, and intent is arbitrated by the boot decision -/// pass against a writer-consistent head — never dropped by an id compare -/// here. (A re-received echo at equal time is also a no-op.) -/// - Inbound older: skip — nothing to change. +/// - Inbound older than a non-pending row: skip — nothing to change. /// /// An unorderable compare is never resolved by guessing: if either side's /// `event_id` is `None` at an equal timestamp, the local row stands. +/// +/// # Why a newer timestamp does not beat pending +/// +/// `pending_sync = 1` is durable local intent: an edit the user made and this +/// device has not yet published. A newer `created_at` is not evidence that the +/// remote content is newer *intent* — the laundering vector this whole change +/// exists to close mints exactly such events. A stale store re-signs its old +/// content at `monotonic_created_at = max(now, head + 1)`, so every laundered +/// revert arrives strictly newer than whatever it is reverting. Letting +/// strictly-newer inbound clear the flag would therefore destroy an unpublished +/// edit on precisely the input the fix targets. +/// +/// Deferring instead is not the final answer, only a safe one: which side wins +/// is decided by the boot decision pass, which arbitrates the pending row +/// against a writer-consistent head (and its paired tombstone) rather than +/// against whichever event happened to arrive first. Until that pass exists, a +/// pending row shadows genuinely newer remote edits for its coordinate — the +/// flush normally clears it within seconds, and preserving an edit that can +/// still be reconciled beats destroying one that cannot be recovered. pub fn retain_inbound_event( conn: &Connection, event: &RetainedEvent, ) -> Result { let existing = get_retained_event(conn, event.kind, &event.pubkey, &event.d_tag)?; + // Durable local intent outranks every ordering rule below it. + if existing.as_ref().is_some_and(|row| row.pending_sync) { + return Ok(InboundOutcome::Deferred); + } + let apply = match &existing { None => true, Some(row) if event.created_at > row.created_at => true, - Some(row) if event.created_at == row.created_at => { - !row.pending_sync && inbound_wins_equal_timestamp(event, row) - } + Some(row) if event.created_at == row.created_at => inbound_wins_equal_timestamp(event, row), // Older: stale. Some(_) => false, }; @@ -358,9 +390,9 @@ pub fn retain_inbound_event( return Ok(InboundOutcome::Skipped); } - // Inbound is strictly newer (or there was no row): overwrite and clear - // `pending_sync`. No upsert guard is needed — the Rust check above already - // established that this event wins. + // Inbound wins over a non-pending row (or there was no row): overwrite. + // `pending_sync` is written as 0 to state the row's published status + // explicitly; the guard above means it can never have been 1 here. conn.execute( "INSERT INTO persona_events (kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7) diff --git a/desktop/src-tauri/src/managed_agents/retention/tests.rs b/desktop/src-tauri/src/managed_agents/retention/tests.rs index b06cc98f5f..d420c9e7a7 100644 --- a/desktop/src-tauri/src/managed_agents/retention/tests.rs +++ b/desktop/src-tauri/src/managed_agents/retention/tests.rs @@ -310,7 +310,7 @@ fn inbound_no_local_row_applies() { } #[test] -fn inbound_equal_second_skips_and_preserves_pending() { +fn inbound_equal_second_defers_and_preserves_pending() { let conn = test_db(); // Pending local edit at t=1000. let local = sample_event(); @@ -324,11 +324,11 @@ fn inbound_equal_second_skips_and_preserves_pending() { }; assert_eq!( retain_inbound_event(&conn, &inbound).unwrap(), - InboundOutcome::Skipped + InboundOutcome::Deferred ); - // Local pending row is untouched: flag preserved, content unchanged so - // the flush republishes and the relay resolves last-writer-wins. + // Local pending row is untouched: flag preserved, content unchanged, for + // the boot decision pass to arbitrate against a writer-consistent head. let row = get_retained_event(&conn, 30175, "abc123", "test-persona") .unwrap() .unwrap(); @@ -337,13 +337,53 @@ fn inbound_equal_second_skips_and_preserves_pending() { } #[test] -fn inbound_strictly_newer_applies_and_clears_pending() { +fn inbound_strictly_newer_defers_to_pending_row() { + // The laundering vector this change exists to close mints STRICTLY NEWER + // events: a stale store re-signs old content at `max(now, head + 1)`. So a + // newer `created_at` is not evidence of newer intent, and applying it here + // would destroy the user's unpublished edit on exactly the input the fix + // targets. Deferral hands the pair to the boot decision pass instead. let conn = test_db(); // Pending local edit at t=1000. let local = sample_event(); retain_event(&conn, &local).unwrap(); // Inbound strictly newer with different content. + let inbound = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + created_at: 2000, + pending_sync: false, + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Deferred + ); + + // Every field of the pending row survives, and the caller does not patch + // disk: `Deferred` is not `Applied`. + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert_eq!(row.created_at, 1000); + assert!(row.pending_sync); + assert!(row.content.contains("Test")); + assert!(!row.content.contains("Remote")); +} + +#[test] +fn inbound_strictly_newer_applies_over_non_pending_row() { + let conn = test_db(); + // Published local row at t=1000 — no local intent to protect. + retain_event( + &conn, + &RetainedEvent { + pending_sync: false, + ..sample_event() + }, + ) + .unwrap(); + let inbound = RetainedEvent { content: r#"{"display_name":"Remote"}"#.to_string(), created_at: 2000, @@ -355,8 +395,6 @@ fn inbound_strictly_newer_applies_and_clears_pending() { InboundOutcome::Applied ); - // Inbound wins: content replaced and pending cleared, so the stale - // local edit stops republishing instead of looping. let row = get_retained_event(&conn, 30175, "abc123", "test-persona") .unwrap() .unwrap(); @@ -368,9 +406,16 @@ fn inbound_strictly_newer_applies_and_clears_pending() { #[test] fn inbound_older_skips() { let conn = test_db(); - let mut local = sample_event(); - local.created_at = 2000; - retain_event(&conn, &local).unwrap(); + // Published local row — the ordering rule decides, not the pending guard. + retain_event( + &conn, + &RetainedEvent { + created_at: 2000, + pending_sync: false, + ..sample_event() + }, + ) + .unwrap(); let inbound = RetainedEvent { content: r#"{"display_name":"Stale"}"#.to_string(), @@ -390,6 +435,41 @@ fn inbound_older_skips() { assert!(!row.content.contains("Stale")); } +#[test] +fn inbound_older_defers_to_pending_row() { + // Third timestamp arm: the guard precedes ordering, so even an inbound + // event that would lose on `created_at` alone reports `Deferred`, not + // `Skipped`. The distinction is what lets the boot decision pass tell + // "lost the compare" apart from "still owes arbitration". + let conn = test_db(); + retain_event( + &conn, + &RetainedEvent { + created_at: 2000, + ..sample_event() + }, + ) + .unwrap(); + + let inbound = RetainedEvent { + content: r#"{"display_name":"Stale"}"#.to_string(), + created_at: 1000, + pending_sync: false, + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Deferred + ); + + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert_eq!(row.created_at, 2000); + assert!(row.pending_sync); + assert!(!row.content.contains("Stale")); +} + /// Two ids at an equal timestamp, ordered as the relay orders them. fn lower_and_higher_ids() -> (String, String) { ("0".repeat(64), "f".repeat(64)) @@ -493,7 +573,7 @@ fn test_equal_second_identical_id_is_a_noop() { } #[test] -fn test_equal_second_lower_id_inbound_still_loses_to_pending_row() { +fn test_equal_second_lower_id_inbound_defers_to_pending_row() { // Pending is durable local intent. Even when the inbound event would win // the relay's id tie-break, applying it here would clear `pending_sync` and // silently drop the user's unpublished edit — the exact class of loss this @@ -519,7 +599,7 @@ fn test_equal_second_lower_id_inbound_still_loses_to_pending_row() { }; assert_eq!( retain_inbound_event(&conn, &inbound).unwrap(), - InboundOutcome::Skipped + InboundOutcome::Deferred ); let row = get_retained_event(&conn, 30175, "abc123", "test-persona") @@ -574,17 +654,18 @@ fn test_equal_second_unorderable_pair_leaves_retained_row_standing() { } #[test] -fn test_strictly_newer_inbound_wins_regardless_of_event_id() { - // The id is only a tie-break. A strictly newer event wins even when its id - // is higher, and even against a pending row — the relay already superseded - // that edit, so republishing it would loop. +fn test_strictly_newer_inbound_defers_regardless_of_event_id() { + // The id is only a tie-break, and it is never reached against a pending + // row: pending outranks every ordering rule, so a strictly newer inbound + // event defers whatever its id. Guards the case where the pending row holds + // the LOWER id — no reading of the tie-break can turn this into an apply. let conn = test_db(); let (lower, higher) = lower_and_higher_ids(); retain_event( &conn, &RetainedEvent { pending_sync: true, - event_id: Some(lower), + event_id: Some(lower.clone()), ..sample_event() }, ) @@ -599,14 +680,15 @@ fn test_strictly_newer_inbound_wins_regardless_of_event_id() { }; assert_eq!( retain_inbound_event(&conn, &inbound).unwrap(), - InboundOutcome::Applied + InboundOutcome::Deferred ); let row = get_retained_event(&conn, 30175, "abc123", "test-persona") .unwrap() .unwrap(); - assert_eq!(row.created_at, 2000); - assert!(!row.pending_sync); + assert_eq!(row.created_at, 1000); + assert!(row.pending_sync); + assert_eq!(row.event_id, Some(lower)); } #[test] From cd8f2440c5ac0b336ab5cf066150503c4a2da6a0 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 30 Jul 2026 18:02:30 -0400 Subject: [PATCH 3/8] fix(desktop): gate agent config publishes behind a boot resolution pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot reconcile re-signs whatever is on disk at `monotonic_created_at = max(now, head + 1)`, so a merely-stale store mints events strictly newer than the edits they revert. Last-write-wins then adopts the revert. A second install with a fresh retention database is exactly this state, which is why a dev build silently reverts config edited elsewhere. `created_at` cannot arbitrate it: the value the stale store carries is manufactured by the reconcile, not observed. The barrier arbitrates on PROVENANCE instead. A baseline records what this install last agreed was published at a coordinate; with no baseline, the install cannot show it ever agreed to anything there, so neither its content nor its timestamps are evidence of intent about the relay's state. `decide` runs four ordered gates: positive tombstone evidence, then the no-baseline park, then the queued-deletion timestamp compare, then the queued edit against the baseline. The ordering is load-bearing — the timestamp compare is sound only because the no-baseline gate already excluded every store that would win it spuriously. Both publication gates are enforced, because a coordinate's record and its queued tombstone occupy different primary keys and the flush loop reads them independently: gating one row computes a suppression and enforces half of it. The live gate is default-deny over the decision set, so a variant added later is withheld until deliberately admitted. The scan is per-owner and unfiltered by `#a` because the relay post-filters `#a` after its `LIMIT`, so absence is claimed only from a page shorter than the limit it asked for. Nothing is applied to disk here. `ApplyHead`, `RestoreFromRelay`, and `DeleteLocal` are decided, logged, and gated; resolving them is the user-driven step. On relay deployments with a replica-routed read pool, the head lookups and tombstone scan read best-available rather than writer-consistent; staleness within replication lag is a documented known limitation (see `head_lookup` and `tombstone_scan` module docs). The barrier's per-decision tracing line is the detection mechanism if it fires. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/event_sync.rs | 16 + .../managed_agents/canonical_projection.rs | 98 ++ .../canonical_projection/tests.rs | 199 ++++ .../src/managed_agents/config_barrier.rs | 284 ++++++ .../managed_agents/config_barrier/tests.rs | 199 ++++ .../src/managed_agents/config_sync.rs | 372 +++++++ .../src/managed_agents/config_sync/tests.rs | 950 ++++++++++++++++++ .../src-tauri/src/managed_agents/decision.rs | 377 +++++++ .../src/managed_agents/decision/tests.rs | 554 ++++++++++ .../src/managed_agents/head_lookup.rs | 140 +++ .../src/managed_agents/head_lookup/tests.rs | 144 +++ desktop/src-tauri/src/managed_agents/mod.rs | 6 + .../src-tauri/src/managed_agents/retention.rs | 197 +++- .../src/managed_agents/retention/schema.rs | 7 +- .../src/managed_agents/tombstone_scan.rs | 234 +++++ .../managed_agents/tombstone_scan/tests.rs | 224 +++++ 16 files changed, 3999 insertions(+), 2 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/canonical_projection.rs create mode 100644 desktop/src-tauri/src/managed_agents/canonical_projection/tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_barrier.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_barrier/tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_sync.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_sync/tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/decision.rs create mode 100644 desktop/src-tauri/src/managed_agents/decision/tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/head_lookup.rs create mode 100644 desktop/src-tauri/src/managed_agents/head_lookup/tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/tombstone_scan.rs create mode 100644 desktop/src-tauri/src/managed_agents/tombstone_scan/tests.rs diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index 6526fc7d70..d05701b1c3 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -25,19 +25,35 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys, db_path: /// `AppState::keys` mutex. The reconcile itself is still synchronous JSON, /// SQLite, and signing work, so it runs on the blocking pool rather than an /// async worker. +/// +/// # Ordering: reconcile, then barrier, and both before the flush publishes +/// +/// The reconcile marks changed coordinates `pending_sync = 1`; the boot barrier +/// ([`crate::managed_agents::config_barrier::run_boot_barrier`]) then decides +/// which of those may actually go out and gates the rest. Running the barrier +/// after the reconcile is what lets it see the rows the reconcile just queued — +/// the stale-store republish it exists to suppress is created right here. The +/// flush loop's first tick is 30s of relay lookups away, so in practice the gate +/// is set before anything publishes; if a flush did win the race, the barrier +/// still gates every later sweep. pub fn spawn_event_sync( app: tauri::AppHandle, owner_keys: nostr::Keys, db_path: std::path::PathBuf, ) { tauri::async_runtime::spawn(async move { + let barrier_app = app.clone(); if let Err(e) = tauri::async_runtime::spawn_blocking(move || { run_event_sync(&app, &owner_keys, &db_path); }) .await { eprintln!("buzz-desktop: event-sync: spawn_blocking failed: {e}"); + // The barrier still runs: a failed reconcile leaves whatever rows + // a previous boot queued, and gating those is exactly the case the + // barrier exists for. } + crate::managed_agents::config_barrier::run_boot_barrier(&barrier_app).await; }); } diff --git a/desktop/src-tauri/src/managed_agents/canonical_projection.rs b/desktop/src-tauri/src/managed_agents/canonical_projection.rs new file mode 100644 index 0000000000..45eeecbf9b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/canonical_projection.rs @@ -0,0 +1,98 @@ +//! The canonical published projection of a config record (V3.1). +//! +//! Every comparison the boot decision pass makes — disk against baseline, disk +//! against the relay head, baseline against what a publish would emit — runs on +//! this projection, never on whole JSON and never on content alone. +//! +//! Two reasons it cannot be either of those: +//! +//! - **Whole JSON is too wide.** Records carry install-local fields the relay +//! never sees (`source_dir`, `is_symlink`, `version`, timestamps, and for +//! managed agents `private_key_nsec`, `auth_tag`, `env_vars`). Comparing them +//! would report a difference for two records that publish identically, and +//! the pass would "repair" a divergence that does not exist. +//! - **Content alone is too narrow.** A persona's `shared` state rides in a +//! relay-significant *tag*, not in the content body. Two personas with equal +//! content but different `shared` tags publish as different events, so a +//! content-only compare would call them equal and skip a real publish. +//! +//! The projection is therefore exactly what publishing emits: the event content +//! plus the relay-significant tags, in a form that compares by value. + +use buzz_core_pkg::kind::{KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; + +use super::{ + agent_events::agent_event_content, persona_events::persona_event_content, + team_events::team_event_content, AgentDefinition, ManagedAgentRecord, TeamRecord, +}; + +/// What one coordinate publishes: the event content and the relay-significant +/// tags that travel with it. +/// +/// Equality is the decision-table's notion of "same": two projections are equal +/// exactly when publishing both would put the same thing on the relay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalProjection { + /// The serialized event content, byte-identical to what the builder emits. + pub content: String, + /// Whether the persona carries `["shared", "true"]`. Always `false` for + /// teams and managed agents, whose builders emit no `shared` tag. + pub shared: bool, +} + +impl CanonicalProjection { + /// The projection a persona (kind:30175) would publish. + /// + /// `shared` is read from the record rather than inferred, because it is the + /// field the builder turns into the tag. + pub fn for_persona(record: &AgentDefinition) -> Result { + Ok(Self { + content: serde_json::to_string(&persona_event_content(record)) + .map_err(|e| format!("failed to serialize persona projection: {e}"))?, + shared: record.shared, + }) + } + + /// The projection a team (kind:30176) would publish. + pub fn for_team(record: &TeamRecord) -> Result { + Ok(Self { + content: serde_json::to_string(&team_event_content(record)) + .map_err(|e| format!("failed to serialize team projection: {e}"))?, + shared: false, + }) + } + + /// The projection a managed agent (kind:30177) would publish. + /// + /// `agent_event_content` is the opt-IN no-secrets allowlist, so this can + /// never carry a private key, auth tag, or env var. + pub fn for_managed_agent(record: &ManagedAgentRecord) -> Result { + Ok(Self { + content: serde_json::to_string(&agent_event_content(record)) + .map_err(|e| format!("failed to serialize managed-agent projection: {e}"))?, + shared: false, + }) + } + + /// The projection an already-signed event carries. + /// + /// This is the head side of a disk-vs-head compare. `shared` is read with + /// the same fail-closed helper the relay's read-authorization uses, so a + /// malformed tag shape is treated as not-shared on both sides of the + /// comparison rather than only one. + pub fn from_event(event: &nostr::Event) -> Self { + let kind = event.kind.as_u16() as u32; + Self { + content: event.content.to_string(), + shared: kind == KIND_PERSONA && buzz_core_pkg::kind::event_is_shared(event), + } + } + + /// Whether `kind` is a config kind this projection knows how to build. + pub fn is_config_kind(kind: u32) -> bool { + matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) + } +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/canonical_projection/tests.rs b/desktop/src-tauri/src/managed_agents/canonical_projection/tests.rs new file mode 100644 index 0000000000..2e6af9b033 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/canonical_projection/tests.rs @@ -0,0 +1,199 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use super::*; +use crate::managed_agents::persona_events::build_persona_event; + +fn sample_persona() -> AgentDefinition { + AgentDefinition { + id: "test-persona".to_string(), + display_name: "Test Persona".to_string(), + avatar_url: Some("https://example.com/avatar.png".to_string()), + system_prompt: "You are a test assistant.".to_string(), + runtime: Some("goose".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + name_pool: vec!["Alpha".to_string()], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: Some("test-slug".to_string()), + catalog_source: None, + env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + } +} + +fn sample_team() -> TeamRecord { + TeamRecord { + id: "team-123".to_string(), + name: "Test Team".to_string(), + description: Some("A test team".to_string()), + instructions: Some("Coordinate carefully.".to_string()), + persona_ids: vec!["p1".to_string()], + is_builtin: false, + source_dir: Some(PathBuf::from("/local/only/path")), + is_symlink: true, + symlink_target: Some("/somewhere".to_string()), + version: Some("1.0".to_string()), + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + } +} + +/// The whole point of the projection: a field the relay never sees must not +/// register as a difference, or the decision pass "repairs" a divergence that +/// does not exist and republishes on every boot. +#[test] +fn local_only_persona_fields_do_not_change_the_projection() { + let base = sample_persona(); + let local_edit = AgentDefinition { + // None of these are in the published content. + is_active: !base.is_active, + source_team: Some("some-team".to_string()), + env_vars: BTreeMap::from([("OTHER".to_string(), "different".to_string())]), + created_at: "2099-01-01T00:00:00Z".to_string(), + updated_at: "2099-01-01T00:00:00Z".to_string(), + ..base.clone() + }; + + assert_eq!( + CanonicalProjection::for_persona(&base).unwrap(), + CanonicalProjection::for_persona(&local_edit).unwrap() + ); +} + +/// A published field must register, or a real edit is silently never published. +#[test] +fn published_persona_fields_change_the_projection() { + let base = sample_persona(); + for edited in [ + AgentDefinition { + system_prompt: "Different prompt.".to_string(), + ..base.clone() + }, + AgentDefinition { + model: Some("other-model".to_string()), + ..base.clone() + }, + AgentDefinition { + provider: Some("other-provider".to_string()), + ..base.clone() + }, + AgentDefinition { + display_name: "Renamed".to_string(), + ..base.clone() + }, + ] { + assert_ne!( + CanonicalProjection::for_persona(&base).unwrap(), + CanonicalProjection::for_persona(&edited).unwrap(), + "a published field edit must be visible to the decision pass" + ); + } +} + +/// `shared` rides in a relay-significant TAG, not the content body. A +/// content-only compare would call these two equal and skip a real publish — +/// this is the case that makes "content alone" wrong. +#[test] +fn shared_tag_difference_is_visible_even_when_content_matches() { + let private = sample_persona(); + let shared = AgentDefinition { + shared: true, + ..private.clone() + }; + + let private_projection = CanonicalProjection::for_persona(&private).unwrap(); + let shared_projection = CanonicalProjection::for_persona(&shared).unwrap(); + + assert_eq!( + private_projection.content, shared_projection.content, + "precondition: the content bodies are identical" + ); + assert_ne!( + private_projection, shared_projection, + "the shared tag must still make the projections differ" + ); +} + +/// Disk-vs-head is only meaningful if both sides project identically for a +/// record that was published unchanged. +#[test] +fn disk_projection_round_trips_through_a_signed_event() { + let keys = nostr::Keys::generate(); + for shared in [false, true] { + let record = AgentDefinition { + shared, + ..sample_persona() + }; + let event = build_persona_event(&record) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + + assert_eq!( + CanonicalProjection::for_persona(&record).unwrap(), + CanonicalProjection::from_event(&event), + "a freshly published record must project equal to its own event" + ); + } +} + +/// Teams and managed agents emit no `shared` tag, so their projections must +/// never claim one — otherwise a team would compare unequal to its own event. +#[test] +fn non_persona_kinds_are_never_shared() { + assert!( + !CanonicalProjection::for_team(&sample_team()) + .unwrap() + .shared + ); + + let keys = nostr::Keys::generate(); + let event = crate::managed_agents::team_events::build_team_event(&sample_team()) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + assert_eq!( + CanonicalProjection::for_team(&sample_team()).unwrap(), + CanonicalProjection::from_event(&event) + ); +} + +/// A team's install-local fields (`source_dir`, `is_symlink`, `version`) are +/// exactly the ones V3.10 cites as unrecoverable from a relay projection, so +/// they must not drive a publish decision either. +#[test] +fn local_only_team_fields_do_not_change_the_projection() { + let base = sample_team(); + let local_edit = TeamRecord { + source_dir: Some(PathBuf::from("/a/completely/different/path")), + is_symlink: !base.is_symlink, + symlink_target: None, + version: Some("9.9".to_string()), + updated_at: "2099-01-01T00:00:00Z".to_string(), + ..base.clone() + }; + + assert_eq!( + CanonicalProjection::for_team(&base).unwrap(), + CanonicalProjection::for_team(&local_edit).unwrap() + ); +} + +#[test] +fn config_kinds_are_recognized_and_others_are_not() { + for kind in [KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT] { + assert!(CanonicalProjection::is_config_kind(kind)); + } + // kind:5 deletions and chat messages are not config coordinates. + for kind in [1u32, 5, 30178] { + assert!(!CanonicalProjection::is_config_kind(kind)); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_barrier.rs b/desktop/src-tauri/src/managed_agents/config_barrier.rs new file mode 100644 index 0000000000..7c07bfdde2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_barrier.rs @@ -0,0 +1,284 @@ +//! The boot config barrier: gather every coordinate, decide, enforce. +//! +//! This is the entry point that makes [`super::decision::decide`] mean +//! something. It runs once per boot, before the flush loop can publish, and +//! wires the three layers together in the order V3.4 requires: +//! +//! 1. enumerate the coordinates this device knows about, from disk; +//! 2. run every relay lookup with NO store lock held; +//! 3. take `managed_agents_store_lock` once, re-check the scope, gather the +//! local half, decide, and enforce — all under that single guard. +//! +//! # Why the barrier exists +//! +//! Without it, boot re-signs local disk content at +//! `monotonic_created_at = max(now, head + 1)` and queues it for publish. A +//! store that is merely stale therefore mints events that are strictly newer +//! than the edits they revert, and the relay's last-write-wins resolution +//! adopts the revert. The barrier's job is to run BEFORE that publish and +//! decide, per coordinate, whether the local content is an edit worth +//! publishing or a stale copy that must be suppressed. +//! +//! # What it enforces, and what it does not +//! +//! It enforces exactly one thing: the publication gate +//! ([`super::config_sync::apply_gate`]). A coordinate whose decision blocks +//! publication is withheld from [`super::retention::get_pending_sync`] and +//! cannot reach the relay on this boot. Decisions that PATCH disk +//! (`ApplyHead`, `RestoreFromRelay`, `DeleteLocal`) are logged and gated but +//! not applied here — applying them is the resolution path (V3.6), which is +//! user-driven by design. Suppression is the half that stops the data loss; +//! adopting remote content is the half that needs a human. + +use std::collections::BTreeSet; + +use tauri::Manager; + +use super::{ + canonical_projection::CanonicalProjection, + config_sync::{local_state, run_decision_pass, CoordinateState}, + decision::HeadState, + head_lookup::{fetch_head, Coordinate}, + retention::{active_retention_scope, open_retention_db, pending_coordinates, RetentionScope}, + tombstone_scan::scan_tombstones, +}; +use crate::app_state::AppState; +use buzz_core_pkg::kind::{KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; + +/// Run the boot barrier for the active scope, best-effort. +/// +/// Failures are logged and swallowed: a barrier that cannot run must not block +/// boot, and its absence is not itself dangerous — it leaves the pre-barrier +/// behaviour, which is what every prior release did. +pub async fn run_boot_barrier(app: &tauri::AppHandle) { + let state = app.state::(); + let scope = match active_retention_scope(app, &state) { + Ok(scope) => scope, + Err(error) => { + tracing::warn!(target: "buzz::config_sync", %error, "boot barrier skipped: no active scope"); + return; + } + }; + + if let Err(error) = run_boot_barrier_for_scope(app, &state, &scope).await { + tracing::warn!(target: "buzz::config_sync", %error, "boot barrier failed"); + } +} + +/// Withhold every queued publish that has no baseline, before any network I/O. +/// +/// This is V3.9's legacy-pending quarantine, and it is also what makes the +/// barrier safe against its own latency. The barrier's decisions need a +/// per-coordinate relay lookup plus a full tombstone scan; the flush loop needs +/// only a timer. On a slow network the flush can therefore win the race and +/// publish exactly the rows the barrier was about to gate. +/// +/// Quarantining first inverts that: the gate is closed synchronously, before +/// the first round trip, and the decision pass re-opens it for the coordinates +/// that earn it. A row is either provably publishable or it is not going out. +/// +/// Scoped to rows with no baseline because those are precisely the unprovable +/// ones: a legacy row queued before the baseline column existed, and a row +/// queued by a second store whose retention database has never agreed to +/// anything at that coordinate. A row WITH a baseline is left alone — its +/// arbitration is decidable, and closing its gate here would stall an ordinary +/// edit behind a scan it does not need. +/// +/// Returns how many rows it withheld. +fn quarantine_unprovable_pending( + conn: &rusqlite::Connection, + owner_pubkey: &str, +) -> Result { + conn.execute( + "UPDATE persona_events SET publish_blocked = 1 + WHERE pending_sync = 1 AND pubkey = ?1 AND baseline_event_id IS NULL", + rusqlite::params![owner_pubkey], + ) + .map_err(|error| format!("failed to quarantine unprovable pending rows: {error}")) +} + +async fn run_boot_barrier_for_scope( + app: &tauri::AppHandle, + state: &AppState, + scope: &RetentionScope, +) -> Result<(), String> { + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + + // Enumerate BEFORE the lookups: the set of coordinates to decide is the + // union of what disk holds and what is already queued for publish. A + // pending row can outlive its record (deleted persona, removed agent, or a + // tombstone), and those rows publish with nothing on disk to enumerate + // them — exactly the ones that most need gating. The gate must see + // everything the publisher can see, and the publisher reads the table. + let mut disk = disk_projections(app)?; + { + let conn = open_retention_db(&scope.db_path)?; + let quarantined = quarantine_unprovable_pending(&conn, &owner_pubkey)?; + if quarantined > 0 { + tracing::info!( + target: "buzz::config_sync", + quarantined, + "withheld pending rows with no baseline pending the decision pass" + ); + } + + let known: BTreeSet<(u32, String)> = disk + .iter() + .map(|(coordinate, _)| (coordinate.kind, coordinate.d_tag.clone())) + .collect(); + for (kind, d_tag) in pending_coordinates(&conn, &owner_pubkey)? { + if !known.contains(&(kind, d_tag.clone())) { + // No disk record: the projection is `None`, which is what the + // table reads as "absent locally". + disk.push((Coordinate { kind, d_tag }, None)); + } + } + } + + // Phase 1 — relay lookups, no lock held. Each head read is an exact + // per-coordinate query; a failure becomes `LookupFailed` + // rather than an absence, so a flaky network can never be read as a + // deletion. The tombstone scan runs once for the whole owner. + let api_base_url = crate::relay::relay_http_base_url(&scope.relay_url); + let mut heads: Vec<(Coordinate, HeadState)> = Vec::with_capacity(disk.len()); + for (coordinate, _) in &disk { + heads.push(( + coordinate.clone(), + fetch_head(state, scope, &api_base_url, coordinate).await, + )); + } + let tombstones = scan_tombstones(state, scope, &api_base_url).await; + + // Phase 2 — one guard for the whole decision pass. + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + // Phase 3 — re-resolve the scope under the lock. `apply_workspace` can swap + // the relay and keys mid-flight without taking this lock, so without this + // check a completed pull from community A could patch community B's store. + let current = active_retention_scope(app, state)?; + if current.db_path != scope.db_path { + tracing::info!( + target: "buzz::config_sync", + "boot barrier abandoned: workspace switched during relay lookups" + ); + return Ok(()); + } + + let conn = open_retention_db(&scope.db_path)?; + let mut states: Vec = Vec::with_capacity(disk.len()); + for ((coordinate, projection), (_, head)) in disk.into_iter().zip(heads) { + let tombstone = tombstones.evidence(&coordinate); + let observation = local_state( + &conn, + &owner_pubkey, + &coordinate, + projection, + head, + tombstone, + )?; + states.push(CoordinateState { + coordinate, + observation, + }); + } + + run_decision_pass(&conn, &owner_pubkey, &states)?; + Ok(()) +} + +/// Read the three record stores and enumerate their coordinates. +fn disk_projections( + app: &tauri::AppHandle, +) -> Result)>, String> { + use super::{load_managed_agents, load_teams, personas::load_personas}; + + Ok(project_records( + &load_personas(app)?, + &load_teams(app)?, + &load_managed_agents(app)?, + )) +} + +/// Every config coordinate the given records occupy, with what each would +/// publish. +/// +/// Uses the same d-tag derivations the publish paths use, so a coordinate can +/// never be enumerated under a key that differs from the one it would publish +/// under. Built-in records are excluded: they come from code, are never +/// published, and have no relay head to compare against. +/// +/// A record whose projection cannot be built is skipped with a warning rather +/// than failing the pass — one malformed record must not stop the barrier from +/// protecting every other coordinate. +fn project_records( + personas: &[super::AgentDefinition], + teams: &[super::TeamRecord], + agents: &[super::ManagedAgentRecord], +) -> Vec<(Coordinate, Option)> { + let mut out: Vec<(Coordinate, Option)> = Vec::new(); + let mut seen: BTreeSet<(u32, String)> = BTreeSet::new(); + + let mut push = |kind: u32, d_tag: String, projection: Result| { + match projection { + Ok(projection) => { + // Two records can normalize onto one d-tag. Deciding the + // coordinate twice would gate it twice from the same inputs, + // so the first wins and the collision is visible in the log. + if seen.insert((kind, d_tag.clone())) { + out.push((Coordinate { kind, d_tag }, Some(projection))); + } else { + tracing::warn!( + target: "buzz::config_sync", + kind, + d_tag = %d_tag, + "duplicate coordinate on disk; deciding the first record only" + ); + } + } + Err(error) => { + tracing::warn!( + target: "buzz::config_sync", + kind, + d_tag = %d_tag, + %error, + "skipping coordinate with an unbuildable projection" + ); + } + } + }; + + for record in personas.iter().filter(|record| !record.is_builtin) { + push( + KIND_PERSONA, + super::persona_events::persona_d_tag(record), + CanonicalProjection::for_persona(record), + ); + } + for record in teams.iter().filter(|record| !record.is_builtin) { + push( + KIND_TEAM, + record.id.clone(), + CanonicalProjection::for_team(record), + ); + } + for record in agents + .iter() + // A key-less agent has no coordinate yet: its d-tag IS its pubkey, and + // it mints one on first start. + .filter(|record| !record.pubkey.is_empty()) + { + push( + KIND_MANAGED_AGENT, + record.pubkey.clone(), + CanonicalProjection::for_managed_agent(record), + ); + } + + out +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/config_barrier/tests.rs b/desktop/src-tauri/src/managed_agents/config_barrier/tests.rs new file mode 100644 index 0000000000..da49441dfa --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_barrier/tests.rs @@ -0,0 +1,199 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use super::*; +use crate::managed_agents::{AgentDefinition, ManagedAgentRecord, TeamRecord}; + +fn persona(id: &str, slug: Option<&str>, builtin: bool) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "prompt".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: builtin, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: slug.map(str::to_string), + catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } +} + +fn team(id: &str, builtin: bool) -> TeamRecord { + TeamRecord { + id: id.to_string(), + name: "Team".to_string(), + description: None, + instructions: None, + persona_ids: Vec::new(), + is_builtin: builtin, + source_dir: Some(PathBuf::from("/local/only")), + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } +} + +fn agent(pubkey: &str) -> ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "{pubkey}", + "name": "Agent", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "You are a test agent.", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }}"# + )) + .unwrap() +} + +fn coordinates(records: &[(Coordinate, Option)]) -> Vec<(u32, String)> { + records + .iter() + .map(|(coordinate, _)| (coordinate.kind, coordinate.d_tag.clone())) + .collect() +} + +/// All three config kinds must be enumerated. A kind the barrier does not +/// enumerate is a kind it can never suppress, so the revert bug would survive +/// for exactly those records. +#[test] +fn test_all_three_config_kinds_are_enumerated() { + let projected = project_records( + &[persona("p1", None, false)], + &[team("t1", false)], + &[agent("agentpubkey")], + ); + + assert_eq!( + coordinates(&projected), + vec![ + (KIND_PERSONA, "p1".to_string()), + (KIND_TEAM, "t1".to_string()), + (KIND_MANAGED_AGENT, "agentpubkey".to_string()), + ] + ); +} + +/// Built-ins ship in the binary and are never published, so a head lookup for +/// one would always report `Absent` and gate a coordinate that has nothing to +/// gate. +#[test] +fn test_builtin_records_are_not_enumerated() { + let projected = project_records( + &[persona("builtin", None, true), persona("real", None, false)], + &[team("builtin-team", true), team("real-team", false)], + &[], + ); + + assert_eq!( + coordinates(&projected), + vec![ + (KIND_PERSONA, "real".to_string()), + (KIND_TEAM, "real-team".to_string()), + ] + ); +} + +/// A key-less agent has no coordinate at all — its d-tag IS its pubkey, minted +/// on first start. Enumerating it would look up `{"#d": [""]}`. +#[test] +fn test_keyless_agents_are_not_enumerated() { + let projected = project_records(&[], &[], &[agent(""), agent("haskey")]); + + assert_eq!( + coordinates(&projected), + vec![(KIND_MANAGED_AGENT, "haskey".to_string())] + ); +} + +/// The enumerated d-tag must be the one the publish path would use, or the +/// barrier looks up a head for a coordinate this device never writes to and +/// reads a spurious absence. `persona_d_tag` prefers the team slug and +/// normalizes it. +#[test] +fn test_persona_coordinate_uses_the_published_d_tag_derivation() { + let projected = project_records(&[persona("uuid-id", Some("CodeReviewer"), false)], &[], &[]); + + assert_eq!( + coordinates(&projected), + vec![(KIND_PERSONA, "codereviewer".to_string())] + ); +} + +/// Two personas can normalize onto one d-tag. Deciding it twice would gate the +/// same coordinate from the same inputs twice; the second pass is noise. +#[test] +fn test_duplicate_coordinates_are_decided_once() { + let projected = project_records( + &[ + persona("first", Some("Shared"), false), + persona("second", Some("shared"), false), + ], + &[], + &[], + ); + + assert_eq!( + coordinates(&projected), + vec![(KIND_PERSONA, "shared".to_string())] + ); +} + +/// A d-tag collision ACROSS kinds is not a duplicate: `30175:x` and `30176:x` +/// are different coordinates with different heads, and collapsing them would +/// let a team's head answer a persona's lookup. +#[test] +fn test_same_d_tag_on_different_kinds_are_separate_coordinates() { + let projected = project_records(&[persona("same", None, false)], &[team("same", false)], &[]); + + assert_eq!( + coordinates(&projected), + vec![ + (KIND_PERSONA, "same".to_string()), + (KIND_TEAM, "same".to_string()), + ] + ); +} + +/// The projection carried into the decision must be what publishing emits, or +/// every disk-vs-head compare is against the wrong value. +#[test] +fn test_projection_matches_what_publishing_would_emit() { + let record = persona("p1", None, false); + let projected = project_records(std::slice::from_ref(&record), &[], &[]); + + assert_eq!( + projected[0].1, + Some(CanonicalProjection::for_persona(&record).unwrap()) + ); +} + +/// Empty stores are the fresh-install case: no coordinates, no lookups, no +/// gate writes. +#[test] +fn test_no_records_yields_no_coordinates() { + assert!(project_records(&[], &[], &[]).is_empty()); +} diff --git a/desktop/src-tauri/src/managed_agents/config_sync.rs b/desktop/src-tauri/src/managed_agents/config_sync.rs new file mode 100644 index 0000000000..49876609b8 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_sync.rs @@ -0,0 +1,372 @@ +//! Boot config sync: gather each coordinate's observable state, decide, enforce. +//! +//! This is the layer between the pure decision table +//! ([`super::decision::decide`]) and the stores it reads. It owns two things +//! the table deliberately does not: WHERE each input comes from, and the +//! ordering guarantees around reading them. +//! +//! # Ordering (V3.4) +//! +//! No network I/O may happen while the store lock is held, and no decision may +//! be made from inputs gathered under a different workspace: +//! +//! 1. capture `(relay, owner, db path)` as a scope snapshot; +//! 2. run the relay lookups with NO lock held; +//! 3. take `managed_agents_store_lock` for the whole decision pass; +//! 4. re-resolve the active scope and abandon the pass if it moved; +//! 5. evaluate the table and write, still under that one guard. +//! +//! Step 4 is load-bearing rather than defensive: `apply_workspace` can swap the +//! relay and keys mid-flight and does not take this lock, so without the +//! recheck a completed pull from community A could patch community B's shared +//! disk store. + +use super::{ + canonical_projection::CanonicalProjection, + decision::{Decision, Head, HeadState, Observation, Resolution, TombstoneEvidence}, + head_lookup::Coordinate, + retention::{get_baseline, get_retained_event, RetainedEvent}, +}; +use rusqlite::Connection; + +/// Everything the decision table needs about one coordinate, plus the identity +/// of the coordinate itself. +#[derive(Debug, Clone)] +pub struct CoordinateState { + pub coordinate: Coordinate, + pub observation: Observation, +} + +/// Read the locally-known half of a coordinate's state: the queued publish, any +/// queued deletion, and the baseline. +/// +/// The head and the tombstone evidence come from the relay and are supplied by +/// the caller, which has already run those lookups outside the lock. +/// +/// The coordinate's own row and its paired tombstone row occupy different +/// primary keys and are reported separately, because the table resolves them on +/// different evidence — a deletion by timestamp against the head, an edit +/// against the baseline. Collapsing them into one flag was the cross-coordinate +/// hole called out in review; reporting only the live row is the same hole. +pub fn local_state( + conn: &Connection, + owner_pubkey: &str, + coordinate: &Coordinate, + disk: Option, + head: HeadState, + tombstone: TombstoneEvidence, +) -> Result { + let row = get_retained_event(conn, coordinate.kind, owner_pubkey, &coordinate.d_tag)?; + let tombstone_row = get_retained_event( + conn, + buzz_core_pkg::kind::KIND_DELETION, + owner_pubkey, + &super::retention::tombstone_retention_d_tag(coordinate.kind, &coordinate.d_tag), + )?; + + Ok(Observation { + disk, + queued: queued_projection(row.as_ref()), + head, + baseline: baseline_projection(conn, owner_pubkey, coordinate, row.as_ref())?, + queued_deletion_at: tombstone_row + .as_ref() + .filter(|row| row.pending_sync) + .map(|row| row.created_at), + tombstone, + }) +} + +/// What a coordinate's queued row would publish, or `None` when nothing is +/// queued there. +/// +/// Built from the retained row's own `raw_event` rather than from disk: the +/// flush loop publishes exactly those bytes, so those are the bytes the +/// baseline arbitration has to be about. A row whose stored event will not +/// parse reports `None` — it cannot publish either, since the flush loop parses +/// the same field and fails the row. +fn queued_projection(row: Option<&RetainedEvent>) -> Option { + use nostr::JsonUtil; + let row = row.filter(|row| row.pending_sync)?; + let event = nostr::Event::from_json(&row.raw_event).ok()?; + Some(CanonicalProjection::from_event(&event)) +} + +/// The baseline projection for a coordinate, or `None` when this install has +/// no provenance for it. +/// +/// The stored baseline is the CONTENT that was published; the `shared` half of +/// the projection is recovered from the baseline event's own tags via the +/// retained row, so a baseline comparison sees the same two fields a head +/// comparison does. +fn baseline_projection( + conn: &Connection, + owner_pubkey: &str, + coordinate: &Coordinate, + row: Option<&RetainedEvent>, +) -> Result, String> { + let Some((baseline_event_id, baseline_content)) = + get_baseline(conn, coordinate.kind, owner_pubkey, &coordinate.d_tag)? + else { + return Ok(None); + }; + + // Recover `shared` from the retained event when the row still holds the + // baseline event; otherwise the tag state is unknown and the baseline is + // only usable for kinds that never carry one. + let shared = row + .filter(|row| row.event_id.as_deref() == Some(baseline_event_id.as_str())) + .and_then(|row| { + use nostr::JsonUtil; + nostr::Event::from_json(&row.raw_event).ok() + }) + .map(|event| CanonicalProjection::from_event(&event).shared) + .unwrap_or(false); + + Ok(Some(CanonicalProjection { + content: baseline_content, + shared, + })) +} + +/// Resolve every gathered coordinate, pairing each resolution with its target. +/// +/// Kept separate from execution so a caller can log or inspect the full plan +/// before anything is written — and so the mapping is testable end to end +/// without a relay or a disk store. +pub fn plan(states: &[CoordinateState]) -> Vec<(Coordinate, Resolution)> { + states + .iter() + .map(|state| { + ( + state.coordinate.clone(), + super::decision::decide(&state.observation), + ) + }) + .collect() +} + +/// Whether a resolution must withhold the coordinate's LIVE row from the flush +/// loop. +/// +/// The gate is DEFAULT-DENY over the decision set: it lists what may publish, +/// not what may not. Every publishing decision names itself here, so a variant +/// added later is withheld until someone deliberately admits it — the opposite +/// default would let a new decision reach the relay by omission. +/// +/// Clearing the gate is as load-bearing as setting it: a coordinate suppressed +/// on one boot must resume publishing once the condition that suppressed it is +/// gone, or the gate latches shut permanently and the record strands. +pub fn blocks_publication(resolution: &Resolution) -> bool { + match resolution.decision { + // The one decision that publishes local content. + Decision::PublishLocalEdit => false, + // The deletion publishes from the TOMBSTONE row, not this one. The + // live row is the thing being deleted, so it must stay withheld: + // publishing it would race its own tombstone at the same coordinate. + Decision::PublishDeletion => true, + // Adopts the relay's version onto disk, so nothing local is going out. + // Only reachable with a baseline present and nothing queued (gate D + // takes a queued row first), so releasing strands nothing. + Decision::ApplyHead => false, + // Only reachable with NO local record and nothing queued, so there is + // nothing to withhold — and nothing to strand by withholding. Blocked + // rather than released because it is the one adopt-the-relay cell + // reachable with no baseline, and a coordinate with no provenance + // against a live head must not leave an open gate behind for a row + // queued after the pass. + Decision::RestoreFromRelay => true, + // `StampBaseline` is easy to miss: disk already equals the head, so + // publishing would push byte-identical content and burn a + // `monotonic_created_at` bump for nothing. + Decision::StampBaseline + | Decision::SuppressPublish + | Decision::Park(_) + | Decision::DeleteLocal + | Decision::Defer => true, + } +} + +/// Whether a resolution must withhold the coordinate's queued TOMBSTONE row. +/// +/// A queued deletion does not live at the coordinate it deletes: it is retained +/// at `(5, owner, ":")`, a different primary key. +/// Gating only [`blocks_publication`]'s row would therefore compute a +/// suppression and enforce it on the wrong row, leaving the tombstone free to +/// publish — the exact "decorative gate" failure this pass exists to avoid, and +/// the worse direction of it: a deletion that escapes the gate destroys a head +/// rather than merely reverting it. +/// +/// Exactly one decision releases it. Every other outcome — including the +/// timestamp arbitration falling through to the live cells — means the queued +/// deletion did not win, so it stays withheld. +pub fn blocks_tombstone_publication(resolution: &Resolution) -> bool { + resolution.decision != Decision::PublishDeletion +} + +/// Log one resolution with the inputs that produced it, then enforce BOTH +/// publication gates. +/// +/// Two rows, not one: the coordinate's live row and its queued tombstone row +/// occupy different primary keys, and the flush loop reads them independently. +/// A single `set_publish_blocked` call therefore enforces half a decision. +/// +/// The log line is a hard requirement, not debug output: this pass silently +/// changes what a device publishes, and the boot log is the only evidence of +/// why a coordinate did or did not go out. Every field the table branched on is +/// recorded, so a surprising decision can be re-derived from the log alone +/// without reproducing the machine's store. +pub fn apply_gate( + conn: &Connection, + owner_pubkey: &str, + coordinate: &Coordinate, + observation: &Observation, + resolution: &Resolution, +) -> Result<(), String> { + tracing::info!( + target: "buzz::config_sync", + kind = coordinate.kind, + d_tag = %coordinate.d_tag, + decision = ?resolution.decision, + blocked = blocks_publication(resolution), + tombstone_blocked = blocks_tombstone_publication(resolution), + cleared_queued_publish = resolution.clear_queued_publish, + head = match &observation.head { + HeadState::Present(_) => "present", + HeadState::Absent => "absent", + HeadState::LookupFailed => "lookup_failed", + }, + disk_present = observation.disk.is_some(), + disk_matches_head = matches!( + (&observation.disk, &observation.head), + (Some(disk), HeadState::Present(head)) if head.projection == *disk + ), + has_baseline = observation.baseline.is_some(), + queued = observation.queued.is_some(), + queued_matches_baseline = matches!( + (&observation.queued, &observation.baseline), + (Some(queued), Some(baseline)) if queued == baseline + ), + queued_deletion = observation.queued_deletion_at.is_some(), + tombstone = ?observation.tombstone, + "config-sync decision" + ); + + super::retention::set_publish_blocked( + conn, + coordinate.kind, + owner_pubkey, + &coordinate.d_tag, + blocks_publication(resolution), + )?; + + super::retention::set_publish_blocked( + conn, + buzz_core_pkg::kind::KIND_DELETION, + owner_pubkey, + &super::retention::tombstone_retention_d_tag(coordinate.kind, &coordinate.d_tag), + blocks_tombstone_publication(resolution), + ) +} + +/// Record the relay head as this install's baseline for a coordinate. +/// +/// Only ever called with a head that a completed, writer-consistent lookup +/// actually returned, which is what makes the baseline a claim about the RELAY +/// rather than about local disk. Stamping from disk content would defeat the +/// entire model: the baseline's job is to distinguish "the user edited this" +/// from "this store never learned about the newer head", and content that was +/// never published cannot answer that question. +fn stamp_baseline_from_head( + conn: &Connection, + owner_pubkey: &str, + coordinate: &Coordinate, + head: &Head, +) -> Result<(), String> { + super::retention::set_baseline( + conn, + coordinate.kind, + owner_pubkey, + &coordinate.d_tag, + &head.event_id, + &head.projection.content, + ) +} + +/// Run the boot decision pass for a set of coordinates and enforce the result. +/// +/// This is the barrier's entry point. Ordering follows V3.4 strictly: the +/// caller performs every relay lookup BEFORE calling this, and this function +/// does only local work, so no network I/O ever happens while the store lock is +/// held. +/// +/// Three outcomes are executed here, and only three: the publication gate, the +/// baseline stamp, and dropping a vacuous queued publish. All three are +/// provenance — none touches the user's records. The decisions that PATCH disk +/// (`ApplyHead`, `RestoreFromRelay`, `DeleteLocal`) are logged and gated but +/// deliberately not applied, because adopting remote content over a local +/// record is the resolution path (V3.6) and is user-driven by design. +/// +/// Returns the plan it enforced, so the caller can act on the non-gate half of +/// each resolution and log a summary. +pub fn run_decision_pass( + conn: &Connection, + owner_pubkey: &str, + states: &[CoordinateState], +) -> Result, String> { + let plan = plan(states); + + for (state, (coordinate, resolution)) in states.iter().zip(plan.iter()) { + apply_gate( + conn, + owner_pubkey, + coordinate, + &state.observation, + resolution, + )?; + + // A vacuous queued publish is dropped rather than gated. Gating would + // hold it forever: it can never become publishable, because it says + // nothing the baseline does not already say. + if resolution.clear_queued_publish { + super::retention::clear_pending_sync( + conn, + coordinate.kind, + owner_pubkey, + &coordinate.d_tag, + )?; + } + + // `StampBaseline` means disk and head already agree, so the head is a + // sound baseline and recording it is what gives every later boot the + // provenance to tell an edit from a stale copy. Without this the + // baseline stays `None` forever and every baseline-dependent cell is + // unreachable. + if resolution.decision == Decision::StampBaseline { + if let HeadState::Present(head) = &state.observation.head { + stamp_baseline_from_head(conn, owner_pubkey, coordinate, head)?; + } + } + } + + let blocked = plan + .iter() + .filter(|(_, resolution)| blocks_publication(resolution)) + .count(); + let cleared = plan + .iter() + .filter(|(_, resolution)| resolution.clear_queued_publish) + .count(); + tracing::info!( + target: "buzz::config_sync", + coordinates = plan.len(), + blocked, + cleared, + "config-sync decision pass complete" + ); + + Ok(plan) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/config_sync/tests.rs b/desktop/src-tauri/src/managed_agents/config_sync/tests.rs new file mode 100644 index 0000000000..c6d4766e7d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_sync/tests.rs @@ -0,0 +1,950 @@ +use std::path::Path; + +use super::*; +use crate::managed_agents::{ + decision::{decide, ParkReason}, + persona_events::build_persona_event, + retention::{ + get_baseline, get_pending_sync, get_retained_event, is_publish_blocked, open_retention_db, + retain_event, set_baseline, tombstone_retention_d_tag, RetainedEvent, + }, +}; +use buzz_core_pkg::kind::{KIND_DELETION, KIND_PERSONA}; + +const OWNER: &str = "ownerpubkeyhex"; + +fn test_db() -> Connection { + open_retention_db(Path::new(":memory:")).unwrap() +} + +fn coordinate() -> Coordinate { + Coordinate { + kind: KIND_PERSONA, + d_tag: "test-persona".to_string(), + } +} + +/// The retention key the coordinate's queued tombstone occupies — a DIFFERENT +/// primary key from the coordinate itself, which is the whole reason the gate +/// has two halves. +fn tombstone_key() -> String { + tombstone_retention_d_tag(KIND_PERSONA, "test-persona") +} + +fn projection(content: &str) -> CanonicalProjection { + CanonicalProjection { + content: content.to_string(), + shared: false, + } +} + +/// A relay head carrying `content` at `created_at`, with an id derived from it. +fn head(content: &str, created_at: i64) -> Head { + Head { + event_id: format!("id-{content}"), + created_at, + projection: projection(content), + } +} + +fn signed_persona(system_prompt: &str) -> nostr::Event { + use std::collections::BTreeMap; + let record = crate::managed_agents::AgentDefinition { + id: "test-persona".to_string(), + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: system_prompt.to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + }; + build_persona_event(&record) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap() +} + +/// Retain a row at the coordinate, optionally pending. Returns the signed +/// event so a caller can stamp a baseline naming it. +fn retain_row(conn: &Connection, kind: u32, d_tag: &str, pending: bool) -> nostr::Event { + retain_prompt(conn, kind, d_tag, pending, "prompt") +} + +/// The same, with control over the persona's system prompt — which is what +/// [`CanonicalProjection`] reads, so it is how a test makes the queued row's +/// content differ from the baseline's. +fn retain_prompt( + conn: &Connection, + kind: u32, + d_tag: &str, + pending: bool, + system_prompt: &str, +) -> nostr::Event { + let event = signed_persona(system_prompt); + retain_event( + conn, + &RetainedEvent { + kind, + pubkey: OWNER.to_string(), + d_tag: d_tag.to_string(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: { + use nostr::JsonUtil; + event.as_json() + }, + pending_sync: pending, + event_id: Some(event.id.to_hex()), + }, + ) + .unwrap(); + event +} + +/// Re-queue an already-retained row, the way an edit or a reconcile would. +fn set_pending(conn: &Connection, kind: u32, d_tag: &str) { + conn.execute( + "UPDATE persona_events SET pending_sync = 1 + WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", + rusqlite::params![kind, OWNER, d_tag], + ) + .unwrap(); +} + +// ── local_state: reading the observation off a real store ────────────────── + +#[test] +fn test_no_local_row_reports_nothing_queued_and_no_baseline() { + let conn = test_db(); + let observation = local_state( + &conn, + OWNER, + &coordinate(), + Some(projection("v1")), + HeadState::Present(head("v1", 100)), + TombstoneEvidence::NotFound, + ) + .unwrap(); + + assert!(observation.queued.is_none()); + assert!(observation.queued_deletion_at.is_none()); + assert!(observation.baseline.is_none()); +} + +/// The queued projection is read from the row's own `raw_event`, because those +/// are the bytes the flush loop publishes. +#[test] +fn test_pending_live_row_reports_its_own_queued_projection() { + let conn = test_db(); + retain_prompt(&conn, KIND_PERSONA, "test-persona", true, "queued-prompt"); + + let observation = local_state( + &conn, + OWNER, + &coordinate(), + Some(projection("whatever-is-on-disk")), + HeadState::Present(head("v2", 200)), + TombstoneEvidence::NotFound, + ) + .unwrap(); + + let queued = observation.queued.expect("the pending row must be queued"); + assert!( + queued.content.contains("queued-prompt"), + "the queued projection must come from the row's raw_event, got {queued:?}" + ); + assert!( + observation.queued_deletion_at.is_none(), + "a queued edit is not a queued deletion" + ); +} + +/// A queued DELETION lives at a different primary key than the record itself. +/// Reporting only the live row was the cross-coordinate hole from review. +#[test] +fn test_pending_tombstone_row_is_reported_for_its_target_coordinate() { + let conn = test_db(); + // The live row is NOT pending; only the paired tombstone is. + retain_row(&conn, KIND_PERSONA, "test-persona", false); + let tombstone = retain_row(&conn, KIND_DELETION, &tombstone_key(), true); + + let observation = local_state( + &conn, + OWNER, + &coordinate(), + Some(projection("v1")), + HeadState::Present(head("v2", 200)), + TombstoneEvidence::NotFound, + ) + .unwrap(); + + assert_eq!( + observation.queued_deletion_at, + Some(tombstone.created_at.as_secs() as i64), + "a pending tombstone must be reported against its TARGET coordinate" + ); + assert!( + observation.queued.is_none(), + "the live row is settled; only the tombstone is queued" + ); +} + +/// A settled tombstone row must not pin the coordinate as queued forever. +#[test] +fn test_non_pending_tombstone_row_is_not_reported_as_queued() { + let conn = test_db(); + retain_row(&conn, KIND_PERSONA, "test-persona", false); + retain_row(&conn, KIND_DELETION, &tombstone_key(), false); + + let observation = local_state( + &conn, + OWNER, + &coordinate(), + Some(projection("v1")), + HeadState::Present(head("v1", 100)), + TombstoneEvidence::NotFound, + ) + .unwrap(); + + assert!(observation.queued.is_none()); + assert!(observation.queued_deletion_at.is_none()); +} + +#[test] +fn test_stamped_baseline_is_read_back() { + let conn = test_db(); + let event = retain_row(&conn, KIND_PERSONA, "test-persona", false); + set_baseline( + &conn, + KIND_PERSONA, + OWNER, + "test-persona", + &event.id.to_hex(), + "baseline-content", + ) + .unwrap(); + + let observation = local_state( + &conn, + OWNER, + &coordinate(), + Some(projection("baseline-content")), + HeadState::Present(head("newer", 200)), + TombstoneEvidence::NotFound, + ) + .unwrap(); + + assert_eq!( + observation.baseline.as_ref().map(|b| b.content.as_str()), + Some("baseline-content") + ); + // Disk still equals the baseline, so the differing head wins — the exact + // stale-store case the fix exists for. + assert_eq!(decide(&observation).decision, Decision::ApplyHead); +} + +/// A half-written baseline (id without content, or vice versa) is not a usable +/// provenance record and must read as "no baseline". +#[test] +fn test_partial_baseline_reads_as_absent() { + let conn = test_db(); + retain_row(&conn, KIND_PERSONA, "test-persona", false); + conn.execute( + "UPDATE persona_events SET baseline_event_id = 'someid', baseline_content = NULL + WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", + rusqlite::params![KIND_PERSONA, OWNER, "test-persona"], + ) + .unwrap(); + + let observation = local_state( + &conn, + OWNER, + &coordinate(), + Some(projection("local")), + HeadState::Present(head("remote", 200)), + TombstoneEvidence::NotFound, + ) + .unwrap(); + + assert!(observation.baseline.is_none()); + assert_eq!(decide(&observation).decision, Decision::Defer); +} + +#[test] +fn test_plan_pairs_every_coordinate_with_its_decision() { + let base = Observation { + disk: Some(projection("v1")), + queued: None, + head: HeadState::Present(head("v2", 200)), + baseline: Some(projection("v1")), + queued_deletion_at: None, + tombstone: TombstoneEvidence::NotFound, + }; + let states = vec![ + CoordinateState { + coordinate: Coordinate { + kind: KIND_PERSONA, + d_tag: "adopts".to_string(), + }, + observation: base.clone(), + }, + CoordinateState { + coordinate: Coordinate { + kind: KIND_PERSONA, + d_tag: "parks".to_string(), + }, + observation: Observation { + disk: Some(projection("edited")), + head: HeadState::Absent, + ..base + }, + }, + ]; + + let plan = plan(&states); + + assert_eq!(plan.len(), 2); + assert_eq!(plan[0].0.d_tag, "adopts"); + assert_eq!(plan[0].1.decision, Decision::ApplyHead); + assert_eq!(plan[1].0.d_tag, "parks"); + assert_eq!( + plan[1].1.decision, + Decision::Park(ParkReason::LocalEditWithNoHead) + ); +} + +// ── The publication gates (V3.9) ─────────────────────────────────────────── + +fn resolution(decision: Decision) -> Resolution { + Resolution { + decision, + clear_queued_publish: false, + } +} + +/// The live-row gate is deny-by-default over the decision set, so a variant +/// added later is withheld until someone deliberately admits it. +#[test] +fn test_exactly_the_publishing_decisions_release_the_live_gate() { + for decision in [ + Decision::SuppressPublish, + Decision::Park(ParkReason::LocalEditWithNoHead), + Decision::Park(ParkReason::NoBaselineWithHead), + Decision::StampBaseline, + Decision::DeleteLocal, + Decision::Defer, + // The deletion publishes from the tombstone row; the live row it + // deletes must not race it. + Decision::PublishDeletion, + // The one adopt-the-relay cell reachable with NO baseline, so it must + // not leave an open gate behind for a row queued after the pass. + Decision::RestoreFromRelay, + ] { + assert!( + blocks_publication(&resolution(decision.clone())), + "{decision:?} must withhold the live row" + ); + } + + for decision in [ + Decision::PublishLocalEdit, + // Only reachable with a baseline present and nothing queued, so + // gating it would strand a later edit and suppress nothing. + Decision::ApplyHead, + ] { + assert!( + !blocks_publication(&resolution(decision.clone())), + "{decision:?} must not withhold the live row" + ); + } +} + +/// Exactly one decision releases the tombstone row. Anything else — including +/// the timestamp arbitration falling through to the live cells — means the +/// queued deletion did not win. +#[test] +fn test_only_publish_deletion_releases_the_tombstone_gate() { + for decision in [ + Decision::PublishLocalEdit, + Decision::ApplyHead, + Decision::RestoreFromRelay, + Decision::StampBaseline, + Decision::SuppressPublish, + Decision::DeleteLocal, + Decision::Defer, + Decision::Park(ParkReason::NoBaselineWithHead), + Decision::Park(ParkReason::LocalEditWithNoHead), + ] { + assert!( + blocks_tombstone_publication(&resolution(decision.clone())), + "{decision:?} must withhold the queued tombstone" + ); + } + assert!(!blocks_tombstone_publication(&resolution( + Decision::PublishDeletion + ))); +} + +/// The gate's whole purpose: a suppressed coordinate must actually vanish from +/// the flush loop's work list. +#[test] +fn test_suppressed_coordinate_is_withheld_from_the_flush_loop() { + let conn = test_db(); + retain_row(&conn, KIND_PERSONA, "test-persona", true); + + assert_eq!( + get_pending_sync(&conn).unwrap().len(), + 1, + "precondition: the pending row is publishable" + ); + + let observation = Observation { + disk: Some(projection("v1")), + queued: None, + head: HeadState::Absent, + baseline: Some(projection("v1")), + queued_deletion_at: None, + tombstone: TombstoneEvidence::NotFound, + }; + apply_gate( + &conn, + OWNER, + &coordinate(), + &observation, + &resolution(Decision::SuppressPublish), + ) + .unwrap(); + + assert!( + get_pending_sync(&conn).unwrap().is_empty(), + "a suppressed coordinate must not reach the publisher" + ); + // The content and its pending flag survive — suppression withholds, it + // does not discard what this device believes. + let row = get_retained_event(&conn, KIND_PERSONA, OWNER, "test-persona") + .unwrap() + .unwrap(); + assert!(row.pending_sync); +} + +/// Suppression must be reversible: once the head is visible again the same +/// coordinate resumes publishing, or the gate latches shut permanently. +#[test] +fn test_gate_reopens_when_a_later_decision_publishes() { + let conn = test_db(); + retain_row(&conn, KIND_PERSONA, "test-persona", true); + + let suppressed = Observation { + disk: Some(projection("v1")), + queued: None, + head: HeadState::Absent, + baseline: Some(projection("v1")), + queued_deletion_at: None, + tombstone: TombstoneEvidence::NotFound, + }; + apply_gate( + &conn, + OWNER, + &coordinate(), + &suppressed, + &resolution(Decision::SuppressPublish), + ) + .unwrap(); + assert!(is_publish_blocked(&conn, KIND_PERSONA, OWNER, "test-persona").unwrap()); + + // Next boot: the head is visible and disk carries a real edit. + let recovered = Observation { + disk: Some(projection("edited")), + head: HeadState::Present(head("v1", 100)), + ..suppressed + }; + apply_gate( + &conn, + OWNER, + &coordinate(), + &recovered, + &resolution(Decision::PublishLocalEdit), + ) + .unwrap(); + + assert!(!is_publish_blocked(&conn, KIND_PERSONA, OWNER, "test-persona").unwrap()); + assert_eq!( + get_pending_sync(&conn).unwrap().len(), + 1, + "the coordinate must be publishable again" + ); +} + +/// The gate is stored per coordinate: withholding one persona must not +/// withhold another, or one unreachable head would silence the whole store. +#[test] +fn test_gate_is_scoped_to_its_own_coordinate() { + let conn = test_db(); + retain_row(&conn, KIND_PERSONA, "test-persona", true); + retain_row(&conn, KIND_PERSONA, "other-persona", true); + + let observation = Observation { + disk: Some(projection("v1")), + queued: None, + head: HeadState::Absent, + baseline: Some(projection("v1")), + queued_deletion_at: None, + tombstone: TombstoneEvidence::NotFound, + }; + apply_gate( + &conn, + OWNER, + &coordinate(), + &observation, + &resolution(Decision::SuppressPublish), + ) + .unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].d_tag, "other-persona"); +} + +/// The gate has two halves because the rows have two primary keys. A pass that +/// wrote only the live half would compute a deletion suppression and enforce +/// it on the wrong row, leaving the tombstone free to publish. +#[test] +fn test_the_gate_enforces_on_the_tombstone_row_too() { + let conn = test_db(); + retain_row(&conn, KIND_DELETION, &tombstone_key(), true); + + assert_eq!( + get_pending_sync(&conn).unwrap().len(), + 1, + "precondition: the queued tombstone is publishable" + ); + + let observation = Observation { + disk: None, + queued: None, + head: HeadState::Present(head("still-there", 200)), + baseline: None, + queued_deletion_at: Some(100), + tombstone: TombstoneEvidence::NotFound, + }; + apply_gate( + &conn, + OWNER, + &coordinate(), + &observation, + &resolution(Decision::Park(ParkReason::NoBaselineWithHead)), + ) + .unwrap(); + + assert!( + get_pending_sync(&conn).unwrap().is_empty(), + "a withheld deletion must not reach the publisher" + ); + assert!(is_publish_blocked(&conn, KIND_DELETION, OWNER, &tombstone_key()).unwrap()); +} + +// ── Composition: the whole pass, not one layer ───────────────────────────── +// +// Every test above exercises one layer in isolation, which is exactly how a +// gate that can never fire passed a full suite: `decide` was tested with +// hand-built observations and `apply_gate` with hand-picked decisions, so +// nothing asserted that the decision a REAL pending row produces is one the +// gate actually blocks. These run the composition — retained row → +// `local_state` → `decide` → `apply_gate` → `get_pending_sync` — because that +// path is the product behaviour, and the seam between layers is where the bug +// lived. + +/// Build the observation for a coordinate the way the barrier does, from rows +/// that are actually in the store. +fn observe(conn: &Connection, head: HeadState, disk: Option) -> Observation { + local_state( + conn, + OWNER, + &coordinate(), + disk, + head, + TombstoneEvidence::NotFound, + ) + .unwrap() +} + +/// Run the real pass over one coordinate and report what the flush loop would +/// then publish. +fn run_pass(conn: &Connection, observation: &Observation) -> (Decision, usize) { + let plan = run_decision_pass( + conn, + OWNER, + &[CoordinateState { + coordinate: coordinate(), + observation: observation.clone(), + }], + ) + .unwrap(); + ( + plan[0].1.decision.clone(), + get_pending_sync(conn).unwrap().len(), + ) +} + +/// **Will's bug, end to end, and the regression test for this whole PR.** +/// +/// A second install holds the OLD system prompt on disk with a FRESH retention +/// database — so no baseline, which is what a dev build against the same relay +/// always looks like. The good edit was made on the other install and is the +/// relay head. Boot's reconcile has already re-signed the stale disk content +/// at `monotonic_created_at = max(now, head + 1)` and queued it, so it would +/// win the relay's last-write-wins compare on `created_at` every time. +/// +/// Composed through the real path rather than asserted on `decide` alone: 43 +/// layer-isolated tests passed while the composition was a no-op, and that is +/// the gap class this closes. Zero rows publishable is the only acceptable +/// outcome. +#[test] +fn test_stale_store_with_no_baseline_cannot_revert_a_newer_head() { + let conn = test_db(); + // The reconcile's queued re-sign of the stale disk content. No baseline is + // stamped: this install has never agreed to anything at this coordinate. + retain_prompt(&conn, KIND_PERSONA, "test-persona", true, "old-prompt"); + assert_eq!( + get_pending_sync(&conn).unwrap().len(), + 1, + "precondition: without the barrier this row publishes" + ); + assert!( + get_baseline(&conn, KIND_PERSONA, OWNER, "test-persona") + .unwrap() + .is_none(), + "precondition: a fresh retention database has no baseline" + ); + + let observation = observe( + &conn, + // The good edit, on the relay, at an OLDER wall-clock time than the + // re-sign — which is precisely why `created_at` cannot arbitrate this. + HeadState::Present(head("good-edit", 1)), + Some(projection("old-prompt")), + ); + let (decision, publishable) = run_pass(&conn, &observation); + + assert_eq!( + decision, + Decision::Park(ParkReason::NoBaselineWithHead), + "the no-baseline-with-head cell must park" + ); + assert_eq!( + publishable, 0, + "the stale revert reached the publisher (decision: {decision:?})" + ); + // Withheld, not destroyed — the device still holds its copy, and the row + // keeps its pending flag so a later resolution can act on it. + let row = get_retained_event(&conn, KIND_PERSONA, OWNER, "test-persona") + .unwrap() + .expect("the record must survive suppression"); + assert!(row.pending_sync); +} + +/// The same bug in its second shape: a queued DELETION from a no-baseline +/// store against a live head. Worse than the revert, because an a-tag delete +/// destroys content this install has never seen rather than reverting it. +#[test] +fn test_stale_store_with_no_baseline_cannot_delete_a_live_head() { + let conn = test_db(); + retain_row(&conn, KIND_DELETION, &tombstone_key(), true); + + let observation = observe(&conn, HeadState::Present(head("still-there", 1)), None); + let (decision, publishable) = run_pass(&conn, &observation); + + assert_eq!(decision, Decision::Park(ParkReason::NoBaselineWithHead)); + assert_eq!( + publishable, 0, + "an unprovable deletion reached the publisher (decision: {decision:?})" + ); +} + +/// **The tombstone escape, composed over BOTH rows.** +/// +/// A coordinate's record and its queued tombstone occupy different primary +/// keys — `(kind, owner, d_tag)` versus `(5, owner, ":")` — and +/// the flush loop reads them independently. A pass that computes the right +/// decision but enforces it on one key is indistinguishable from no gate at +/// all for the other, so the no-baseline invariant is only meaningful stated +/// over both. +/// +/// The deletion here is deliberately NEWER than the head, which is the exact +/// input `deletion_at >= head.created_at` would resolve to `PublishDeletion`. +/// It must not get there: the no-baseline gate runs first, so an install that +/// cannot show it ever agreed to anything at this coordinate never destroys a +/// head it has not seen. Asserted through `get_pending_sync` — what the flush +/// loop actually selects — not through the decision alone. +#[test] +fn test_no_baseline_store_withholds_both_the_record_and_its_tombstone() { + let conn = test_db(); + // Both halves queued at once: the reconcile's re-sign of stale disk + // content, and a tombstone targeting the same coordinate. + retain_prompt(&conn, KIND_PERSONA, "test-persona", true, "old-prompt"); + let tombstone = retain_row(&conn, KIND_DELETION, &tombstone_key(), true); + assert_eq!( + get_pending_sync(&conn).unwrap().len(), + 2, + "precondition: without the barrier both rows publish" + ); + assert!( + get_baseline(&conn, KIND_PERSONA, OWNER, "test-persona") + .unwrap() + .is_none(), + "precondition: a fresh retention database has no baseline" + ); + + // The head predates the tombstone, so cell 4's timestamp arbitration would + // hand this to `PublishDeletion` if the baseline gate did not run first. + let head_created_at = tombstone.created_at.as_secs() as i64 - 1; + let observation = observe( + &conn, + HeadState::Present(head("good-edit", head_created_at)), + Some(projection("old-prompt")), + ); + assert!( + observation.queued_deletion_at.unwrap() >= head_created_at, + "precondition: the queued deletion must be the newer of the two" + ); + + let (decision, publishable) = run_pass(&conn, &observation); + + assert_eq!( + decision, + Decision::Park(ParkReason::NoBaselineWithHead), + "the no-baseline gate must take this before the timestamp compare" + ); + assert_eq!( + publishable, 0, + "a row escaped the flush-selection gate (decision: {decision:?})" + ); + assert!( + is_publish_blocked(&conn, KIND_PERSONA, OWNER, "test-persona").unwrap(), + "the record's own row must be gated" + ); + assert!( + is_publish_blocked(&conn, KIND_DELETION, OWNER, &tombstone_key()).unwrap(), + "the tombstone row must be gated at its own primary key" + ); +} + +/// The mirror case, and the reason suppression cannot simply block everything +/// pending: a genuine local edit against a head this install has already +/// agreed on must still publish, or the fix freezes every agent config. +#[test] +fn test_genuine_local_edit_still_publishes() { + let conn = test_db(); + let event = retain_prompt(&conn, KIND_PERSONA, "test-persona", true, "my-new-edit"); + // The baseline is what this install last agreed was published; the queued + // row has since moved past it. + set_baseline( + &conn, + KIND_PERSONA, + OWNER, + "test-persona", + &event.id.to_hex(), + "published-v1", + ) + .unwrap(); + + let observation = observe( + &conn, + HeadState::Present(head("published-v1", 100)), + Some(projection("my-new-edit")), + ); + let (decision, publishable) = run_pass(&conn, &observation); + + assert_eq!( + decision, + Decision::PublishLocalEdit, + "a post-baseline edit must be recognized as an edit" + ); + assert_eq!(publishable, 1, "the user's edit must still reach the relay"); +} + +/// A vacuous re-sign — the queued row publishes exactly what the baseline +/// already says — is DROPPED, not gated. Gating would hold it forever, since +/// it can never become publishable. +#[test] +fn test_vacuous_re_sign_is_cleared_rather_than_held() { + let conn = test_db(); + let event = retain_prompt(&conn, KIND_PERSONA, "test-persona", true, "agreed"); + // Baseline names this exact event, so the queued projection equals it. + set_baseline( + &conn, + KIND_PERSONA, + OWNER, + "test-persona", + &event.id.to_hex(), + &event.content, + ) + .unwrap(); + + let observation = observe( + &conn, + HeadState::Present(head("newer-elsewhere", 200)), + Some(projection(&event.content)), + ); + let (_, publishable) = run_pass(&conn, &observation); + + assert_eq!(publishable, 0); + let row = get_retained_event(&conn, KIND_PERSONA, OWNER, "test-persona") + .unwrap() + .unwrap(); + assert!( + !row.pending_sync, + "a re-sign carrying no new intent must be dropped, not held pending forever" + ); +} + +/// A pending row whose disk content already equals the head is the +/// crash-convergence case: stamp the baseline and stop, rather than burn a +/// `created_at` bump republishing identical content. +#[test] +fn test_pending_row_matching_the_head_stamps_and_stops() { + let conn = test_db(); + retain_row(&conn, KIND_PERSONA, "test-persona", true); + + let observation = observe( + &conn, + HeadState::Present(head("agreed", 100)), + Some(projection("agreed")), + ); + let (decision, publishable) = run_pass(&conn, &observation); + + assert_eq!(decision, Decision::Park(ParkReason::NoBaselineWithHead)); + assert_eq!(publishable, 0, "identical content must not be republished"); +} + +/// `StampBaseline` has to actually write the baseline. It is the input every +/// other cell depends on, so a decision computed and dropped leaves the table +/// permanently in its no-provenance state. +#[test] +fn test_stamp_baseline_records_the_head_as_provenance() { + let conn = test_db(); + retain_row(&conn, KIND_PERSONA, "test-persona", false); + + let observation = observe( + &conn, + HeadState::Present(head("agreed", 100)), + Some(projection("agreed")), + ); + run_pass(&conn, &observation); + + assert_eq!( + get_baseline(&conn, KIND_PERSONA, OWNER, "test-persona").unwrap(), + Some(("id-agreed".to_string(), "agreed".to_string())), + "the baseline must be stamped from the head, content and id together" + ); +} + +/// The two-boot sequence that makes the fix durable rather than a one-shot: +/// boot 1 converges and stamps the baseline, boot 2 uses that provenance to +/// recognize the stale copy the reconcile re-queued. +#[test] +fn test_stamped_baseline_lets_the_next_boot_suppress_a_revert() { + let conn = test_db(); + let event = retain_prompt(&conn, KIND_PERSONA, "test-persona", false, "v1"); + // The head IS the event this store already holds — the converged state + // boot 1 is supposed to recognize. + let converged = Head { + event_id: event.id.to_hex(), + created_at: event.created_at.as_secs() as i64, + projection: CanonicalProjection::from_event(&event), + }; + let disk = converged.projection.clone(); + + // Boot 1: disk equals the head, so the baseline is stamped from it. + let settled = observe( + &conn, + HeadState::Present(converged.clone()), + Some(disk.clone()), + ); + let (decision, _) = run_pass(&conn, &settled); + assert_eq!(decision, Decision::StampBaseline); + assert_eq!( + get_baseline(&conn, KIND_PERSONA, OWNER, "test-persona").unwrap(), + Some((converged.event_id.clone(), converged.projection.content)), + "boot 1 must establish provenance" + ); + + // Boot 2: the other install published a newer edit; the reconcile on THIS + // install re-queued its unchanged copy at a manufactured timestamp. + set_pending(&conn, KIND_PERSONA, "test-persona"); + let stale = observe( + &conn, + HeadState::Present(head("good-edit", 1)), + Some(disk.clone()), + ); + let (decision, publishable) = run_pass(&conn, &stale); + + assert_eq!( + publishable, 0, + "with a baseline, the stale copy must be recognized and withheld \ + (decision: {decision:?})" + ); + // Recognized as vacuous rather than merely gated: the queued row says + // exactly what the baseline already says, so it is dropped and the newer + // head is what this coordinate should adopt. + assert_eq!(decision, Decision::ApplyHead); + assert!( + !get_retained_event(&conn, KIND_PERSONA, OWNER, "test-persona") + .unwrap() + .unwrap() + .pending_sync + ); +} + +/// A queued deletion from a store that CAN prove its provenance still reaches +/// the relay. Suppressing every tombstone would resurrect records the user +/// deliberately removed. +#[test] +fn test_a_provable_queued_tombstone_still_publishes() { + let conn = test_db(); + let tombstone = retain_row(&conn, KIND_DELETION, &tombstone_key(), true); + // Provenance at the target coordinate: this install agreed to what is + // published there, so its deletion is arbitrable (cell 4). + retain_prompt(&conn, KIND_PERSONA, "test-persona", false, "agreed"); + let event = get_retained_event(&conn, KIND_PERSONA, OWNER, "test-persona") + .unwrap() + .unwrap(); + set_baseline( + &conn, + KIND_PERSONA, + OWNER, + "test-persona", + event.event_id.as_deref().unwrap(), + &event.content, + ) + .unwrap(); + + // The head is older than the tombstone, so the deletion wins. + let observation = observe( + &conn, + HeadState::Present(head("still-there", 1)), + Some(projection(&event.content)), + ); + assert_eq!( + observation.queued_deletion_at, + Some(tombstone.created_at.as_secs() as i64) + ); + let (decision, publishable) = run_pass(&conn, &observation); + + assert_eq!(decision, Decision::PublishDeletion); + assert_eq!(publishable, 1, "the queued deletion must reach the relay"); + assert_eq!(get_pending_sync(&conn).unwrap()[0].kind, KIND_DELETION); +} diff --git a/desktop/src-tauri/src/managed_agents/decision.rs b/desktop/src-tauri/src/managed_agents/decision.rs new file mode 100644 index 0000000000..b0d8374790 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/decision.rs @@ -0,0 +1,377 @@ +//! The boot decision table (V3.2, as amended by V3.10 and V3.11). +//! +//! One pure function, [`decide`], maps the observable state of a single config +//! coordinate onto the action the boot pass should take. It performs no I/O so +//! that every cell is testable directly, and so the ordering between them is +//! visible in one place rather than spread across the caller. +//! +//! # Why a table instead of a content diff +//! +//! Agent configs sync as NIP-33 replaceable events resolved last-write-wins on +//! `created_at`, and the boot reconcile re-signs local disk content at +//! `monotonic_created_at = max(now, head + 1)`. A store that is merely *stale* +//! therefore mints events that are always strictly newer than the edits they +//! revert. Content equality alone cannot tell "the user changed this" from +//! "this store never learned about the newer head", so the pass needs +//! provenance — the baseline — and not just a diff. +//! +//! # Why `created_at` decides almost nothing here (V3.11) +//! +//! V3.2's original row 1 resolved a queued local edit against the relay head by +//! "greater `created_at` wins". Against this failure mode that is not +//! arbitration at all: the queued event's timestamp is manufactured by the very +//! path being stopped, so the stale store wins that comparison every single +//! time. Row 1 as written ratified the laundering vector. +//! +//! V3.11 splits it by evidence class. A queued **edit** is resolved against the +//! baseline, never against a timestamp. A queued **deletion** keeps timestamp +//! arbitration, but only downstream of the baseline gates — and it is sound +//! there precisely because every timestamp that reaches it was minted by an +//! interactive save or by the relay, never by a reconcile re-sign. +//! +//! # The suppression/deletion split (V3.10) +//! +//! Head absence and local deletion are decided on different evidence because +//! the cost of being wrong is wildly asymmetric. Missing head → stop +//! publishing, and nothing else; if that read was wrong the cost is one boot of +//! deferral. Removing a local record requires positive tombstone evidence, +//! because for a managed agent the removal is irreversible: the relay +//! projection is a strict subset of the record, so `private_key_nsec`, +//! `auth_tag`, `env_vars`, and the runtime config cannot be restored from it. +//! The retention scope key is only `owner || relay_url`, so any relay-side +//! history loss under the same URL presents as *every* coordinate being +//! head-absent at once — under a delete-on-absence rule that wipes the device. + +use super::canonical_projection::CanonicalProjection; + +/// What the boot pass observes about one coordinate, after the pulls have run +/// and before anything is written. +#[derive(Debug, Clone)] +pub struct Observation { + /// The canonical projection of the record currently on disk, or `None` + /// when the record is absent from the local store. + pub disk: Option, + /// The projection of the event QUEUED for publish at this coordinate. + /// + /// This, not [`Self::disk`], is what the flush loop would put on the relay, + /// so it is what the baseline arbitration compares. The two agree in both + /// minting paths today — the reconcile signs disk content, an interactive + /// save writes both — but the table must not assume that silently: it is + /// the queued bytes that publish, and a divergence would mean deciding + /// about content that is not the content going out. + pub queued: Option, + /// The relay head's state. `Absent` and `LookupFailed` are distinct: only a + /// completed, writer-consistent lookup can report absence. + pub head: HeadState, + /// The projection this install last agreed was published for this + /// coordinate. `None` before the baseline is established. + pub baseline: Option, + /// `created_at` of a queued deletion targeting this coordinate, if one is + /// queued. + /// + /// The only timestamp the table consults, and it is honest: a tombstone is + /// only ever minted by an interactive delete — a real user action at a real + /// time — never by the reconcile re-sign that manufactures timestamps. + pub queued_deletion_at: Option, + /// Positive evidence that the owner deleted this coordinate: a verified + /// owner-authored kind:5 naming it, from a scan that ran to completion. + pub tombstone: TombstoneEvidence, +} + +/// A relay head: what it publishes, when it was published, and the event id +/// that published it. +/// +/// The id travels with the projection because a baseline is a claim about a +/// specific event on the relay, not about content in the abstract. Stamping a +/// baseline from a projection alone would record "this is what's published" +/// with no way to tell later whether the row still holds that same event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Head { + pub event_id: String, + pub created_at: i64, + pub projection: CanonicalProjection, +} + +/// The result of looking up a coordinate's head on the relay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HeadState { + /// A head exists, with this projection. + Present(Head), + /// A completed lookup found no head. + /// + /// Only reachable from an exact per-coordinate query + /// (`{kinds, authors, #d}`, `limit: 2`); never inferred from a combined + /// inventory, which cannot bound absence due to server-side page caps. + Absent, + /// The lookup did not complete. Never treated as absence. + LookupFailed, +} + +/// Whether the owner positively deleted this coordinate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TombstoneEvidence { + /// A verified owner-authored kind:5 names this exact coordinate. + Found, + /// The scan ran to completion and found no tombstone for it. + NotFound, + /// The scan did not complete. Never treated as `NotFound`. + ScanIncomplete, +} + +/// What the boot pass should do with one coordinate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Decision { + /// Record that disk and head already agree. Stamps or repairs the + /// baseline; publishes nothing. + StampBaseline, + /// Adopt the relay's version onto disk and stamp the baseline from it. + ApplyHead, + /// Recreate a locally missing record from the relay head. + RestoreFromRelay, + /// A genuine post-baseline local edit: publish the queued event. + PublishLocalEdit, + /// The queued deletion wins: publish the tombstone. + PublishDeletion, + /// Remove the local record — only ever returned with positive tombstone + /// evidence. + DeleteLocal, + /// Stop publishing this coordinate but change nothing. The safe answer + /// whenever the head is absent without deletion evidence. + SuppressPublish, + /// Needs an explicit human decision; never auto-resolves on a later boot. + Park(ParkReason), + /// Not enough information yet. Nothing is written and nothing publishes. + Defer, +} + +/// Why a coordinate was parked, so the resolution surface can explain it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParkReason { + /// Disk differs from the baseline and no head exists — there is no + /// trustworthy timestamp for the local edit, and file mtime must not be + /// used to manufacture one. + LocalEditWithNoHead, + /// Something is queued at a coordinate this install has no baseline for, + /// and the relay holds a head. V3.11 cell 2, and the field failure this + /// whole change exists to stop: a second store with its own fresh + /// retention database is ALWAYS in this state, so publishing here is + /// exactly how a stale copy reverts a good edit. + /// + /// Covers a queued DELETION as well as a queued edit, and the deletion is + /// the worse half. An edit that loses this race can be re-made; a deletion + /// published against a head this install never agreed to destroys content + /// it has never seen, and the a-tag delete then propagates that removal to + /// every install that CAN see it. + NoBaselineWithHead, +} + +/// The boot pass's resolution for one coordinate. +/// +/// The decision and the fate of the queued publish travel together in one value +/// deliberately. They are not independent: `ApplyHead` and `PublishLocalEdit` +/// both permit publication, so a caller that took the decision and forgot to +/// drop a vacuous queued row would publish the stale copy those decisions were +/// chosen to avoid. One value cannot be half-used. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resolution { + pub decision: Decision, + /// Drop the queued publish: it re-publishes content this install already + /// agreed was published, so it is a reconcile re-sign with nothing to say. + pub clear_queued_publish: bool, +} + +impl Resolution { + fn keep(decision: Decision) -> Self { + Self { + decision, + clear_queued_publish: false, + } + } +} + +/// Decide what to do with one coordinate. +/// +/// Gates are evaluated in order and the first match wins. The ordering is the +/// contract: durable local intent is never silently discarded, every +/// destructive outcome is gated behind positive evidence rather than an +/// inference, and no cell publishes on the strength of a lookup that did not +/// complete. +pub fn decide(observation: &Observation) -> Resolution { + // Gate A — positive deletion evidence, checked first so a coordinate the + // owner deleted elsewhere is never "restored" by the absent-on-disk cell + // and never republished by the cells below. + if observation.tombstone == TombstoneEvidence::Found { + match &observation.head { + // A live head overrides the tombstone: a later upsert legitimately + // re-creates a coordinate, and the head is current relay truth. + HeadState::Present(_) => {} + // Deletion is proven and no head contradicts it. + HeadState::Absent => { + return Resolution::keep(if observation.disk.is_some() { + Decision::DeleteLocal + } else { + // Already gone locally; the tombstone confirms it. Nothing + // to do, but it must not be republished. + Decision::SuppressPublish + }); + } + // The owner deleted this coordinate, but a re-creation reads + // exactly like this and only a completed head read tells them + // apart. Nothing publishes and nothing is removed. + HeadState::LookupFailed => return Resolution::keep(Decision::Defer), + } + } + + // Gate B — nothing queued here is provable (V3.11 cells 2 and 3). + // + // Ordered ahead of BOTH arbitrations below, and that ordering is what V3.11 + // turns on. With no baseline this install cannot show it ever agreed to + // anything at the coordinate, so neither its content nor its timestamps are + // evidence of intent about the relay's state — and a fresh retention + // database is exactly this state. Gate C's timestamp compare is sound only + // downstream of here. + let queued_deletion = observation.queued_deletion_at.is_some(); + if observation.baseline.is_none() && (observation.queued.is_some() || queued_deletion) { + return Resolution::keep(match &observation.head { + // Cell 2 — Will's bug, as a cell. A head exists and this install + // cannot show it ever agreed to anything here, so it cannot claim + // its queued copy is newer intent. Parked, never auto-resolved. + HeadState::Present(_) => Decision::Park(ParkReason::NoBaselineWithHead), + HeadState::Absent => match observation.tombstone { + // Cell 3 — a create against a provably empty coordinate: a + // completed writer-consistent lookup says no head and a + // completed scan says no tombstone. Nothing to revert and + // nothing to resurrect, so the genuinely-new record publishes. + // A queued deletion here removes a purely local coordinate. + TombstoneEvidence::NotFound => { + if queued_deletion { + Decision::PublishDeletion + } else { + Decision::PublishLocalEdit + } + } + // `Found` already returned at gate A; it is grouped here so the + // resurrection vector stays closed if that ever changes. + // `ScanIncomplete` cannot support the absence cell 3 needs. + TombstoneEvidence::Found | TombstoneEvidence::ScanIncomplete => Decision::Defer, + }, + HeadState::LookupFailed => Decision::Defer, + }); + } + + // Gate C — a queued deletion arbitrated against the head by `created_at` + // (V3.11 cell 4). + // + // Sound HERE where V3.2's row 1 was not, and only because of what gate B + // already excluded: both sides are honest. The tombstone was minted by an + // interactive delete at a real time and the head's timestamp is the relay's + // own; neither is the reconcile re-sign that manufactures + // `max(now, head + 1)`. A store with no baseline never reaches this + // compare, which is what stops a stale copy from deleting a head it has + // never seen. + if let Some(deletion_at) = observation.queued_deletion_at { + match &observation.head { + // Deciding a deletion from a lookup that did not complete could + // wipe a head this device simply failed to see. + HeadState::LookupFailed => return Resolution::keep(Decision::Defer), + // Nothing on the relay to contradict the deletion. + HeadState::Absent => return Resolution::keep(Decision::PublishDeletion), + // Deletion wins equality: at the same second the removal is the + // more conservative reading of intent. + HeadState::Present(head) if deletion_at >= head.created_at => { + return Resolution::keep(Decision::PublishDeletion) + } + // The head is strictly newer than the tombstone, so the coordinate + // was legitimately re-created after the delete. The tombstone is + // stale; fall through and decide the live record on its own terms. + HeadState::Present(_) => {} + } + } + + // Gate D — a queued edit resolved against the baseline (V3.11 cells 1a and + // 1b), NEVER against `created_at`. Gate B returned for every no-baseline + // queue, so a baseline is present here whenever anything is queued. + if let (Some(queued), Some(baseline)) = + (observation.queued.as_ref(), observation.baseline.as_ref()) + { + if baseline == queued { + // Cell 1a — the queued event publishes exactly what this install + // already agreed was published. A reconcile re-sign carrying no new + // intent, so it is dropped and the coordinate decided as if nothing + // were queued. + return Resolution { + decision: decide_unqueued(observation).decision, + clear_queued_publish: true, + }; + } + // Cell 1b — the queued event differs from the last agreed publish: + // genuine post-baseline intent, and the only cell that publishes an + // edit. It still needs a completed lookup, because publishing means + // re-signing PAST the head and a lookup that did not complete cannot + // say what the head is. Deferring costs one boot; the row keeps its + // pending flag and publishes on the next pass that can see the relay. + return Resolution::keep(match observation.head { + HeadState::LookupFailed => Decision::Defer, + _ => Decision::PublishLocalEdit, + }); + } + + decide_unqueued(observation) +} + +/// Decide a coordinate with nothing queued for publish. +/// +/// Reached directly when no publish is queued, and from cell 1a once a vacuous +/// queued event has been dropped — the two states are identical from here down, +/// which is why the fall-through is a call rather than a copy of these cells. +fn decide_unqueued(observation: &Observation) -> Resolution { + let Some(disk) = observation.disk.as_ref() else { + // Absent on disk. Absence is never deletion intent; that requires a + // tombstone, handled by gate B. + return Resolution::keep(match &observation.head { + HeadState::Present(_) => Decision::RestoreFromRelay, + // No local record and no head: nothing to reconcile. + HeadState::Absent => Decision::SuppressPublish, + HeadState::LookupFailed => Decision::Defer, + }); + }; + + Resolution::keep(match &observation.head { + // Disk already equals the head. This is the crash-convergence cell: + // inbound retains the head before writing JSON, so a crash between + // those two steps lands here and must repair the baseline rather than + // be misread as a hand edit and republished. + HeadState::Present(head) if head.projection == *disk => Decision::StampBaseline, + + HeadState::Present(_) => match observation.baseline.as_ref() { + // Disk is untouched since the baseline, so the head is strictly + // newer information. + Some(baseline) if baseline == disk => Decision::ApplyHead, + // Disk differs from both: a genuine post-baseline edit. Nothing is + // queued for it yet; the next reconcile queues it and cell 1b + // publishes it. + Some(_) => Decision::PublishLocalEdit, + // No baseline: this install has no provenance for the record, so it + // cannot claim the difference is an edit. + None => Decision::Defer, + }, + + // V3.10 — head absent. Suppress, never delete: a wrong absence read + // costs one boot of deferral, a wrong deletion is unrecoverable. + HeadState::Absent => match observation.baseline.as_ref() { + // Disk is untouched and the head is gone. Under V3.2 this deleted + // locally; V3.10 downgrades it to suppression absent a tombstone. + Some(baseline) if baseline == disk => Decision::SuppressPublish, + // A local edit with no head to sign past: no trustworthy edit + // timestamp exists, so this needs an explicit decision. + Some(_) => Decision::Park(ParkReason::LocalEditWithNoHead), + None => Decision::Defer, + }, + + // Nothing is decided from a lookup that did not complete. + HeadState::LookupFailed => Decision::Defer, + }) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/decision/tests.rs b/desktop/src-tauri/src/managed_agents/decision/tests.rs new file mode 100644 index 0000000000..7f821389e2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/decision/tests.rs @@ -0,0 +1,554 @@ +use super::*; +use crate::managed_agents::config_sync::{blocks_publication, blocks_tombstone_publication}; + +fn projection(content: &str) -> CanonicalProjection { + CanonicalProjection { + content: content.to_string(), + shared: false, + } +} + +/// A relay head carrying `content` at `created_at`. The event id is derived +/// from the content so two heads with different content never collide on id. +fn head(content: &str, created_at: i64) -> Head { + Head { + event_id: format!("id-{content}"), + created_at, + projection: projection(content), + } +} + +/// A coordinate whose disk, baseline, and head all agree, with nothing queued — +/// the steady state. Individual tests mutate one axis at a time from here. +fn settled() -> Observation { + Observation { + disk: Some(projection("v1")), + queued: None, + head: HeadState::Present(head("v1", 100)), + baseline: Some(projection("v1")), + queued_deletion_at: None, + tombstone: TombstoneEvidence::NotFound, + } +} + +/// Every state the table can be asked about, as a cross product of each axis. +/// +/// The sweeps below are the only proofs that hold over states nobody thought +/// to write down, which is where this table's failures have actually come +/// from — the reverted-config bug was a cell no row-by-row test covered. +/// Enumerating is cheap (under a thousand states) and the coverage is total. +fn all_observations() -> impl Iterator { + let disks = [None, Some(projection("v1")), Some(projection("edited"))]; + let queues = [None, Some(projection("v1")), Some(projection("edited"))]; + let heads = [ + HeadState::Present(head("v1", 100)), + HeadState::Present(head("v2", 200)), + HeadState::Absent, + HeadState::LookupFailed, + ]; + let baselines = [None, Some(projection("v1")), Some(projection("old"))]; + // One tombstone older than every head and one newer, so the cell-4 + // arbitration is exercised in both directions wherever it is reachable. + let deletions = [None, Some(50), Some(500)]; + let tombstones = [ + TombstoneEvidence::Found, + TombstoneEvidence::NotFound, + TombstoneEvidence::ScanIncomplete, + ]; + + disks.into_iter().flat_map(move |disk| { + let queues = queues.clone(); + let heads = heads.clone(); + let baselines = baselines.clone(); + queues.into_iter().flat_map(move |queued| { + let heads = heads.clone(); + let baselines = baselines.clone(); + let disk = disk.clone(); + heads.into_iter().flat_map(move |head| { + let baselines = baselines.clone(); + let disk = disk.clone(); + let queued = queued.clone(); + baselines.into_iter().flat_map(move |baseline| { + let disk = disk.clone(); + let queued = queued.clone(); + let head = head.clone(); + deletions.into_iter().flat_map(move |queued_deletion_at| { + let disk = disk.clone(); + let queued = queued.clone(); + let head = head.clone(); + let baseline = baseline.clone(); + tombstones.into_iter().map(move |tombstone| Observation { + disk: disk.clone(), + queued: queued.clone(), + head: head.clone(), + baseline: baseline.clone(), + queued_deletion_at, + tombstone, + }) + }) + }) + }) + }) + }) +} + +// ── The invariants ───────────────────────────────────────────────────────── + +/// **Will's bug, stated as a property.** This is the whole point of V3.11. +/// +/// A store with no baseline for a coordinate cannot show it ever agreed to +/// anything published there. When the relay nonetheless holds a head, that +/// store's queued content is by definition uninformed about the head — which +/// is exactly the state of a second install with a fresh retention database. +/// Its `created_at` is manufactured by `monotonic_created_at = max(now, +/// head + 1)`, so any comparison decided on it is one the stale store always +/// wins. Nothing may reach the relay from this state, for any combination of +/// the remaining axes. +/// +/// Asserted against the real publication gates rather than against the +/// `Decision` enum, because the gate is what the flush loop obeys: a decision +/// that suppresses but is enforced on the wrong row is indistinguishable from +/// no gate at all. +/// +/// The statement is over BOTH rows and is unconditional on what is queued. A +/// coordinate's live row and its tombstone row have different primary keys, so +/// a single-row invariant is provably insufficient — and the assertion holds +/// even where nothing is queued right now, because the gate this pass leaves +/// behind is what governs a row queued after it. +#[test] +fn test_no_baseline_with_a_live_head_releases_neither_row() { + let mut covered = 0; + for observation in all_observations() { + if observation.baseline.is_some() || !matches!(observation.head, HeadState::Present(_)) { + continue; + } + let resolution = decide(&observation); + + assert!( + blocks_publication(&resolution), + "the live row escaped the gate with no baseline against a live head: \ + {observation:?} -> {resolution:?}" + ); + assert!( + blocks_tombstone_publication(&resolution), + "the tombstone row escaped the gate with no baseline against a live head: \ + {observation:?} -> {resolution:?}" + ); + covered += 1; + } + assert!( + covered > 0, + "the sweep must actually reach the guarded states" + ); +} + +/// The cell-2 decision behind that property, named so a future reader can see +/// which cell the sweep is protecting. +#[test] +fn test_no_baseline_with_a_live_head_parks() { + for queued in [Some(projection("stale")), None] { + for queued_deletion_at in [None, Some(500)] { + if queued.is_none() && queued_deletion_at.is_none() { + continue; // nothing queued: not cell 2 + } + let observation = Observation { + disk: Some(projection("stale")), + queued: queued.clone(), + head: HeadState::Present(head("good-edit", 200)), + baseline: None, + queued_deletion_at, + tombstone: TombstoneEvidence::NotFound, + }; + assert_eq!( + decide(&observation).decision, + Decision::Park(ParkReason::NoBaselineWithHead), + "cell 2 must park, not auto-resolve: {observation:?}" + ); + } + } +} + +/// `created_at` is never consulted for a live queued row against a head. +/// +/// The head's timestamp is varied across the whole range around the queued +/// row's while every other axis is held fixed. If any decision moved, some +/// cell would be arbitrating on a value the reconcile manufactures, and the +/// stale store would win it. +/// +/// Deliberately excludes queued deletions: cell 4 arbitrates those on +/// `created_at` by design, and soundly, because a tombstone is only ever +/// minted by an interactive delete. +#[test] +fn test_head_created_at_never_moves_a_live_decision() { + for observation in all_observations() { + if observation.queued_deletion_at.is_some() { + continue; // cell 4 consults timestamps on purpose + } + let HeadState::Present(present) = &observation.head else { + continue; + }; + let baseline = decide(&observation); + for created_at in [1, 99, 100, 101, 1_000_000] { + let shifted = Observation { + head: HeadState::Present(Head { + created_at, + ..present.clone() + }), + ..observation.clone() + }; + assert_eq!( + decide(&shifted), + baseline, + "the head's created_at moved a live decision: {observation:?} at {created_at}" + ); + } + } +} + +/// No decision path may destroy local state without positive tombstone +/// evidence. The safety property of the whole table, over the full state space +/// rather than row by row: for a managed agent the removal is irreversible, +/// since the relay projection is a strict subset of the local record. +#[test] +fn test_deletion_is_unreachable_without_tombstone_evidence() { + for observation in all_observations() { + if observation.tombstone == TombstoneEvidence::Found { + continue; + } + assert_ne!( + decide(&observation).decision, + Decision::DeleteLocal, + "reached DeleteLocal without evidence: {observation:?}" + ); + } +} + +/// Nothing is decided from a lookup that did not complete. A flaky network +/// must never present as a deletion or as an absence worth publishing into. +/// +/// Scoped to the DECISION. Dropping a vacuous queued publish is deliberately +/// still allowed here: "the queued row says exactly what the baseline says" is +/// a purely local fact that needs no relay evidence, and the row could never +/// become publishable anyway. `test_a_queued_publish_is_only_dropped_when_it_\ +/// equals_the_baseline` is what bounds that drop. +#[test] +fn test_a_failed_head_lookup_decides_nothing() { + for observation in all_observations() { + if observation.head != HeadState::LookupFailed { + continue; + } + assert_eq!( + decide(&observation).decision, + Decision::Defer, + "a failed lookup must decide nothing: {observation:?}" + ); + } +} + +/// A queued publish is only ever DROPPED as vacuous, never as a judgement +/// about content. Dropping it anywhere else would silently discard durable +/// local intent, which is the one thing row 1 has always guaranteed against. +#[test] +fn test_a_queued_publish_is_only_dropped_when_it_equals_the_baseline() { + for observation in all_observations() { + if !decide(&observation).clear_queued_publish { + continue; + } + assert_eq!( + observation.queued, observation.baseline, + "dropped a queued publish that carried new intent: {observation:?}" + ); + assert!(observation.queued.is_some()); + } +} + +// ── Cell 1: a queued row against its baseline ────────────────────────────── + +/// Cell 1a. The queued event publishes exactly what this install already +/// agreed was published, so it is a reconcile re-sign carrying nothing. It is +/// dropped rather than gated: it can never become publishable, so gating would +/// hold it forever. +#[test] +fn test_vacuous_re_sign_is_dropped_and_falls_through() { + let observation = Observation { + disk: Some(projection("v1")), + queued: Some(projection("v1")), + head: HeadState::Present(head("v2", 200)), + baseline: Some(projection("v1")), + queued_deletion_at: None, + tombstone: TombstoneEvidence::NotFound, + }; + let resolution = decide(&observation); + assert!(resolution.clear_queued_publish); + // Fell through to the unqueued cells, which see disk untouched since the + // baseline and adopt the newer head. + assert_eq!(resolution.decision, Decision::ApplyHead); +} + +/// Cell 1b. An edit made after the last agreed publish is real user intent and +/// must still reach the relay — suppression cannot simply block anything +/// queued. +#[test] +fn test_queued_edit_past_the_baseline_publishes() { + let observation = Observation { + disk: Some(projection("my-edit")), + queued: Some(projection("my-edit")), + head: HeadState::Present(head("v1", 100)), + baseline: Some(projection("v1")), + queued_deletion_at: None, + tombstone: TombstoneEvidence::NotFound, + }; + let resolution = decide(&observation); + assert_eq!(resolution.decision, Decision::PublishLocalEdit); + assert!(!blocks_publication(&resolution)); +} + +/// The comparand is the QUEUED event, not disk. The flush loop publishes the +/// queued row's bytes, so a divergence between disk and the queued row must be +/// decided on what actually goes out. Here disk still equals the baseline +/// while the queued row does not — the queued row wins the comparison. +#[test] +fn test_arbitration_reads_the_queued_row_not_disk() { + let observation = Observation { + disk: Some(projection("v1")), + queued: Some(projection("diverged")), + head: HeadState::Present(head("v1", 100)), + baseline: Some(projection("v1")), + queued_deletion_at: None, + tombstone: TombstoneEvidence::NotFound, + }; + assert_eq!(decide(&observation).decision, Decision::PublishLocalEdit); +} + +// ── Cell 3: the evidence-gated carve-out ─────────────────────────────────── + +/// A create against a provably empty coordinate: a completed writer-consistent +/// lookup says no head and a completed scan says no tombstone. Nothing to +/// revert and nothing to resurrect, so a genuinely new agent publishes rather +/// than parking forever. +#[test] +fn test_new_coordinate_with_both_evidence_gates_publishes() { + let observation = Observation { + disk: Some(projection("brand-new")), + queued: Some(projection("brand-new")), + head: HeadState::Absent, + baseline: None, + queued_deletion_at: None, + tombstone: TombstoneEvidence::NotFound, + }; + let resolution = decide(&observation); + assert_eq!(resolution.decision, Decision::PublishLocalEdit); + assert!(!blocks_publication(&resolution)); +} + +/// The resurrection vector. A deleted coordinate reads head-absent exactly +/// like a new one, and the relay keeps no watermark — so without the tombstone +/// gate, cell 3 would republish every record the owner ever deleted. +#[test] +fn test_a_deleted_coordinate_is_never_republished_as_new() { + let observation = Observation { + disk: Some(projection("deleted-agent")), + queued: Some(projection("deleted-agent")), + head: HeadState::Absent, + baseline: None, + queued_deletion_at: None, + tombstone: TombstoneEvidence::Found, + }; + let resolution = decide(&observation); + assert_eq!(resolution.decision, Decision::DeleteLocal); + assert!(blocks_publication(&resolution)); +} + +/// An incomplete scan cannot support the absence cell 3 needs, so the queued +/// row is held rather than published on unproven evidence. +#[test] +fn test_new_coordinate_defers_on_an_incomplete_scan() { + let observation = Observation { + disk: Some(projection("brand-new")), + queued: Some(projection("brand-new")), + head: HeadState::Absent, + baseline: None, + queued_deletion_at: None, + tombstone: TombstoneEvidence::ScanIncomplete, + }; + assert_eq!(decide(&observation).decision, Decision::Defer); +} + +// ── Cell 4: tombstone-vs-live arbitration ────────────────────────────────── + +/// Downstream of the baseline gates, both timestamps are honest — the +/// tombstone's was minted by an interactive delete, the head's by the relay — +/// so arbitrating on them is sound. Deletion wins equality: at the same second +/// the removal is the more conservative reading of intent. +#[test] +fn test_a_tombstone_at_or_after_the_head_publishes_the_deletion() { + for deletion_at in [100, 200] { + let observation = Observation { + queued_deletion_at: Some(deletion_at), + ..settled() + }; + let resolution = decide(&observation); + assert_eq!(resolution.decision, Decision::PublishDeletion); + // The tombstone row is released and the live row it deletes is + // withheld, so the record cannot race its own deletion. + assert!(!blocks_tombstone_publication(&resolution)); + assert!(blocks_publication(&resolution)); + } +} + +/// A head strictly newer than the tombstone means the coordinate was +/// legitimately re-created after the delete. The tombstone is stale and the +/// live record is decided on its own terms. +#[test] +fn test_a_head_newer_than_the_tombstone_survives_it() { + let observation = Observation { + queued_deletion_at: Some(50), + ..settled() + }; + let resolution = decide(&observation); + assert_eq!(resolution.decision, Decision::StampBaseline); + assert!(blocks_tombstone_publication(&resolution)); +} + +/// Deciding a deletion from a lookup that did not complete could wipe a head +/// this device simply failed to see. +#[test] +fn test_a_deletion_behind_a_failed_lookup_defers() { + let observation = Observation { + head: HeadState::LookupFailed, + queued_deletion_at: Some(500), + ..settled() + }; + assert_eq!(decide(&observation).decision, Decision::Defer); +} + +// ── V3.10: suppression is not deletion ───────────────────────────────────── + +/// The core V3.10 rule. Relay-side history loss under one URL presents as +/// every coordinate being head-absent at once; under a delete-on-absence rule +/// that wipes the device. +#[test] +fn test_head_absent_with_clean_disk_suppresses_instead_of_deleting() { + let observation = Observation { + head: HeadState::Absent, + ..settled() + }; + let resolution = decide(&observation); + assert_eq!(resolution.decision, Decision::SuppressPublish); + assert!(blocks_publication(&resolution)); +} + +/// A live head is current relay truth: a later upsert legitimately re-creates +/// a coordinate, so an older tombstone must not delete it. +#[test] +fn test_live_head_overrides_an_older_tombstone() { + let observation = Observation { + tombstone: TombstoneEvidence::Found, + ..settled() + }; + assert_eq!(decide(&observation).decision, Decision::StampBaseline); +} + +/// Proven deletion plus a head read that never completed is ambiguous: a +/// re-creation reads the same way. Neither removing nor republishing is safe. +#[test] +fn test_tombstone_with_a_failed_lookup_defers() { + let observation = Observation { + head: HeadState::LookupFailed, + tombstone: TombstoneEvidence::Found, + ..settled() + }; + assert_eq!(decide(&observation).decision, Decision::Defer); +} + +// ── Crash convergence and the unqueued cells ─────────────────────────────── + +/// Inbound retains the head BEFORE writing JSON, so a crash in between leaves +/// disk equal to the head but unequal to the old baseline. That must repair +/// the baseline, not be misread as a hand edit and republished. +#[test] +fn test_disk_equal_to_head_stamps_baseline_even_when_baseline_is_stale() { + let observation = Observation { + disk: Some(projection("v2")), + head: HeadState::Present(head("v2", 200)), + baseline: Some(projection("v1")), + ..settled() + }; + assert_eq!(decide(&observation).decision, Decision::StampBaseline); +} + +/// The same cell with no baseline at all. This is the bootstrap: without it +/// the baseline stays `None` forever and every baseline-dependent cell is +/// unreachable. +#[test] +fn test_disk_equal_to_head_stamps_baseline_when_baseline_is_missing() { + let observation = Observation { + disk: Some(projection("v2")), + head: HeadState::Present(head("v2", 200)), + baseline: None, + ..settled() + }; + assert_eq!(decide(&observation).decision, Decision::StampBaseline); +} + +/// Disk is untouched since the baseline, so the differing head is strictly +/// newer information and wins. +#[test] +fn test_untouched_disk_adopts_a_differing_head() { + let observation = Observation { + disk: Some(projection("v1")), + head: HeadState::Present(head("v2", 200)), + baseline: Some(projection("v1")), + ..settled() + }; + assert_eq!(decide(&observation).decision, Decision::ApplyHead); +} + +/// Without a baseline the difference cannot be attributed to a local edit, so +/// nothing is adopted and nothing is published. +#[test] +fn test_no_baseline_never_acts_on_a_difference() { + let observation = Observation { + disk: Some(projection("stale")), + head: HeadState::Present(head("current", 200)), + baseline: None, + ..settled() + }; + assert_eq!(decide(&observation).decision, Decision::Defer); +} + +/// Absence on disk is never deletion intent; that requires a tombstone. +#[test] +fn test_missing_disk_record_with_live_head_restores() { + let observation = Observation { + disk: None, + ..settled() + }; + assert_eq!(decide(&observation).decision, Decision::RestoreFromRelay); +} + +#[test] +fn test_missing_disk_record_and_missing_head_is_a_no_op() { + let observation = Observation { + disk: None, + head: HeadState::Absent, + ..settled() + }; + assert_eq!(decide(&observation).decision, Decision::SuppressPublish); +} + +/// A local edit with no head has no trustworthy timestamp to sign past, and +/// file mtime must not be used to manufacture one — so it needs a human. +#[test] +fn test_local_edit_with_no_head_parks() { + let observation = Observation { + disk: Some(projection("edited")), + head: HeadState::Absent, + baseline: Some(projection("v1")), + ..settled() + }; + assert_eq!( + decide(&observation).decision, + Decision::Park(ParkReason::LocalEditWithNoHead) + ); +} diff --git a/desktop/src-tauri/src/managed_agents/head_lookup.rs b/desktop/src-tauri/src/managed_agents/head_lookup.rs new file mode 100644 index 0000000000..dbeccf7cd1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/head_lookup.rs @@ -0,0 +1,140 @@ +//! Exact per-coordinate head lookups for the boot decision pass (V3.3). +//! +//! The decision table may only treat a head as ABSENT on the strength of a +//! lookup that (a) named the exact coordinate and (b) completed. Anything +//! weaker is reported as [`HeadState::LookupFailed`], which decides nothing. +//! +//! # Why absence needs its own query shape +//! +//! It is tempting to pull one inventory of all `30175/30176/30177` events for +//! the owner and treat "not in the list" as absent. That is unsound: the +//! effective server-side cap on a query is 1000 rows +//! (`crates/buzz-db/src/event.rs`), so a truncated page is indistinguishable +//! from a short one — absence would be inferred from a cut-off list. The +//! inventory may discover records, but only an exact `{kinds, authors, #d}` +//! lookup may deny them. +//! +//! # Known limitation +//! +//! On relay deployments that route historical reads through a read-replica +//! pool, a replica that has not yet replayed an event returns the same empty +//! page as a genuine deletion, with no staleness budget bounding the +//! difference. The barrier's tracing line is the detection mechanism if this +//! ever fires in the wild; re-adding a `sync_authoritative` opt-in to the +//! protocol is a small standalone change reversible on evidence. + +use serde_json::json; + +use super::{ + canonical_projection::CanonicalProjection, + decision::{Head, HeadState}, + retention::RetentionScope, +}; +use crate::app_state::AppState; + +/// One coordinate to look up: kind plus `d` tag, under the scope's owner. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Coordinate { + pub kind: u32, + pub d_tag: String, +} + +/// Build the exact filter for one coordinate's head. +/// +/// `limit: 2` rather than 1 is deliberate: NIP-33 guarantees at most one head +/// per coordinate, so a second row means the relay disagrees with that +/// assumption and the result must not be trusted as authoritative. +pub fn head_filter(owner_pubkey_hex: &str, coordinate: &Coordinate) -> serde_json::Value { + json!({ + "kinds": [coordinate.kind], + "authors": [owner_pubkey_hex], + "#d": [coordinate.d_tag], + "limit": 2, + }) +} + +/// Interpret the events a head lookup returned for one coordinate. +/// +/// Split from the network call so the interpretation rules are testable +/// without a relay. The caller passes only events it actually received; a +/// transport error must become [`HeadState::LookupFailed`] instead of an empty +/// slice, or a network failure would masquerade as a deletion. +pub fn head_state_from_events(coordinate: &Coordinate, events: &[nostr::Event]) -> HeadState { + // Defense in depth: the relay filtered by kind and `#d`, but a mismatched + // row here means the answer is about a different coordinate, so it cannot + // be used to deny this one. + let matching: Vec<&nostr::Event> = events + .iter() + .filter(|event| { + event.kind.as_u16() as u32 == coordinate.kind + && event_d_tag(event).as_deref() == Some(coordinate.d_tag.as_str()) + }) + .collect(); + + match matching.len() { + 0 => HeadState::Absent, + 1 => HeadState::Present(Head { + event_id: matching[0].id.to_hex(), + // Safety: nostr timestamps are seconds and stay below i64::MAX + // until year 2262 — the same cast the retention store uses. + created_at: matching[0].created_at.as_secs() as i64, + projection: CanonicalProjection::from_event(matching[0]), + }), + // More than one head for a replaceable coordinate contradicts NIP-33. + // Refuse to decide rather than pick one. + _ => HeadState::LookupFailed, + } +} + +/// The `d` tag of an event, if it has exactly one. +fn event_d_tag(event: &nostr::Event) -> Option { + let mut found: Option = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() >= 2 && parts[0].as_str() == "d" { + if found.is_some() { + // Two `d` tags: the coordinate is ambiguous. + return None; + } + found = Some(parts[1].to_string()); + } + } + found +} + +/// Look up one coordinate's head on the relay. +/// +/// Every failure mode — transport error, malformed response, contradictory +/// result — collapses to [`HeadState::LookupFailed`] so that no caller can +/// mistake a failed lookup for an authoritative absence. +pub async fn fetch_head( + state: &AppState, + scope: &RetentionScope, + api_base_url: &str, + coordinate: &Coordinate, +) -> HeadState { + let owner_hex = scope.owner_keys.public_key().to_hex(); + let filter = head_filter(&owner_hex, coordinate); + + match crate::relay::query_relay_at_with_keys( + state, + api_base_url, + std::slice::from_ref(&filter), + &scope.owner_keys, + None, + ) + .await + { + Ok(events) => head_state_from_events(coordinate, &events), + Err(error) => { + eprintln!( + "buzz-desktop: config-sync: head lookup failed for {}:{} — {error}", + coordinate.kind, coordinate.d_tag + ); + HeadState::LookupFailed + } + } +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/head_lookup/tests.rs b/desktop/src-tauri/src/managed_agents/head_lookup/tests.rs new file mode 100644 index 0000000000..033cddeb13 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/head_lookup/tests.rs @@ -0,0 +1,144 @@ +use super::*; +use crate::managed_agents::persona_events::build_persona_event; +use buzz_core_pkg::kind::{KIND_PERSONA, KIND_TEAM}; + +fn coordinate() -> Coordinate { + Coordinate { + kind: KIND_PERSONA, + d_tag: "test-persona".to_string(), + } +} + +fn signed_persona(keys: &nostr::Keys, d_tag: &str, shared: bool) -> nostr::Event { + use std::collections::BTreeMap; + let record = crate::managed_agents::AgentDefinition { + id: d_tag.to_string(), + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "prompt".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + }; + build_persona_event(&record) + .unwrap() + .sign_with_keys(keys) + .unwrap() +} + +/// The filter names the exact coordinate (kinds + authors + #d) and requests +/// limit:2 so a NIP-33 violation is detectable. This shape is what makes the +/// absence claim sound — an inventory query cannot bound absence. +#[test] +fn test_head_filter_is_exact() { + let filter = head_filter("ownerhex", &coordinate()); + + assert_eq!(filter["kinds"], serde_json::json!([KIND_PERSONA])); + assert_eq!(filter["authors"], serde_json::json!(["ownerhex"])); + assert_eq!(filter["#d"], serde_json::json!(["test-persona"])); +} + +/// A cap of 1 could not tell "exactly one head" from "the relay truncated the +/// page", so the lookup asks for one more than NIP-33 permits. +#[test] +fn test_head_filter_can_observe_a_nip33_violation() { + assert_eq!(head_filter("ownerhex", &coordinate())["limit"], 2); +} + +#[test] +fn test_empty_result_is_absent() { + assert_eq!( + head_state_from_events(&coordinate(), &[]), + HeadState::Absent + ); +} + +#[test] +fn test_single_matching_event_is_present_with_its_projection() { + let keys = nostr::Keys::generate(); + let event = signed_persona(&keys, "test-persona", false); + + assert_eq!( + head_state_from_events(&coordinate(), std::slice::from_ref(&event)), + HeadState::Present(Head { + event_id: event.id.to_hex(), + created_at: event.created_at.as_secs() as i64, + projection: CanonicalProjection::from_event(&event), + }) + ); +} + +/// The `shared` tag has to survive the lookup, or a disk-vs-head compare would +/// report a spurious difference for a shared persona and republish it forever. +#[test] +fn test_present_head_preserves_the_shared_tag() { + let keys = nostr::Keys::generate(); + let event = signed_persona(&keys, "test-persona", true); + + match head_state_from_events(&coordinate(), std::slice::from_ref(&event)) { + HeadState::Present(head) => assert!(head.projection.shared), + other => panic!("expected Present, got {other:?}"), + } +} + +/// Two heads for one replaceable coordinate contradicts NIP-33. Picking one +/// would make the decision depend on relay row order, so the lookup refuses. +#[test] +fn test_multiple_heads_fail_the_lookup_rather_than_guessing() { + let keys = nostr::Keys::generate(); + let events = [ + signed_persona(&keys, "test-persona", false), + signed_persona(&keys, "test-persona", true), + ]; + + assert_eq!( + head_state_from_events(&coordinate(), &events), + HeadState::LookupFailed + ); +} + +/// A row for a different coordinate cannot be used to answer this one. If it +/// were counted as a match, an unrelated event would mask a real absence; if +/// it were merely ignored without filtering, a mismatched relay response would +/// still read as "present". +#[test] +fn test_events_for_other_coordinates_are_ignored() { + let keys = nostr::Keys::generate(); + let other = signed_persona(&keys, "a-different-persona", false); + + assert_eq!( + head_state_from_events(&coordinate(), std::slice::from_ref(&other)), + HeadState::Absent + ); +} + +/// A kind mismatch is the cross-kind d-tag collision case: d-tags are not +/// unique across kinds, so a team with the same d-tag must not answer a +/// persona lookup. +#[test] +fn test_events_of_another_kind_do_not_answer_the_lookup() { + let keys = nostr::Keys::generate(); + let persona = signed_persona(&keys, "test-persona", false); + + let team_coordinate = Coordinate { + kind: KIND_TEAM, + d_tag: "test-persona".to_string(), + }; + assert_eq!( + head_state_from_events(&team_coordinate, std::slice::from_ref(&persona)), + HeadState::Absent + ); +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index be9b07cf11..0aee72eb70 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,7 +1,13 @@ mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; +pub(crate) mod canonical_projection; +pub(crate) mod config_barrier; +pub(crate) mod config_sync; +pub(crate) mod decision; +pub(crate) mod head_lookup; pub(crate) mod team_snapshot; +pub(crate) mod tombstone_scan; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 9476b3a468..0c24cac6f4 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -208,6 +208,7 @@ pub fn open_retention_db(path: &Path) -> Result { event_id TEXT, baseline_event_id TEXT, baseline_content TEXT, + publish_blocked INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (kind, pubkey, d_tag) );", ) @@ -480,12 +481,23 @@ pub fn get_retained_personas( /// deletion soft-deletes every live row at the coordinate with no timestamp /// comparison, so a tombstone published AFTER the replacement would wipe it. /// Within each group, oldest first for the same reason. +/// +/// # The publication gate (V3.9) +/// +/// `publish_blocked = 1` withholds a row from this list while leaving it +/// retained. The boot pass sets it when it decides a coordinate must stop +/// publishing — a head it cannot see, or a conflict parked for the user — +/// and clearing `pending_sync` would be wrong for both: the local content is +/// still the thing this device believes, and dropping the flag would lose that. +/// Suppression therefore needs a state of its own, and this query is the single +/// place it takes effect. Anything that bypasses this function bypasses the +/// gate. pub fn get_pending_sync(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id FROM persona_events - WHERE pending_sync = 1 + WHERE pending_sync = 1 AND publish_blocked = 0 ORDER BY (kind != 5), created_at ASC", ) .map_err(|e| format!("failed to prepare pending sync query: {e}"))?; @@ -509,6 +521,57 @@ pub fn get_pending_sync(conn: &Connection) -> Result, String> .map_err(|e| format!("failed to read pending sync row: {e}")) } +/// Every coordinate this owner has queued for publish, gated or not. +/// +/// The boot barrier enumerates from disk, but a pending row can outlive its +/// record: a deleted persona, a removed agent, or a kind-5 tombstone all leave +/// a queued row with nothing on disk to enumerate. Those are precisely the rows +/// that publish unsupervised, so the barrier unions this with its disk walk. +/// +/// Tombstone rows are mapped back to the coordinate they target, since that is +/// the coordinate the decision is about. Rows whose gate is already set are +/// still returned: a gate must be re-decided each boot so suppression can lift +/// once the condition that caused it is gone. +pub fn pending_coordinates(conn: &Connection, pubkey: &str) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT kind, d_tag FROM persona_events + WHERE pending_sync = 1 AND pubkey = ?1", + ) + .map_err(|e| format!("failed to prepare pending coordinate query: {e}"))?; + + let rows = stmt + .query_map(params![pubkey], |row| { + Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| format!("failed to query pending coordinates: {e}"))?; + + let mut out = Vec::new(); + for row in rows { + let (kind, d_tag) = row.map_err(|e| format!("failed to read pending coordinate: {e}"))?; + out.push(if kind == buzz_core_pkg::kind::KIND_DELETION { + match parse_tombstone_retention_d_tag(&d_tag) { + Some(target) => target, + // An unparseable tombstone key names no coordinate, so there is + // nothing to decide about. Skipped rather than guessed at. + None => continue, + } + } else { + (kind, d_tag) + }); + } + Ok(out) +} + +/// Recover the target coordinate from a tombstone row's retention key. +/// +/// Inverse of [`tombstone_retention_d_tag`]. Splits once from the left because +/// only the kind prefix is structural — a target d-tag may itself contain `:`. +fn parse_tombstone_retention_d_tag(key: &str) -> Option<(u32, String)> { + let (kind, d_tag) = key.split_once(':')?; + Some((kind.parse().ok()?, d_tag.to_string())) +} + /// Clear the `pending_sync` flag for an event the relay just confirmed. /// /// Compare-and-clear: only clears the row if its `created_at` and `content` @@ -535,6 +598,114 @@ pub fn mark_synced( Ok(()) } +/// Drop a queued publish without touching the row's retained content. +/// +/// Used by the boot pass for a queued event that publishes exactly what the +/// baseline already says is published — a reconcile re-sign carrying no new +/// intent. Such a row can never become publishable, so gating it would hold it +/// forever; clearing the flag is the honest outcome. +/// +/// Distinct from [`mark_synced`], which clears the flag on evidence the RELAY +/// accepted those exact bytes. This clears it on evidence the relay already +/// had them. +pub fn clear_pending_sync( + conn: &Connection, + kind: u32, + pubkey: &str, + d_tag: &str, +) -> Result<(), String> { + conn.execute( + "UPDATE persona_events SET pending_sync = 0 + WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", + params![kind, pubkey, d_tag], + ) + .map_err(|e| format!("failed to clear pending sync: {e}"))?; + + Ok(()) +} + +/// Withhold a coordinate from the flush loop without discarding its content. +/// +/// Set when the boot pass decides a coordinate must stop publishing: a head it +/// could not see (V3.10 suppression) or a conflict parked for the user (V3.6). +/// The row keeps `pending_sync` and its content — this device still believes +/// that content — but [`get_pending_sync`] will not hand it to the publisher. +/// +/// Idempotent, and a no-op on a coordinate with no row: there is nothing to +/// withhold until something is retained there. +pub fn set_publish_blocked( + conn: &Connection, + kind: u32, + pubkey: &str, + d_tag: &str, + blocked: bool, +) -> Result<(), String> { + conn.execute( + "UPDATE persona_events SET publish_blocked = ?4 + WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", + params![kind, pubkey, d_tag, i32::from(blocked)], + ) + .map_err(|e| format!("failed to set publish gate: {e}"))?; + + Ok(()) +} + +/// The baseline recorded for a coordinate: what this install last agreed was +/// published there. +/// +/// Read as a unit because the two columns are only meaningful together — an +/// id without content cannot be compared, and content without an id has no +/// provenance. `None` means the baseline was never established. +pub fn get_baseline( + conn: &Connection, + kind: u32, + pubkey: &str, + d_tag: &str, +) -> Result, String> { + conn.query_row( + "SELECT baseline_event_id, baseline_content + FROM persona_events + WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", + params![kind, pubkey, d_tag], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + )) + }, + ) + .optional() + .map_err(|e| format!("failed to get baseline: {e}")) + // Both halves must be present: a row carrying only one is not a usable + // baseline and is treated as if none was recorded. + .map(|row| row.and_then(|(id, content)| Some((id?, content?)))) +} + +/// Record what this install now agrees is published at a coordinate. +/// +/// Only the boot decision pass and the publish-confirmation path call this: a +/// baseline is a claim about the RELAY's state, so it may only be stamped from +/// an event that is actually on the relay — never from local disk content that +/// has not been published. +pub fn set_baseline( + conn: &Connection, + kind: u32, + pubkey: &str, + d_tag: &str, + baseline_event_id: &str, + baseline_content: &str, +) -> Result<(), String> { + conn.execute( + "UPDATE persona_events + SET baseline_event_id = ?4, baseline_content = ?5 + WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", + params![kind, pubkey, d_tag, baseline_event_id, baseline_content], + ) + .map_err(|e| format!("failed to set baseline: {e}"))?; + + Ok(()) +} + /// Delete a retained event by its coordinate. /// /// Called from the synchronous, lock-held delete-persona command body so the @@ -557,6 +728,30 @@ pub fn delete_retained_event( Ok(()) } +/// Whether a coordinate's row is currently withheld from the flush loop. +/// +/// Test-only: production code never asks, because +/// [`get_pending_sync`] applies the gate in SQL rather than filtering after +/// the fact. Tests need to distinguish "withheld" from "no row at all", which +/// a `get_pending_sync` count alone cannot. +#[cfg(test)] +pub fn is_publish_blocked( + conn: &Connection, + kind: u32, + pubkey: &str, + d_tag: &str, +) -> Result { + conn.query_row( + "SELECT publish_blocked FROM persona_events + WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", + params![kind, pubkey, d_tag], + |row| row.get::<_, i32>(0), + ) + .optional() + .map_err(|e| format!("failed to read publish gate: {e}")) + .map(|blocked| blocked == Some(1)) +} + /// Check if the retention store has any persona events for the given pubkey. #[cfg(test)] pub fn has_retained_personas(conn: &Connection, pubkey: &str) -> Result { diff --git a/desktop/src-tauri/src/managed_agents/retention/schema.rs b/desktop/src-tauri/src/managed_agents/retention/schema.rs index c0d548aabd..82cf7cff7a 100644 --- a/desktop/src-tauri/src/managed_agents/retention/schema.rs +++ b/desktop/src-tauri/src/managed_agents/retention/schema.rs @@ -13,6 +13,10 @@ //! published. This is what lets the boot pass tell "the user edited this //! locally" from "this store never learned about the newer relay head", //! instead of inferring intent from a content diff. +//! - `publish_blocked` — the publication gate. A pending row is normally +//! published by the flush loop; a row the boot pass suppressed must stay +//! retained but unpublished, and there is no other state that expresses +//! "durable, but must not go out." //! //! # Crash and concurrency safety //! @@ -27,10 +31,11 @@ use rusqlite::{Connection, TransactionBehavior}; /// Columns added after the initial `persona_events` shape. -const ADDED_COLUMNS: [(&str, &str); 3] = [ +const ADDED_COLUMNS: [(&str, &str); 4] = [ ("event_id", "TEXT"), ("baseline_event_id", "TEXT"), ("baseline_content", "TEXT"), + ("publish_blocked", "INTEGER NOT NULL DEFAULT 0"), ]; /// Bring `persona_events` up to the current column set. diff --git a/desktop/src-tauri/src/managed_agents/tombstone_scan.rs b/desktop/src-tauri/src/managed_agents/tombstone_scan.rs new file mode 100644 index 0000000000..d342ed3e38 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/tombstone_scan.rs @@ -0,0 +1,234 @@ +//! The exhaustive owner kind:5 scan (V3.3), and the evidence it yields. +//! +//! [`TombstoneEvidence::NotFound`] is a claim that the owner never deleted a +//! coordinate. Under V3.11 that claim is what lets a genuinely new record +//! publish against a provably empty coordinate, and under V3.10 its opposite is +//! the only thing that authorizes deleting a local record. Both directions are +//! consequential, so the claim may only come from a scan that ran to +//! completion — anything less reports [`TombstoneEvidence::ScanIncomplete`], +//! which decides nothing. +//! +//! # Why the scan is exhaustive rather than filtered +//! +//! The obvious query is one `#a`-filtered lookup per coordinate. The relay +//! cannot serve it soundly: `#a` is not a pushed-down constraint +//! (`filter_fully_pushable` in `crates/buzz-relay/src/handlers/req.rs` lists +//! `#a` among the post-filtered keys), so the server applies it AFTER the SQL +//! `LIMIT`. A page that fills with 1000 unrelated kind:5 events returns empty +//! for the coordinate asked about — indistinguishable from no tombstone +//! existing. So the scan pulls the owner's kind:5 history by pages, filters +//! `a`-tags locally, and only reports absence once a page comes back short of +//! its own limit. +//! +//! # Why one scan for every coordinate +//! +//! The scan is per-OWNER, not per-coordinate: one paginated walk yields the +//! evidence for every coordinate the boot pass is about to decide. A +//! per-coordinate scan would multiply the same pages by the number of records +//! on the device for an identical answer. + +use std::collections::BTreeSet; + +use serde_json::json; + +use super::{decision::TombstoneEvidence, head_lookup::Coordinate, retention::RetentionScope}; +use crate::app_state::AppState; + +/// Page size for the scan. The relay clamps any request to 1000 +/// (`crates/buzz-db/src/event.rs`), so asking for more would silently get 1000 +/// and make a full page look short. +const SCAN_PAGE_LIMIT: usize = 1000; + +/// Hard bound on pages walked in one boot, so a pathological history cannot +/// stall startup indefinitely. Hitting it yields an INCOMPLETE scan, never a +/// premature `NotFound`. +const MAX_SCAN_PAGES: usize = 20; + +/// Which coordinates the owner has tombstoned, from a scan that either ran to +/// completion or did not. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TombstonedCoordinates { + tombstoned: BTreeSet<(u32, String)>, + complete: bool, +} + +impl TombstonedCoordinates { + /// The result of a scan that did not finish. Every lookup against it + /// reports [`TombstoneEvidence::ScanIncomplete`], even for coordinates the + /// partial results did name — a partial scan cannot support `NotFound`, and + /// reporting `Found` from it would make the evidence's meaning depend on + /// which page a tombstone happened to land on. + pub fn incomplete() -> Self { + Self { + tombstoned: BTreeSet::new(), + complete: false, + } + } + + /// The evidence this scan provides about one coordinate. + pub fn evidence(&self, coordinate: &Coordinate) -> TombstoneEvidence { + if !self.complete { + return TombstoneEvidence::ScanIncomplete; + } + if self + .tombstoned + .contains(&(coordinate.kind, coordinate.d_tag.clone())) + { + TombstoneEvidence::Found + } else { + TombstoneEvidence::NotFound + } + } +} + +/// One page of the owner's kind:5 history, oldest-bound by a keyset cursor. +/// +/// # Known limitation +/// +/// On relay deployments that route historical reads through a read-replica +/// pool, a replica that has not yet replayed a tombstone answers "not found" +/// in the dangerous direction. The barrier's tracing line surfaces this if +/// it ever fires; re-adding `sync_authoritative` to the protocol is a small +/// standalone change reversible on observed evidence. +pub fn scan_page_filter(owner_pubkey_hex: &str, cursor: Option<&PageCursor>) -> serde_json::Value { + let mut filter = json!({ + "kinds": [buzz_core_pkg::kind::KIND_DELETION], + "authors": [owner_pubkey_hex], + "limit": SCAN_PAGE_LIMIT, + }); + if let Some(cursor) = cursor { + // Composite keyset cursor: the relay requires both halves or neither, + // and rejects `before_id` without `until`. A timestamp-only cursor + // would drop every event tied at the page boundary's second. + filter["until"] = json!(cursor.until); + filter["before_id"] = json!(cursor.before_id); + } + filter +} + +/// The `(created_at, id)` pair that bounds the next page. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageCursor { + pub until: i64, + pub before_id: String, +} + +/// The cursor for the page after `events`, or `None` when this page ends the +/// walk. +/// +/// A page shorter than the limit it asked for is the ONLY proof of exhaustion. +/// `rows == limit` never proves there is more, but it never proves there isn't +/// either, so the walk continues until a page comes back short. +pub fn next_cursor(events: &[nostr::Event]) -> Option { + if events.len() < SCAN_PAGE_LIMIT { + return None; + } + let last = events.last()?; + Some(PageCursor { + until: last.created_at.as_secs() as i64, + before_id: last.id.to_hex(), + }) +} + +/// The config coordinates a page of kind:5 events tombstones. +/// +/// `a`-tags are parsed locally with the same owner-scoping rule the inbound +/// tombstone path uses: only the coordinate's own author may tombstone it, so +/// a validly signed kind:5 naming another owner's coordinate names nothing +/// here. Coordinates outside the three config kinds are ignored — the owner's +/// kind:5 history includes deletions this pass has no opinion about. +pub fn tombstoned_in_page(events: &[nostr::Event]) -> BTreeSet<(u32, String)> { + use super::canonical_projection::CanonicalProjection; + + events + .iter() + .filter_map(|event| { + let (kind, d_tag) = deletion_coordinate(event)?; + CanonicalProjection::is_config_kind(kind).then_some((kind, d_tag)) + }) + .collect() +} + +/// Parse a NIP-09 `a`-tag coordinate `::` into its +/// target kind and d-tag, rejecting a coordinate the event's author does not +/// own. +fn deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> { + event.tags.iter().find_map(|tag| { + let values = tag.as_slice(); + if values.first().map(|v| v.as_str()) != Some("a") { + return None; + } + // `::` — the d-tag may itself contain ':', so split + // at most twice and keep the remainder whole. + let mut parts = values.get(1)?.splitn(3, ':'); + let kind: u32 = parts.next()?.parse().ok()?; + if parts.next()? != event.pubkey.to_hex() { + return None; + } + Some((kind, parts.next()?.to_string())) + }) +} + +/// Walk the owner's kind:5 history to exhaustion. +/// +/// Every failure mode — a transport error, a page that never ends — yields +/// [`TombstonedCoordinates::incomplete`], so a scan that could not prove +/// absence never claims it. +pub async fn scan_tombstones( + state: &AppState, + scope: &RetentionScope, + api_base_url: &str, +) -> TombstonedCoordinates { + let owner_hex = scope.owner_keys.public_key().to_hex(); + let mut tombstoned = BTreeSet::new(); + let mut cursor: Option = None; + + for _ in 0..MAX_SCAN_PAGES { + let filter = scan_page_filter(&owner_hex, cursor.as_ref()); + let events = match crate::relay::query_relay_at_with_keys( + state, + api_base_url, + std::slice::from_ref(&filter), + &scope.owner_keys, + None, + ) + .await + { + Ok(events) => events, + Err(error) => { + tracing::warn!( + target: "buzz::config_sync", + %error, + "tombstone scan page failed; evidence is incomplete" + ); + return TombstonedCoordinates::incomplete(); + } + }; + + tombstoned.extend(tombstoned_in_page(&events)); + match next_cursor(&events) { + Some(next) => cursor = Some(next), + None => { + tracing::info!( + target: "buzz::config_sync", + tombstoned = tombstoned.len(), + "tombstone scan complete" + ); + return TombstonedCoordinates { + tombstoned, + complete: true, + }; + } + } + } + + tracing::warn!( + target: "buzz::config_sync", + max_pages = MAX_SCAN_PAGES, + "tombstone scan hit its page bound; evidence is incomplete" + ); + TombstonedCoordinates::incomplete() +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/tombstone_scan/tests.rs b/desktop/src-tauri/src/managed_agents/tombstone_scan/tests.rs new file mode 100644 index 0000000000..7209022cd9 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/tombstone_scan/tests.rs @@ -0,0 +1,224 @@ +use super::*; +use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; +use nostr::{EventBuilder, Keys, Kind, Tag}; + +/// A signed kind:5 carrying one `a`-tag, with control over whose coordinate it +/// names — the owner-scoping rule is what makes a foreign tombstone inert. +fn deletion_of(keys: &Keys, coordinate_owner_hex: &str, kind: u32, d_tag: &str) -> nostr::Event { + let coord = format!("{kind}:{coordinate_owner_hex}:{d_tag}"); + EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "") + .tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()]) + .sign_with_keys(keys) + .unwrap() +} + +/// A kind:5 deleting by event id instead of coordinate. It names no +/// parameterized coordinate at all. +fn deletion_by_event_id(keys: &Keys) -> nostr::Event { + EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "") + .tags(vec![Tag::parse(["e", &"ab".repeat(32)]).unwrap()]) + .sign_with_keys(keys) + .unwrap() +} + +fn coordinate(kind: u32, d_tag: &str) -> Coordinate { + Coordinate { + kind, + d_tag: d_tag.to_string(), + } +} + +// ── The evidence contract ────────────────────────────────────────────────── + +/// An incomplete scan decides nothing. Reporting `Found` from partial results +/// would make the evidence's meaning depend on which page a tombstone landed +/// on, and reporting `NotFound` would be the unsound direction outright. +#[test] +fn test_an_incomplete_scan_reports_no_evidence_for_any_coordinate() { + let scan = TombstonedCoordinates::incomplete(); + + assert_eq!( + scan.evidence(&coordinate(KIND_PERSONA, "anything")), + TombstoneEvidence::ScanIncomplete + ); +} + +#[test] +fn test_a_complete_scan_distinguishes_tombstoned_from_untouched() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let events = [deletion_of(&keys, &owner, KIND_PERSONA, "deleted")]; + + let scan = TombstonedCoordinates { + tombstoned: tombstoned_in_page(&events), + complete: true, + }; + + assert_eq!( + scan.evidence(&coordinate(KIND_PERSONA, "deleted")), + TombstoneEvidence::Found + ); + assert_eq!( + scan.evidence(&coordinate(KIND_PERSONA, "never-deleted")), + TombstoneEvidence::NotFound + ); + // Same d-tag under a different kind is a different coordinate. + assert_eq!( + scan.evidence(&coordinate(KIND_TEAM, "deleted")), + TombstoneEvidence::NotFound + ); +} + +// ── Parsing a page into coordinates ──────────────────────────────────────── + +/// Only the coordinate's own author may tombstone it. A validly signed kind:5 +/// naming someone else's coordinate is inert, or one owner could suppress +/// another's config by publishing a deletion for it. +#[test] +fn test_a_tombstone_for_another_owners_coordinate_names_nothing() { + let keys = Keys::generate(); + let stranger = Keys::generate().public_key().to_hex(); + + let tombstoned = tombstoned_in_page(&[deletion_of(&keys, &stranger, KIND_PERSONA, "victim")]); + + assert!( + tombstoned.is_empty(), + "a foreign coordinate must not be treated as deleted: {tombstoned:?}" + ); +} + +/// The owner's kind:5 history covers deletions this pass has no opinion about. +#[test] +fn test_deletions_outside_the_config_kinds_are_ignored() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + + let tombstoned = tombstoned_in_page(&[ + deletion_of(&keys, &owner, 30023, "some-article"), + deletion_of(&keys, &owner, KIND_MANAGED_AGENT, "agent"), + ]); + + assert_eq!( + tombstoned, + BTreeSet::from([(KIND_MANAGED_AGENT, "agent".to_string())]) + ); +} + +/// A d-tag may itself contain `:`, so the coordinate splits at most twice and +/// keeps the remainder whole. Truncating here would report evidence about a +/// coordinate that does not exist while missing the one that does. +#[test] +fn test_a_d_tag_containing_colons_survives_parsing() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + + let tombstoned = tombstoned_in_page(&[deletion_of(&keys, &owner, KIND_PERSONA, "a:b:c")]); + + assert_eq!( + tombstoned, + BTreeSet::from([(KIND_PERSONA, "a:b:c".to_string())]) + ); +} + +#[test] +fn test_an_event_id_deletion_names_no_coordinate() { + let keys = Keys::generate(); + + assert!(tombstoned_in_page(&[deletion_by_event_id(&keys)]).is_empty()); +} + +// ── Pagination ───────────────────────────────────────────────────────────── + +/// A short page is the ONLY proof the walk is exhausted. +#[test] +fn test_a_short_page_ends_the_walk() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + + assert_eq!( + next_cursor(&[deletion_of(&keys, &owner, KIND_PERSONA, "one")]), + None + ); + assert_eq!(next_cursor(&[]), None); +} + +/// A page that exactly fills its limit proves nothing about exhaustion, so the +/// walk continues from the oldest event on it. +#[test] +fn test_a_full_page_continues_from_its_oldest_event() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let filler = deletion_of(&keys, &owner, KIND_PERSONA, "filler"); + let oldest = deletion_of(&keys, &owner, KIND_PERSONA, "oldest"); + + let mut page = vec![filler; SCAN_PAGE_LIMIT - 1]; + page.push(oldest.clone()); + + assert_eq!( + next_cursor(&page), + Some(PageCursor { + until: oldest.created_at.as_secs() as i64, + before_id: oldest.id.to_hex(), + }), + "the cursor must be the composite keyset of the page's last event" + ); +} + +// ── The page filter ──────────────────────────────────────────────────────── + +/// The page filter names the owner's kind:5 events with the SCAN_PAGE_LIMIT +/// cap. The scan is per-owner (not per-coordinate) so one walk covers every +/// coordinate the barrier is deciding on. +#[test] +fn test_page_filter_covers_owner_deletions() { + let with_cursor = scan_page_filter( + "ownerhex", + Some(&PageCursor { + until: 100, + before_id: "abc".to_string(), + }), + ); + + for filter in [scan_page_filter("ownerhex", None), with_cursor] { + assert_eq!(filter["kinds"], serde_json::json!([KIND_DELETION])); + assert_eq!(filter["authors"], serde_json::json!(["ownerhex"])); + } +} + +/// The relay rejects `before_id` without `until` and drops events tied at the +/// boundary second given `until` alone, so the cursor is all-or-nothing. +#[test] +fn test_the_cursor_halves_are_both_present_or_both_absent() { + let first = scan_page_filter("ownerhex", None); + assert!(first.get("until").is_none()); + assert!(first.get("before_id").is_none()); + + let next = scan_page_filter( + "ownerhex", + Some(&PageCursor { + until: 100, + before_id: "abc".to_string(), + }), + ); + assert_eq!(next["until"], serde_json::json!(100)); + assert_eq!(next["before_id"], serde_json::json!("abc")); +} + +/// Asking for more than the relay's clamp would silently get the clamp back +/// and make a full page look short — which is exactly the state that ends the +/// walk and licenses a `NotFound`. +#[test] +fn test_the_page_limit_does_not_exceed_the_relay_clamp() { + /// The relay's own clamp (`crates/buzz-db/src/event.rs`). + const RELAY_CLAMP: u64 = 1000; + + let limit = scan_page_filter("ownerhex", None)["limit"] + .as_u64() + .expect("the filter must carry a numeric limit"); + + assert_eq!(limit, SCAN_PAGE_LIMIT as u64); + assert!( + limit <= RELAY_CLAMP, + "asking for {limit} would silently get {RELAY_CLAMP} back and make a full page look short" + ); +} From 5471703a006d264b090244285711c4e312274126 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 30 Jul 2026 19:33:03 -0400 Subject: [PATCH 4/8] fix(managed-agents): enforce fail-closed per-scope barrier before flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-scope readiness latch (config_sync_ready_scope in AppState) that the flush loop checks before publishing any row. Before this, the flush loop ran independently of the boot barrier, so legacy pending rows from a prior boot were publishable at t≈0 — before reconcile or barrier had run — recreating the stale-store revert this PR exists to close. Changes: - Add publish_blocked field to RetainedEvent and carry it in all query sites (get_pending_sync, get_retained_event, get_retained_personas, legacy_migration reader). New rows start publish_blocked: false; the barrier sets it via set_publish_blocked. - Add config_sync_ready_scope: Mutex> to AppState. Starts None. Set to Some(db_path) by run_boot_barrier on successful completion; never set on any error path. - flush_active_pending_events checks readiness before snapshotting pending rows. If not ready it runs run_boot_barrier inline, then re-checks: a barrier failure skips this tick and retries on the next, so a transient relay-unreachable at boot does not wedge publishing for the session. - flush_pending_events_at re-reads publish_blocked immediately before submit_signed_event_at_with_keys. A row gated after the pending snapshot was taken cannot publish. - apply_workspace clears config_sync_ready_scope to None before spawn_event_sync so a workspace switch lands unready until its own barrier passes. - Remove the 'in practice' timer-luck paragraph from event_sync.rs; replace with the enforced structural invariant. - Remove all writer-consistent and sync_authoritative references from desktop/src-tauri; replace with exact, best-available, replica-lag-exposed language. Grep acceptance: empty. - Add three deterministic tests in flush_barrier module: (a) test_row_gated_after_snapshot_cannot_submit: barrier gates a row after the pending snapshot; flush sees publish_blocked on re-read and skips it; row stays pending. (b) test_barrier_error_leaves_scope_unready: latch starts None, stays None until barrier succeeds, cleared on workspace switch. (c) test_retry_path_publishes_after_barrier_succeeds: latch None → scope not ready; latch set → flush proceeds and row publishes. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/app_state.rs | 14 ++ desktop/src-tauri/src/commands/workspace.rs | 7 + desktop/src-tauri/src/event_sync.rs | 14 +- .../src/managed_agents/config_barrier.rs | 40 +++- .../src/managed_agents/config_sync.rs | 3 +- .../src/managed_agents/config_sync/tests.rs | 1 + .../src-tauri/src/managed_agents/decision.rs | 4 +- .../src/managed_agents/decision/tests.rs | 5 +- .../src/managed_agents/head_lookup.rs | 4 +- .../src/managed_agents/persona_events.rs | 53 +++++ .../managed_agents/persona_events/tests.rs | 183 ++++++++++++++++++ .../src/managed_agents/reconcile/tests.rs | 1 + .../src-tauri/src/managed_agents/retention.rs | 31 ++- .../retention/legacy_migration.rs | 4 + .../retention/legacy_migration/tests.rs | 3 + .../src/managed_agents/retention/tests.rs | 6 +- .../src/managed_agents/tombstone_scan.rs | 4 +- 17 files changed, 348 insertions(+), 29 deletions(-) diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 94d162e620..6f8479c3bb 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -48,6 +48,19 @@ pub struct AppState { pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, + /// Per-scope config-sync readiness latch. + /// + /// Holds the `db_path` of the retention scope whose boot barrier has + /// completed successfully. `None` means no scope is ready. The flush loop + /// checks this before publishing: if the active scope's `db_path` does not + /// match, it runs the barrier inline (retry path) before attempting any + /// publishes. `apply_workspace` clears this to `None` before spawning a + /// new sync, so the incoming scope is unready until its own barrier passes. + /// + /// Invariant: once set to `Some(path)`, the barrier has enforced the + /// publication gate for every coordinate in that scope. Any error path in + /// the barrier leaves this as `None`, keeping the scope fail-closed. + pub config_sync_ready_scope: Mutex>, /// Tauri app handle — stored after setup so huddle commands can emit /// `huddle-state-changed` events without needing the handle threaded /// through every call site. @@ -213,6 +226,7 @@ pub fn build_app_state() -> AppState { managed_agent_processes: Mutex::new(HashMap::new()), session_config_cache: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), + config_sync_ready_scope: Mutex::new(None), app_handle: Mutex::new(None), audio_output_device: Mutex::new(None), media_proxy_port: AtomicU16::new(0), diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 731a99d9d9..24c4d10b79 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -217,6 +217,13 @@ pub async fn apply_workspace( // collapse every community into one pending-event store. match crate::managed_agents::retention::active_retention_scope(&restore_app, &state) { Ok(scope) => { + // Clear the readiness latch before spawning so the new scope is + // unready until its own boot barrier completes. Without this, + // a prior scope's latch could let the new scope's flush loop + // publish before its gate has run. + if let Ok(mut ready) = state.config_sync_ready_scope.lock() { + *ready = None; + } // Adopt whatever the pre-scoping release left queued in the global // retention database BEFORE the scoped reconcile and flush run, so // stranded tombstones and archive requests publish on this boot diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index d05701b1c3..0b3aeff0d1 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -26,16 +26,16 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys, db_path: /// SQLite, and signing work, so it runs on the blocking pool rather than an /// async worker. /// -/// # Ordering: reconcile, then barrier, and both before the flush publishes +/// # Ordering: reconcile, then barrier, enforced by the flush readiness latch /// /// The reconcile marks changed coordinates `pending_sync = 1`; the boot barrier /// ([`crate::managed_agents::config_barrier::run_boot_barrier`]) then decides -/// which of those may actually go out and gates the rest. Running the barrier -/// after the reconcile is what lets it see the rows the reconcile just queued — -/// the stale-store republish it exists to suppress is created right here. The -/// flush loop's first tick is 30s of relay lookups away, so in practice the gate -/// is set before anything publishes; if a flush did win the race, the barrier -/// still gates every later sweep. +/// which of those may actually go out and gates the rest. The flush loop +/// enforces this ordering structurally: before publishing any row it checks the +/// per-scope readiness latch in `AppState::config_sync_ready_scope`; if unset, +/// it runs the barrier inline and only proceeds on success. This guarantees that +/// nothing publishes for a scope until the barrier has completed successfully — +/// regardless of when the flush loop's first tick fires. pub fn spawn_event_sync( app: tauri::AppHandle, owner_keys: nostr::Keys, diff --git a/desktop/src-tauri/src/managed_agents/config_barrier.rs b/desktop/src-tauri/src/managed_agents/config_barrier.rs index 7c07bfdde2..e623970afb 100644 --- a/desktop/src-tauri/src/managed_agents/config_barrier.rs +++ b/desktop/src-tauri/src/managed_agents/config_barrier.rs @@ -45,11 +45,15 @@ use super::{ use crate::app_state::AppState; use buzz_core_pkg::kind::{KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; -/// Run the boot barrier for the active scope, best-effort. +/// Run the boot barrier for the active scope. /// -/// Failures are logged and swallowed: a barrier that cannot run must not block -/// boot, and its absence is not itself dangerous — it leaves the pre-barrier -/// behaviour, which is what every prior release did. +/// On success, marks the scope's `db_path` as ready in `AppState`, allowing +/// the flush loop to publish for that scope. On any error, leaves the ready +/// latch unset so the scope stays fail-closed until a later retry succeeds. +/// +/// This is called from the flush loop when it finds the scope not ready — +/// so a transient failure (relay unreachable) retries on the next flush tick +/// rather than wedging publishing for the session. pub async fn run_boot_barrier(app: &tauri::AppHandle) { let state = app.state::(); let scope = match active_retention_scope(app, &state) { @@ -60,8 +64,32 @@ pub async fn run_boot_barrier(app: &tauri::AppHandle) { } }; - if let Err(error) = run_boot_barrier_for_scope(app, &state, &scope).await { - tracing::warn!(target: "buzz::config_sync", %error, "boot barrier failed"); + match run_boot_barrier_for_scope(app, &state, &scope).await { + Ok(()) => { + // Mark the scope ready. Any error in the scope check or lock is + // treated as a barrier failure — leave unready rather than risk + // opening the gate without enforcement. + match state.config_sync_ready_scope.lock() { + Ok(mut guard) => { + *guard = Some(scope.db_path.clone()); + tracing::info!( + target: "buzz::config_sync", + db_path = %scope.db_path.display(), + "boot barrier complete; scope is ready for publication" + ); + } + Err(error) => { + tracing::warn!( + target: "buzz::config_sync", + %error, + "boot barrier completed but failed to set ready latch; scope stays closed" + ); + } + } + } + Err(error) => { + tracing::warn!(target: "buzz::config_sync", %error, "boot barrier failed; scope stays closed for retry"); + } } } diff --git a/desktop/src-tauri/src/managed_agents/config_sync.rs b/desktop/src-tauri/src/managed_agents/config_sync.rs index 49876609b8..3c0c526828 100644 --- a/desktop/src-tauri/src/managed_agents/config_sync.rs +++ b/desktop/src-tauri/src/managed_agents/config_sync.rs @@ -271,7 +271,8 @@ pub fn apply_gate( /// Record the relay head as this install's baseline for a coordinate. /// -/// Only ever called with a head that a completed, writer-consistent lookup +/// Only ever called with a head that a completed, exact, best-available +/// (replica-lag-exposed) lookup /// actually returned, which is what makes the baseline a claim about the RELAY /// rather than about local disk. Stamping from disk content would defeat the /// entire model: the baseline's job is to distinguish "the user edited this" diff --git a/desktop/src-tauri/src/managed_agents/config_sync/tests.rs b/desktop/src-tauri/src/managed_agents/config_sync/tests.rs index c6d4766e7d..e064364c71 100644 --- a/desktop/src-tauri/src/managed_agents/config_sync/tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_sync/tests.rs @@ -108,6 +108,7 @@ fn retain_prompt( }, pending_sync: pending, event_id: Some(event.id.to_hex()), + publish_blocked: false, }, ) .unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/decision.rs b/desktop/src-tauri/src/managed_agents/decision.rs index b0d8374790..5f7bf48626 100644 --- a/desktop/src-tauri/src/managed_agents/decision.rs +++ b/desktop/src-tauri/src/managed_agents/decision.rs @@ -61,7 +61,7 @@ pub struct Observation { /// about content that is not the content going out. pub queued: Option, /// The relay head's state. `Absent` and `LookupFailed` are distinct: only a - /// completed, writer-consistent lookup can report absence. + /// completed, exact, best-available (replica-lag-exposed) lookup can report absence. pub head: HeadState, /// The projection this install last agreed was published for this /// coordinate. `None` before the baseline is established. @@ -239,7 +239,7 @@ pub fn decide(observation: &Observation) -> Resolution { HeadState::Present(_) => Decision::Park(ParkReason::NoBaselineWithHead), HeadState::Absent => match observation.tombstone { // Cell 3 — a create against a provably empty coordinate: a - // completed writer-consistent lookup says no head and a + // completed, exact, best-available lookup says no head and a // completed scan says no tombstone. Nothing to revert and // nothing to resurrect, so the genuinely-new record publishes. // A queued deletion here removes a purely local coordinate. diff --git a/desktop/src-tauri/src/managed_agents/decision/tests.rs b/desktop/src-tauri/src/managed_agents/decision/tests.rs index 7f821389e2..fa79e1a940 100644 --- a/desktop/src-tauri/src/managed_agents/decision/tests.rs +++ b/desktop/src-tauri/src/managed_agents/decision/tests.rs @@ -322,8 +322,9 @@ fn test_arbitration_reads_the_queued_row_not_disk() { // ── Cell 3: the evidence-gated carve-out ─────────────────────────────────── -/// A create against a provably empty coordinate: a completed writer-consistent -/// lookup says no head and a completed scan says no tombstone. Nothing to +/// A create against a provably empty coordinate: a completed, exact, +/// best-available (replica-lag-exposed) lookup says no head and a completed +/// scan says no tombstone. Nothing to /// revert and nothing to resurrect, so a genuinely new agent publishes rather /// than parking forever. #[test] diff --git a/desktop/src-tauri/src/managed_agents/head_lookup.rs b/desktop/src-tauri/src/managed_agents/head_lookup.rs index dbeccf7cd1..229c66655c 100644 --- a/desktop/src-tauri/src/managed_agents/head_lookup.rs +++ b/desktop/src-tauri/src/managed_agents/head_lookup.rs @@ -20,8 +20,8 @@ //! pool, a replica that has not yet replayed an event returns the same empty //! page as a genuine deletion, with no staleness budget bounding the //! difference. The barrier's tracing line is the detection mechanism if this -//! ever fires in the wild; re-adding a `sync_authoritative` opt-in to the -//! protocol is a small standalone change reversible on evidence. +//! ever fires in the wild; re-adding a relay-side writer-pinned read opt-in to +//! the protocol is a small standalone change reversible on evidence. use serde_json::json; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 6afc18a501..4942940129 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -239,11 +239,57 @@ pub async fn flush_pending_events( /// The scope snapshots its relay, owner keys, and database path together /// before network work starts. Switching communities during the flush cannot /// redirect rows from the old scope into the new relay. +/// +/// # Readiness gate +/// +/// Before taking a publish snapshot, this function checks whether the active +/// scope's boot barrier has completed successfully. If not (first run or after +/// a workspace switch), it runs the barrier inline before flushing. A barrier +/// failure leaves the scope unready and skips the flush for this tick — the +/// next tick retries, so a transient relay-unreachable at boot does not wedge +/// publishing for the session. pub async fn flush_active_pending_events( app: &tauri::AppHandle, state: &AppState, ) -> Result { let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + + // Check readiness before snapshotting pending rows. The check and the + // snapshot are separate DB reads — a gate set between the two is caught + // by the per-row publish_blocked re-read immediately before submit. + let scope_is_ready = state + .config_sync_ready_scope + .lock() + .map_err(|e| format!("config_sync_ready_scope lock poisoned: {e}"))? + .as_ref() + .is_some_and(|ready| *ready == scope.db_path); + + if !scope_is_ready { + // Barrier not yet run (first boot tick) or a previous barrier failed + // (relay unreachable). Run it now; on success the latch is set and the + // flush proceeds. On failure the scope stays unready and we skip this + // tick — next tick retries the barrier rather than wedging forever. + crate::managed_agents::config_barrier::run_boot_barrier(app).await; + + // Re-check readiness after the barrier attempt. If it failed, skip + // this flush tick; the barrier will retry on the next one. + let now_ready = state + .config_sync_ready_scope + .lock() + .map_err(|e| format!("config_sync_ready_scope lock poisoned: {e}"))? + .as_ref() + .is_some_and(|ready| *ready == scope.db_path); + + if !now_ready { + tracing::info!( + target: "buzz::config_sync", + db_path = %scope.db_path.display(), + "flush skipped: boot barrier has not completed for this scope" + ); + return Ok(0); + } + } + flush_pending_events_at(&scope.db_path, state, &scope.relay_url, &scope.owner_keys).await } @@ -288,6 +334,13 @@ async fn flush_pending_events_at( if current.created_at != row.created_at || current.content != row.content { continue; // superseded by a newer edit; that row publishes itself } + // Re-read publish_blocked: the boot barrier may have set it between + // the pending snapshot above and this point (race between barrier and + // flush snapshot). A row that was unblocked when snapshotted but gated + // before submit must not publish. + if current.publish_blocked { + continue; // gated after snapshot; barrier will own this row + } let event = nostr::Event::from_json(¤t.raw_event) .map_err(|e| format!("failed to parse retained event '{}': {e}", current.d_tag))?; diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 8d99bac2cc..ebd288759c 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -830,6 +830,7 @@ mod flush_barrier { raw_event: event.as_json(), event_id: None, pending_sync: true, + publish_blocked: false, }, ) .expect("retain test event"); @@ -937,4 +938,186 @@ mod flush_barrier { "unrelated row marked synced" ); } + + /// (a) A row gated after its snapshot was taken cannot submit. + /// + /// Scenario: the boot barrier runs between `get_pending_sync` (which + /// snapshots rows) and the per-row `submit_signed_event_at_with_keys` + /// call. The pre-submit re-read checks `publish_blocked` on the + /// freshest DB copy — a row blocked after snapshot must not publish. + #[tokio::test] + async fn test_row_gated_after_snapshot_cannot_submit() { + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("retention.db"); + + // Retain a pending row that the stub relay would happily accept. + { + let conn = open_retention_db(&db_path).expect("open db"); + retain_signed( + &conn, + &keys, + KIND_PERSONA, + "my-agent", + EventBuilder::new( + Kind::Custom(KIND_PERSONA as u16), + "{\"display_name\":\"X\"}", + ) + .tags(vec![Tag::parse(["d", "my-agent"]).unwrap()]), + 1000, + ); + } + + // Simulate the barrier setting publish_blocked AFTER the pending + // snapshot would have included this row. + { + use crate::managed_agents::retention::set_publish_blocked; + let conn = open_retention_db(&db_path).expect("open db"); + set_publish_blocked(&conn, KIND_PERSONA, &pubkey, "my-agent", true).expect("gate row"); + } + + let state = build_app_state(); + *state.keys.lock().unwrap() = keys; + *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); + + // flush_pending_events uses get_pending_sync (SQL gate: publish_blocked=0) + // AND re-reads publish_blocked immediately before submit. Both checks + // must suppress this row. + let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); + assert_eq!(flushed, 0, "gated row must not publish"); + + // Row must remain pending (not cleared) so a future resolution can act. + let conn = open_retention_db(&db_path).expect("reopen db"); + let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "my-agent") + .unwrap() + .unwrap(); + assert!( + row.pending_sync, + "gated row stays pending for future resolution" + ); + assert!(row.publish_blocked, "publish_blocked flag persists"); + } + + /// (b) Barrier error after reconcile leaves scope closed: `config_sync_ready_scope` + /// stays `None` and the readiness latch prevents any flush from proceeding. + /// + /// This tests the latch's fail-closed polarity: any error path in the + /// barrier must leave `config_sync_ready_scope` as `None`, so the flush + /// loop's readiness check correctly skips the scope on the next tick + /// instead of opening the gate without enforcement. + #[test] + fn test_barrier_error_leaves_scope_unready() { + use crate::app_state::build_app_state; + let state = build_app_state(); + let db_path = std::path::PathBuf::from("/test/scope/retention.db"); + + // Initially unset — no scope is ready. + { + let guard = state.config_sync_ready_scope.lock().unwrap(); + assert!(guard.is_none(), "scope starts unready"); + } + + // Simulate: barrier ran but failed (error path) — latch stays None. + // (In production this happens inside run_boot_barrier when + // run_boot_barrier_for_scope returns Err.) + { + let guard = state.config_sync_ready_scope.lock().unwrap(); + assert!( + guard.as_ref().map_or(true, |p| *p != db_path), + "scope is not ready after a barrier failure — flush must skip" + ); + } + + // Simulate: barrier completed successfully — latch is set. + { + let mut guard = state.config_sync_ready_scope.lock().unwrap(); + *guard = Some(db_path.clone()); + } + { + let guard = state.config_sync_ready_scope.lock().unwrap(); + assert_eq!( + guard.as_deref(), + Some(db_path.as_path()), + "scope is ready after barrier success" + ); + } + + // Simulate: workspace switches — latch is cleared before new sync. + { + let mut guard = state.config_sync_ready_scope.lock().unwrap(); + *guard = None; + } + { + let guard = state.config_sync_ready_scope.lock().unwrap(); + assert!( + guard.is_none(), + "scope is unready again after workspace switch" + ); + } + } + + /// (c) Retry path: after a barrier failure the flush skips; on a later + /// tick the barrier succeeds and the row publishes. + /// + /// Tests the `is_some_and(|ready| *ready == scope.db_path)` readiness + /// check by verifying that a row withheld while `config_sync_ready_scope` + /// is `None` publishes after the latch is set to the matching db_path. + #[tokio::test] + async fn test_retry_path_publishes_after_barrier_succeeds() { + use crate::app_state::build_app_state; + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("retention.db"); + + { + let conn = open_retention_db(&db_path).expect("open db"); + retain_signed( + &conn, + &keys, + KIND_PERSONA, + "my-agent", + EventBuilder::new( + Kind::Custom(KIND_PERSONA as u16), + "{\"display_name\":\"Y\"}", + ) + .tags(vec![Tag::parse(["d", "my-agent"]).unwrap()]), + 1000, + ); + } + + let state = build_app_state(); + *state.keys.lock().unwrap() = keys.clone(); + let relay_url = spawn_stub_relay().await; + *state.relay_url_override.lock().unwrap() = Some(relay_url.clone()); + + // Scope is NOT yet ready: flush must skip (0 flushed). + // We test this via the underlying flush fn (bypass scope check) but + // verify the latch semantics: scope not in ready set → should skip. + // Direct unit-test: latch is None, check is_some_and returns false. + let ready_before = state + .config_sync_ready_scope + .lock() + .unwrap() + .as_ref() + .is_some_and(|p| *p == db_path); + assert!(!ready_before, "scope not ready before barrier"); + + // Now simulate a successful barrier: set the latch. + { + let mut guard = state.config_sync_ready_scope.lock().unwrap(); + *guard = Some(db_path.clone()); + } + + // Scope is now ready: flush proceeds and publishes the row. + let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); + assert_eq!(flushed, 1, "row publishes once scope is ready"); + + let conn = open_retention_db(&db_path).expect("reopen db"); + let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "my-agent") + .unwrap() + .unwrap(); + assert!(!row.pending_sync, "row marked synced after publish"); + } } diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index f2f37447d2..2e7ec5f704 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -257,6 +257,7 @@ fn slimming_republish_wave_is_one_time() { raw_event: String::new(), event_id: None, pending_sync: false, + publish_blocked: false, }, ) .unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 0c24cac6f4..88bf819f9d 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -117,6 +117,14 @@ pub struct RetainedEvent { pub created_at: i64, pub raw_event: String, pub pending_sync: bool, + /// Whether the boot barrier has withheld this row from publication. + /// + /// Set by [`set_publish_blocked`] when the decision pass determines the + /// row must not go out (no-baseline park, head present but unverifiable, + /// or conflict parked for the user). The flush loop re-reads this field + /// immediately before submit — a row gated after its snapshot was taken + /// is suppressed here, not at the SQL snapshot boundary alone. + pub publish_blocked: bool, /// NIP-01 id of this row's own event, the second half of the ordering key. /// /// NIP-33 resolves an equal-`created_at` collision by lowest event id, so a @@ -179,6 +187,9 @@ impl RetainedEvent { raw_event: event.as_json(), event_id: Some(event.id.to_hex()), pending_sync, + // Newly constructed rows start unblocked; the boot barrier sets + // this via `set_publish_blocked` when it decides suppression. + publish_blocked: false, } } } @@ -322,7 +333,8 @@ pub enum InboundOutcome { /// The coordinate carries a pending local edit, so the inbound event is /// not resolved here at any timestamp. The retained row, its content, and /// its `pending_sync` flag are left exactly as they were, for the boot - /// decision pass to arbitrate against a writer-consistent head. + /// decision pass to arbitrate against an exact, best-available + /// (replica-lag-exposed) relay head. Deferred, } @@ -363,7 +375,8 @@ pub enum InboundOutcome { /// /// Deferring instead is not the final answer, only a safe one: which side wins /// is decided by the boot decision pass, which arbitrates the pending row -/// against a writer-consistent head (and its paired tombstone) rather than +/// against an exact, best-available (replica-lag-exposed) relay head (and its +/// paired tombstone) rather than /// against whichever event happened to arrive first. Until that pass exists, a /// pending row shadows genuinely newer remote edits for its coordinate — the /// flush normally clears it within seconds, and preserving an edit that can @@ -447,7 +460,8 @@ pub fn get_retained_personas( ) -> Result, String> { let mut stmt = conn .prepare( - "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id, + publish_blocked FROM persona_events WHERE pubkey = ?1 ORDER BY d_tag", @@ -465,6 +479,7 @@ pub fn get_retained_personas( raw_event: row.get(5)?, pending_sync: row.get::<_, i32>(6)? != 0, event_id: row.get(7)?, + publish_blocked: row.get::<_, i32>(8)? != 0, }) }) .map_err(|e| format!("failed to query retained events: {e}"))?; @@ -495,7 +510,8 @@ pub fn get_retained_personas( pub fn get_pending_sync(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( - "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id, + publish_blocked FROM persona_events WHERE pending_sync = 1 AND publish_blocked = 0 ORDER BY (kind != 5), created_at ASC", @@ -513,6 +529,9 @@ pub fn get_pending_sync(conn: &Connection) -> Result, String> raw_event: row.get(5)?, pending_sync: row.get::<_, i32>(6)? != 0, event_id: row.get(7)?, + // SQL gate ensures this is 0 for every row returned; carry it + // so the flush loop's pre-submit re-read uses the same type. + publish_blocked: row.get::<_, i32>(8)? != 0, }) }) .map_err(|e| format!("failed to query pending sync events: {e}"))?; @@ -771,7 +790,8 @@ pub fn get_retained_event( d_tag: &str, ) -> Result, String> { conn.query_row( - "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync, event_id, + publish_blocked FROM persona_events WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", params![kind, pubkey, d_tag], @@ -785,6 +805,7 @@ pub fn get_retained_event( raw_event: row.get(5)?, pending_sync: row.get::<_, i32>(6)? != 0, event_id: row.get(7)?, + publish_blocked: row.get::<_, i32>(8)? != 0, }) }, ) diff --git a/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs b/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs index abbb228526..f6214894e0 100644 --- a/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs +++ b/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs @@ -143,6 +143,10 @@ fn legacy_rows_for_owner( event_id: super::event_id_from_raw(&raw_event), raw_event, pending_sync: row.get::<_, i32>(6)? != 0, + // Legacy databases may predate the publish_blocked column; + // treat all migrated rows as unblocked — the destination + // scope's boot barrier runs after migration and decides. + publish_blocked: false, }) }) .map_err(|e| format!("failed to query legacy retained events: {e}"))?; diff --git a/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs b/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs index fbfda47928..d28396ec11 100644 --- a/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs +++ b/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs @@ -18,6 +18,7 @@ fn pending_tombstone(d_tag: &str) -> RetainedEvent { raw_event: format!(r#"{{"kind":5,"d":"{d_tag}"}}"#), event_id: None, pending_sync: true, + publish_blocked: false, } } @@ -144,6 +145,7 @@ fn test_post_upgrade_scoped_row_is_not_overwritten_by_its_legacy_ancestor() { raw_event: r#"{"content":"old"}"#.to_string(), event_id: None, pending_sync: true, + publish_blocked: false, }; seed_legacy(dir.path(), &[legacy_head]); let scope = scope_path(dir.path(), "wss://a.example"); @@ -161,6 +163,7 @@ fn test_post_upgrade_scoped_row_is_not_overwritten_by_its_legacy_ancestor() { raw_event: r#"{"content":"new"}"#.to_string(), event_id: None, pending_sync: true, + publish_blocked: false, }, ) .unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/retention/tests.rs b/desktop/src-tauri/src/managed_agents/retention/tests.rs index d420c9e7a7..80c346735f 100644 --- a/desktop/src-tauri/src/managed_agents/retention/tests.rs +++ b/desktop/src-tauri/src/managed_agents/retention/tests.rs @@ -87,6 +87,7 @@ fn sample_event() -> RetainedEvent { raw_event: r#"{"id":"..."}"#.to_string(), event_id: None, pending_sync: true, + publish_blocked: false, } } @@ -121,6 +122,7 @@ fn tombstone_retention_keys_are_distinct_across_kinds() { raw_event: format!("{{\"k\":{target_kind}}}"), event_id: None, pending_sync: true, + publish_blocked: false, }, ) .unwrap(); @@ -328,7 +330,7 @@ fn inbound_equal_second_defers_and_preserves_pending() { ); // Local pending row is untouched: flag preserved, content unchanged, for - // the boot decision pass to arbitrate against a writer-consistent head. + // the boot decision pass to arbitrate against an exact, best-available relay head. let row = get_retained_event(&conn, 30175, "abc123", "test-persona") .unwrap() .unwrap(); @@ -578,7 +580,7 @@ fn test_equal_second_lower_id_inbound_defers_to_pending_row() { // the relay's id tie-break, applying it here would clear `pending_sync` and // silently drop the user's unpublished edit — the exact class of loss this // whole change exists to stop. Intent is arbitrated by the boot decision - // pass against a writer-consistent head, not by an id compare in the cache. + // pass against an exact, best-available relay head, not by an id compare in the cache. let conn = test_db(); let (lower, higher) = lower_and_higher_ids(); retain_event( diff --git a/desktop/src-tauri/src/managed_agents/tombstone_scan.rs b/desktop/src-tauri/src/managed_agents/tombstone_scan.rs index d342ed3e38..5c266689c5 100644 --- a/desktop/src-tauri/src/managed_agents/tombstone_scan.rs +++ b/desktop/src-tauri/src/managed_agents/tombstone_scan.rs @@ -88,8 +88,8 @@ impl TombstonedCoordinates { /// On relay deployments that route historical reads through a read-replica /// pool, a replica that has not yet replayed a tombstone answers "not found" /// in the dangerous direction. The barrier's tracing line surfaces this if -/// it ever fires; re-adding `sync_authoritative` to the protocol is a small -/// standalone change reversible on observed evidence. +/// it ever fires; re-adding a relay-side writer-pinned read opt-in to the +/// protocol is a small standalone change reversible on observed evidence. pub fn scan_page_filter(owner_pubkey_hex: &str, cursor: Option<&PageCursor>) -> serde_json::Value { let mut filter = json!({ "kinds": [buzz_core_pkg::kind::KIND_DELETION], From e582629bda481e1c9fe4f87307a392892ac8ad6a Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 30 Jul 2026 19:35:43 -0400 Subject: [PATCH 5/8] fix(managed-agents): use is_none_or in barrier readiness test Replace map_or(true, ...) with is_none_or(...) per clippy lint unnecessary_map_or (-D warnings gate). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/managed_agents/persona_events/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index ebd288759c..b6d575b6b6 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -1024,7 +1024,7 @@ mod flush_barrier { { let guard = state.config_sync_ready_scope.lock().unwrap(); assert!( - guard.as_ref().map_or(true, |p| *p != db_path), + guard.as_ref().is_none_or(|p| *p != db_path), "scope is not ready after a barrier failure — flush must skip" ); } From 9b1782d179cf430dfe2bb1e7d65f697f8ba1363a Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 30 Jul 2026 19:42:33 -0400 Subject: [PATCH 6/8] docs(config_barrier): note disk_projections ordering is defense-in-depth under latch Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/managed_agents/config_barrier.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/config_barrier.rs b/desktop/src-tauri/src/managed_agents/config_barrier.rs index e623970afb..df057c0f5b 100644 --- a/desktop/src-tauri/src/managed_agents/config_barrier.rs +++ b/desktop/src-tauri/src/managed_agents/config_barrier.rs @@ -105,6 +105,13 @@ pub async fn run_boot_barrier(app: &tauri::AppHandle) { /// the first round trip, and the decision pass re-opens it for the coordinates /// that earn it. A row is either provably publishable or it is not going out. /// +/// Note: `disk_projections` runs before this call inside +/// `run_boot_barrier_for_scope`. Under the readiness latch that ordering is +/// defense-in-depth — the flush loop cannot snapshot any scope that has not +/// yet passed its barrier, so quarantine ordering no longer determines whether +/// a pre-quarantine row can escape. The synchronous-before-network property is +/// retained because it is cheap and keeps the invariant obvious. +/// /// Scoped to rows with no baseline because those are precisely the unprovable /// ones: a legacy row queued before the baseline column existed, and a row /// queued by a second store whose retention database has never agreed to From ce36d6e53e719baafb634ea177d4a77234c88fba Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 30 Jul 2026 20:36:42 -0400 Subject: [PATCH 7/8] fix(managed-agents): close CAS race between reconcile and inline flush barrier spawn_event_sync previously called mark_unready() between the reconcile and its trailing run_boot_barrier call, opening a window where the flush loop could win the claim_in_progress CAS and certify readiness against the pre-reconcile database state. A stale row retained by reconcile would then publish before the post-reconcile barrier could gate it. Fix: hold InProgress continuously through the entire reconcile+barrier window. Add run_boot_barrier_after_claim (skips the CAS) called by spawn_event_sync; run_boot_barrier (with CAS) remains the flush-loop entry. The process-global static in config_sync_readiness.rs makes the latch app-state-free, keeping app_state.rs within its size gate. Also split flush-barrier tests into a dedicated file and rewrite them to exercise production transition functions via module-level free functions, with thread_local isolation for test parallelism. Each test now exercises a named production invariant: (a) snapshot-vs-gate race, (b) InProgress rejects second claim, (c) barrier error resets to Unready, (d) Thufir's deterministic interleaving proof, (e) retry publishes. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/app_state.rs | 14 - desktop/src-tauri/src/commands/workspace.rs | 4 +- desktop/src-tauri/src/egress_guard_tests.rs | 5 + desktop/src-tauri/src/event_sync.rs | 41 ++- .../src/managed_agents/config_barrier.rs | 80 +++-- .../managed_agents/config_sync_readiness.rs | 191 ++++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/persona_events.rs | 111 ++++-- .../persona_events/flush_barrier_tests.rs | 336 ++++++++++++++++++ .../managed_agents/persona_events/tests.rs | 182 ---------- 10 files changed, 694 insertions(+), 271 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/config_sync_readiness.rs create mode 100644 desktop/src-tauri/src/managed_agents/persona_events/flush_barrier_tests.rs diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 6f8479c3bb..94d162e620 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -48,19 +48,6 @@ pub struct AppState { pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, - /// Per-scope config-sync readiness latch. - /// - /// Holds the `db_path` of the retention scope whose boot barrier has - /// completed successfully. `None` means no scope is ready. The flush loop - /// checks this before publishing: if the active scope's `db_path` does not - /// match, it runs the barrier inline (retry path) before attempting any - /// publishes. `apply_workspace` clears this to `None` before spawning a - /// new sync, so the incoming scope is unready until its own barrier passes. - /// - /// Invariant: once set to `Some(path)`, the barrier has enforced the - /// publication gate for every coordinate in that scope. Any error path in - /// the barrier leaves this as `None`, keeping the scope fail-closed. - pub config_sync_ready_scope: Mutex>, /// Tauri app handle — stored after setup so huddle commands can emit /// `huddle-state-changed` events without needing the handle threaded /// through every call site. @@ -226,7 +213,6 @@ pub fn build_app_state() -> AppState { managed_agent_processes: Mutex::new(HashMap::new()), session_config_cache: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), - config_sync_ready_scope: Mutex::new(None), app_handle: Mutex::new(None), audio_output_device: Mutex::new(None), media_proxy_port: AtomicU16::new(0), diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 24c4d10b79..237d4082de 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -221,9 +221,7 @@ pub async fn apply_workspace( // unready until its own boot barrier completes. Without this, // a prior scope's latch could let the new scope's flush loop // publish before its gate has run. - if let Ok(mut ready) = state.config_sync_ready_scope.lock() { - *ready = None; - } + crate::managed_agents::config_sync_readiness::mark_unready(); // Adopt whatever the pre-scoping release left queued in the global // retention database BEFORE the scoped reconcile and flush run, so // stranded tombstones and archive requests publish on this boot diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index f487c8ce16..704558f8a1 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -250,6 +250,11 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ ("src/relay_admission.rs", 1, 0), ("src/archive/mod_tests.rs", 1, 0), ("src/managed_agents/persona_events/tests.rs", 1, 0), + ( + "src/managed_agents/persona_events/flush_barrier_tests.rs", + 1, + 0, + ), ("src/commands/team_snapshot/tests.rs", 1, 0), // Mock-relay route in its in-file tests; production publish goes through // the guarded boundary-1 funnel (`submit_signed_event_at_with_keys`). diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index 0b3aeff0d1..87db42fe4e 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -26,21 +26,38 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys, db_path: /// SQLite, and signing work, so it runs on the blocking pool rather than an /// async worker. /// -/// # Ordering: reconcile, then barrier, enforced by the flush readiness latch +/// # Ordering: reconcile, then barrier, enforced by the single-flight latch /// /// The reconcile marks changed coordinates `pending_sync = 1`; the boot barrier -/// ([`crate::managed_agents::config_barrier::run_boot_barrier`]) then decides -/// which of those may actually go out and gates the rest. The flush loop -/// enforces this ordering structurally: before publishing any row it checks the -/// per-scope readiness latch in `AppState::config_sync_ready_scope`; if unset, -/// it runs the barrier inline and only proceeds on success. This guarantees that -/// nothing publishes for a scope until the barrier has completed successfully — -/// regardless of when the flush loop's first tick fires. +/// ([`crate::managed_agents::config_barrier::run_boot_barrier_after_claim`]) +/// then decides which may go out and gates the rest. Ordering is enforced +/// structurally: +/// +/// 1. This function claims `InProgress` on the process-global readiness latch +/// BEFORE the reconcile task starts, preventing any concurrent flush tick +/// from running its own barrier against the pre-reconcile database state. +/// +/// 2. The blocking task runs reconcile; `run_boot_barrier_after_claim` then +/// completes the transition to `Ready(db_path)` (or back to `Unready` on +/// error). InProgress is held continuously — there is no reset between +/// reconcile and barrier, so no concurrent flush tick can win the CAS and +/// certify readiness against a pre-reconcile snapshot. +/// +/// 3. The flush loop sees `InProgress` during the entire reconcile+barrier +/// window and skips its tick rather than racing to certify readiness early. pub fn spawn_event_sync( app: tauri::AppHandle, owner_keys: nostr::Keys, db_path: std::path::PathBuf, ) { + // Claim the single-flight slot synchronously before spawning. The flush + // loop sees InProgress immediately and skips until reconcile+barrier + // completes — no concurrent inline barrier can beat the reconcile. + if !crate::managed_agents::config_sync_readiness::claim_in_progress() { + // Already InProgress or Ready: another path owns this cycle. + return; + } + tauri::async_runtime::spawn(async move { let barrier_app = app.clone(); if let Err(e) = tauri::async_runtime::spawn_blocking(move || { @@ -49,11 +66,11 @@ pub fn spawn_event_sync( .await { eprintln!("buzz-desktop: event-sync: spawn_blocking failed: {e}"); - // The barrier still runs: a failed reconcile leaves whatever rows - // a previous boot queued, and gating those is exactly the case the - // barrier exists for. } - crate::managed_agents::config_barrier::run_boot_barrier(&barrier_app).await; + // InProgress is still held here — no mark_unready between reconcile + // and the barrier. run_boot_barrier_after_claim owns the transition + // to Ready or Unready without re-claiming. + crate::managed_agents::config_barrier::run_boot_barrier_after_claim(&barrier_app).await; }); } diff --git a/desktop/src-tauri/src/managed_agents/config_barrier.rs b/desktop/src-tauri/src/managed_agents/config_barrier.rs index df057c0f5b..c3e5559f5e 100644 --- a/desktop/src-tauri/src/managed_agents/config_barrier.rs +++ b/desktop/src-tauri/src/managed_agents/config_barrier.rs @@ -47,48 +47,76 @@ use buzz_core_pkg::kind::{KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; /// Run the boot barrier for the active scope. /// -/// On success, marks the scope's `db_path` as ready in `AppState`, allowing -/// the flush loop to publish for that scope. On any error, leaves the ready -/// latch unset so the scope stays fail-closed until a later retry succeeds. +/// Attempts to claim the `Unready → InProgress` transition. If another +/// caller already owns it (state is `InProgress` or `Ready`), this returns +/// immediately — the in-flight or completed barrier is sufficient. /// -/// This is called from the flush loop when it finds the scope not ready — -/// so a transient failure (relay unreachable) retries on the next flush tick -/// rather than wedging publishing for the session. +/// On success, marks the scope `Ready`, allowing the flush loop to publish +/// for that scope. On any error, resets to `Unready` so the scope stays +/// fail-closed until a later retry succeeds. +/// +/// Called from the flush loop (retry path, only when `Unready`). The +/// `claim_in_progress` CAS ensures at most one barrier runs at a time. +/// `spawn_event_sync` calls [`run_boot_barrier_after_claim`] instead, which +/// assumes the caller already holds `InProgress` and skips the CAS. pub async fn run_boot_barrier(app: &tauri::AppHandle) { + use super::config_sync_readiness; + + // Single-flight: only the first caller to claim InProgress runs the + // barrier. If the state is already InProgress (spawn_event_sync's + // reconcile is in-flight) or Ready, skip. + if !config_sync_readiness::claim_in_progress() { + return; + } + + run_boot_barrier_enforcing(app).await; +} + +/// Execute the barrier enforcement assuming the caller already claimed +/// `InProgress`. +/// +/// Called from `spawn_event_sync` after reconcile completes. The caller holds +/// `InProgress` through the entire reconcile+barrier window, preventing any +/// concurrent flush tick from certifying readiness against the pre-reconcile +/// database state. This function never calls `claim_in_progress` — ownership +/// is the caller's invariant. +/// +/// On success: transitions to `Ready(db_path)`. On any error: resets to +/// `Unready` (fail-closed, retryable on the next flush tick). +pub(crate) async fn run_boot_barrier_after_claim(app: &tauri::AppHandle) { + run_boot_barrier_enforcing(app).await; +} + +/// Shared enforcement body: scope resolution, relay lookups, decision pass. +/// +/// Caller must own `InProgress` (either via `claim_in_progress` in +/// `run_boot_barrier`, or via the pre-spawn claim in `spawn_event_sync` passed +/// through `run_boot_barrier_after_claim`). +async fn run_boot_barrier_enforcing(app: &tauri::AppHandle) { + use super::config_sync_readiness; + let state = app.state::(); let scope = match active_retention_scope(app, &state) { Ok(scope) => scope, Err(error) => { tracing::warn!(target: "buzz::config_sync", %error, "boot barrier skipped: no active scope"); + config_sync_readiness::mark_unready(); return; } }; match run_boot_barrier_for_scope(app, &state, &scope).await { Ok(()) => { - // Mark the scope ready. Any error in the scope check or lock is - // treated as a barrier failure — leave unready rather than risk - // opening the gate without enforcement. - match state.config_sync_ready_scope.lock() { - Ok(mut guard) => { - *guard = Some(scope.db_path.clone()); - tracing::info!( - target: "buzz::config_sync", - db_path = %scope.db_path.display(), - "boot barrier complete; scope is ready for publication" - ); - } - Err(error) => { - tracing::warn!( - target: "buzz::config_sync", - %error, - "boot barrier completed but failed to set ready latch; scope stays closed" - ); - } - } + config_sync_readiness::mark_ready(scope.db_path.clone()); + tracing::info!( + target: "buzz::config_sync", + db_path = %scope.db_path.display(), + "boot barrier complete; scope is ready for publication" + ); } Err(error) => { tracing::warn!(target: "buzz::config_sync", %error, "boot barrier failed; scope stays closed for retry"); + config_sync_readiness::mark_unready(); } } } diff --git a/desktop/src-tauri/src/managed_agents/config_sync_readiness.rs b/desktop/src-tauri/src/managed_agents/config_sync_readiness.rs new file mode 100644 index 0000000000..b2f435ef02 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_sync_readiness.rs @@ -0,0 +1,191 @@ +//! Per-scope readiness latch for the boot config barrier. +//! +//! Tracks the single-flight state of the `Unready → InProgress → Ready` +//! transition for the active retention scope, preventing a concurrent inline +//! flush barrier from certifying readiness before the reconcile that follows +//! `spawn_event_sync` has finished queuing its rows. +//! +//! # State machine +//! +//! ```text +//! apply_workspace ─────────────────────────────────► Unready +//! spawn_event_sync (claim) ─────────────────────────► InProgress +//! flush retry (claim, only when Unready) ───────────► InProgress +//! run_boot_barrier success ─────────────────────────► Ready(db_path) +//! run_boot_barrier error / abandon ─────────────────► Unready +//! flush: InProgress → skip tick; Ready(p) → publish +//! ``` +//! +//! Exactly one caller at a time owns the `Unready → InProgress` transition. +//! `claim_in_progress` is a compare-and-swap under the process-global lock: +//! it succeeds only from `Unready` and returns `false` when the state is +//! already `InProgress` or `Ready`. An inline flush retry that fires while +//! `spawn_event_sync`'s reconcile is running will see `InProgress` and skip +//! its tick — it cannot certify readiness against the pre-reconcile database. + +use std::path::{Path, PathBuf}; + +/// The current readiness state for the active scope's publication gate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReadinessState { + /// No barrier has been run for the current scope. + Unready, + /// A reconcile + barrier is in progress. The flush loop must skip this tick. + InProgress, + /// The barrier completed successfully for the named scope. + Ready(PathBuf), +} + +// ── Process-global latch ────────────────────────────────────────────────────── + +#[cfg(not(test))] +static READINESS: std::sync::Mutex = std::sync::Mutex::new(ReadinessState::Unready); + +// In tests, each test thread gets an isolated latch so tests cannot interfere. +#[cfg(test)] +thread_local! { + static READINESS: std::sync::Mutex = + const { std::sync::Mutex::new(ReadinessState::Unready) }; +} + +// ── Public interface ────────────────────────────────────────────────────────── + +/// Attempt a CAS from `Unready` → `InProgress`. +/// +/// Returns `true` if the caller won the transition and now owns the +/// reconcile+barrier run. Returns `false` if the state was already +/// `InProgress` or `Ready` — the caller must not start a barrier. +pub fn claim_in_progress() -> bool { + with_lock(|state| { + if *state == ReadinessState::Unready { + *state = ReadinessState::InProgress; + true + } else { + false + } + }) + .unwrap_or(false) +} + +/// Mark the scope `Ready` for the given `db_path`. +pub fn mark_ready(db_path: PathBuf) { + let _ = with_lock(|state| { + *state = ReadinessState::Ready(db_path); + }); +} + +/// Reset to `Unready`. Called on barrier error, workspace switch, or any +/// path that must leave the scope fail-closed. +pub fn mark_unready() { + let _ = with_lock(|state| { + *state = ReadinessState::Unready; + }); +} + +/// `true` iff the scope is `Ready` for exactly `db_path`. +pub fn is_ready_for(db_path: &Path) -> bool { + with_lock(|state| matches!(&*state, ReadinessState::Ready(p) if p == db_path)).unwrap_or(false) +} + +/// `true` iff a reconcile+barrier is currently in-flight. +#[cfg(test)] +pub fn is_in_progress() -> bool { + with_lock(|state| *state == ReadinessState::InProgress).unwrap_or(false) +} + +/// Read a snapshot of the current state. +pub fn readiness_state() -> Option { + with_lock(|state| state.clone()).ok() +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +#[cfg(not(test))] +fn with_lock(f: impl FnOnce(&mut ReadinessState) -> R) -> Result { + READINESS + .lock() + .map(|mut g| f(&mut g)) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +fn with_lock(f: impl FnOnce(&mut ReadinessState) -> R) -> Result { + READINESS.with(|m| m.lock().map(|mut g| f(&mut g)).map_err(|e| e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_initial_state_is_unready() { + mark_unready(); // reset for test isolation + assert!(!is_in_progress()); + assert_eq!(readiness_state(), Some(ReadinessState::Unready)); + } + + #[test] + fn test_claim_in_progress_from_unready_succeeds() { + mark_unready(); + assert!(claim_in_progress(), "first claim must succeed"); + assert!(is_in_progress()); + mark_unready(); // cleanup + } + + #[test] + fn test_claim_in_progress_is_single_flight() { + mark_unready(); + assert!(claim_in_progress(), "first claim wins"); + assert!( + !claim_in_progress(), + "second claim rejected while InProgress" + ); + mark_unready(); // cleanup + } + + #[test] + fn test_claim_rejected_when_ready() { + mark_unready(); + let path = PathBuf::from("/scope/db"); + mark_ready(path.clone()); + assert!(!claim_in_progress(), "cannot claim when Ready"); + assert!(is_ready_for(&path)); + mark_unready(); // cleanup + } + + #[test] + fn test_mark_ready_then_is_ready_for() { + mark_unready(); + let path = PathBuf::from("/scope/db"); + claim_in_progress(); + mark_ready(path.clone()); + assert!(is_ready_for(&path)); + assert!(!is_ready_for(Path::new("/other/db"))); + mark_unready(); // cleanup + } + + #[test] + fn test_mark_unready_resets_to_claimable() { + mark_unready(); + claim_in_progress(); + mark_unready(); + assert_eq!(readiness_state(), Some(ReadinessState::Unready)); + assert!(claim_in_progress(), "after reset, claim must succeed"); + mark_unready(); // cleanup + } + + #[test] + fn test_workspace_switch_cycle() { + mark_unready(); + let path = PathBuf::from("/scope/db"); + claim_in_progress(); + mark_ready(path.clone()); + assert!(is_ready_for(&path)); + mark_unready(); + assert!(!is_ready_for(&path)); + assert!(claim_in_progress()); + mark_ready(path.clone()); + assert!(is_ready_for(&path)); + mark_unready(); // cleanup + } +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 0aee72eb70..66d29944b4 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod agent_snapshot; pub(crate) mod canonical_projection; pub(crate) mod config_barrier; pub(crate) mod config_sync; +pub(crate) mod config_sync_readiness; pub(crate) mod decision; pub(crate) mod head_lookup; pub(crate) mod team_snapshot; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 4942940129..c0716f83a8 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -242,52 +242,51 @@ pub async fn flush_pending_events( /// /// # Readiness gate /// -/// Before taking a publish snapshot, this function checks whether the active -/// scope's boot barrier has completed successfully. If not (first run or after -/// a workspace switch), it runs the barrier inline before flushing. A barrier -/// failure leaves the scope unready and skips the flush for this tick — the -/// next tick retries, so a transient relay-unreachable at boot does not wedge -/// publishing for the session. +/// Before taking a publish snapshot, this function checks the scope's +/// single-flight readiness state: +/// +/// - `Ready(path)` matching the active scope → flush proceeds. +/// - `InProgress` → a reconcile+barrier is in-flight; skip this tick. +/// - `Unready` → no barrier has claimed the slot; run the barrier inline +/// (which will claim `InProgress`, run, and set `Ready` or `Unready`). +/// A barrier failure skips this tick; the next tick retries. pub async fn flush_active_pending_events( app: &tauri::AppHandle, state: &AppState, ) -> Result { + use crate::managed_agents::config_sync_readiness::{self, ReadinessState}; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - // Check readiness before snapshotting pending rows. The check and the - // snapshot are separate DB reads — a gate set between the two is caught - // by the per-row publish_blocked re-read immediately before submit. - let scope_is_ready = state - .config_sync_ready_scope - .lock() - .map_err(|e| format!("config_sync_ready_scope lock poisoned: {e}"))? - .as_ref() - .is_some_and(|ready| *ready == scope.db_path); - - if !scope_is_ready { - // Barrier not yet run (first boot tick) or a previous barrier failed - // (relay unreachable). Run it now; on success the latch is set and the - // flush proceeds. On failure the scope stays unready and we skip this - // tick — next tick retries the barrier rather than wedging forever. - crate::managed_agents::config_barrier::run_boot_barrier(app).await; - - // Re-check readiness after the barrier attempt. If it failed, skip - // this flush tick; the barrier will retry on the next one. - let now_ready = state - .config_sync_ready_scope - .lock() - .map_err(|e| format!("config_sync_ready_scope lock poisoned: {e}"))? - .as_ref() - .is_some_and(|ready| *ready == scope.db_path); - - if !now_ready { + match config_sync_readiness::readiness_state() { + Some(ReadinessState::Ready(ref path)) if *path == scope.db_path => { + // Scope is ready — fall through to flush. + } + Some(ReadinessState::InProgress) => { + // Reconcile+barrier is in-flight. Skip this tick; the owning + // task will transition to Ready (or Unready on error). tracing::info!( target: "buzz::config_sync", db_path = %scope.db_path.display(), - "flush skipped: boot barrier has not completed for this scope" + "flush skipped: reconcile+barrier in progress for this scope" ); return Ok(0); } + _ => { + // Unready (or None if lock poisoned). Run the barrier inline; + // claim_in_progress inside run_boot_barrier is the CAS guard. + crate::managed_agents::config_barrier::run_boot_barrier(app).await; + + // Re-check after the barrier attempt. + if !config_sync_readiness::is_ready_for(&scope.db_path) { + tracing::info!( + target: "buzz::config_sync", + db_path = %scope.db_path.display(), + "flush skipped: boot barrier has not completed for this scope" + ); + return Ok(0); + } + } } flush_pending_events_at(&scope.db_path, state, &scope.relay_url, &scope.owner_keys).await @@ -312,6 +311,14 @@ async fn flush_pending_events_at( get_pending_sync(&conn)? }; // connection dropped before any .await + // Test-only hook: called after the pending snapshot is taken but before + // any per-row publish. Tests use this to gate rows between snapshot and + // submit, exercising the pre-submit `publish_blocked` re-read. + #[cfg(test)] + if let Some(hook) = POST_SNAPSHOT_HOOK.with(|h| h.borrow().clone()) { + hook(db_path); + } + let mut flushed = 0u32; let mut failed_tombstones: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); @@ -575,4 +582,40 @@ pub fn preview_prospective_persona_snapshot( preview } #[cfg(test)] +mod flush_barrier_tests; +#[cfg(test)] mod tests; + +#[cfg(test)] +use std::cell::RefCell; + +#[cfg(test)] +type PostSnapshotHookFn = dyn Fn(&std::path::Path) + Send + Sync; + +// Thread-local hook called between snapshot and per-row submit in +// `flush_pending_events_at`. Set by tests to gate rows mid-flight and +// verify the pre-submit `publish_blocked` re-read catches them. +#[cfg(test)] +thread_local! { + static POST_SNAPSHOT_HOOK: RefCell>> = + const { RefCell::new(None) }; +} + +/// Register a post-snapshot hook for the current thread. +/// +/// The hook is called with the DB path after `get_pending_sync` takes its +/// snapshot but before any row's pre-submit re-read runs. +#[cfg(test)] +pub fn set_post_snapshot_hook(f: impl Fn(&std::path::Path) + Send + Sync + 'static) { + POST_SNAPSHOT_HOOK.with(|h| { + *h.borrow_mut() = Some(std::sync::Arc::new(f)); + }); +} + +/// Clear the post-snapshot hook for the current thread. +#[cfg(test)] +pub fn clear_post_snapshot_hook() { + POST_SNAPSHOT_HOOK.with(|h| { + *h.borrow_mut() = None; + }); +} diff --git a/desktop/src-tauri/src/managed_agents/persona_events/flush_barrier_tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/flush_barrier_tests.rs new file mode 100644 index 0000000000..d6bb945bb7 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_events/flush_barrier_tests.rs @@ -0,0 +1,336 @@ +//! Deterministic tests for the publish-gate, readiness-latch, and +//! single-flight invariants of the flush loop. +//! +//! All tests exercise the production transition functions +//! (`flush_pending_events`, `set_post_snapshot_hook`, the module-level +//! readiness functions in `config_sync_readiness`) rather than manually +//! toggling mutex state. +//! +//! Thread isolation: `config_sync_readiness` uses `thread_local!` in test +//! builds so each test thread gets an isolated latch; no cross-test +//! interference. +//! +//! Gated off Windows for the same reason as `flush_barrier` in tests.rs: +//! `build_app_state()` pulls native DLLs unavailable in the Windows CI runner. +#![cfg(not(target_os = "windows"))] + +use nostr::{EventBuilder, JsonUtil, Kind, Tag}; +use tempfile::tempdir; + +use crate::app_state::build_app_state; +use crate::managed_agents::config_sync_readiness::{self, ReadinessState}; +use crate::managed_agents::persona_events::{ + clear_post_snapshot_hook, flush_pending_events, set_post_snapshot_hook, +}; +use crate::managed_agents::retention::{ + get_retained_event, open_retention_db, retain_event, set_publish_blocked, RetainedEvent, +}; +use buzz_core_pkg::kind::KIND_PERSONA; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async fn spawn_stub_relay() -> String { + use axum::{http::StatusCode, routing::post, Router}; + let app = Router::new().route( + "/events", + post(|body: String| async move { + let event: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + if event.get("kind").and_then(serde_json::Value::as_u64) == Some(5) { + return (StatusCode::INTERNAL_SERVER_ERROR, String::new()); + } + ( + StatusCode::OK, + serde_json::json!({ + "event_id": event.get("id").and_then(serde_json::Value::as_str).unwrap_or(""), + "accepted": true, + "message": "" + }) + .to_string(), + ) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stub relay"); + let addr = listener.local_addr().expect("stub relay addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + format!("http://{addr}") +} + +fn retain_pending(conn: &rusqlite::Connection, keys: &nostr::Keys, d_tag: &str) { + let builder = EventBuilder::new( + Kind::Custom(KIND_PERSONA as u16), + format!("{{\"display_name\":\"{d_tag}\"}}"), + ) + .tags(vec![Tag::parse(["d", d_tag]).unwrap()]); + let event = builder.sign_with_keys(keys).expect("sign"); + retain_event( + conn, + &RetainedEvent { + kind: KIND_PERSONA, + pubkey: keys.public_key().to_hex(), + d_tag: d_tag.to_string(), + content: event.content.to_string(), + created_at: 1_000_000, + raw_event: event.as_json(), + event_id: None, + pending_sync: true, + publish_blocked: false, + }, + ) + .expect("retain"); +} + +// ── (a) snapshot-vs-gate race: row gated AFTER snapshot cannot submit ───────── +// +// The boot barrier may close the gate between `get_pending_sync` (snapshot) +// and the per-row `submit_signed_event_at_with_keys` call. The pre-submit +// re-read of `publish_blocked` must catch rows gated in this window. +// +// Setup: +// 1. Insert an unblocked pending row (passes the SQL gate at snapshot time). +// 2. Install a post-snapshot hook that sets `publish_blocked = 1` on the row. +// 3. Run `flush_pending_events`. +// Expected: 0 published (pre-submit re-read blocked the row); row stays pending. +#[tokio::test] +async fn test_row_gated_between_snapshot_and_submit_cannot_publish() { + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let dir = tempdir().expect("tempdir"); + let db_path = dir.path().join("retention.db"); + + { + let conn = open_retention_db(&db_path).expect("open db"); + retain_pending(&conn, &keys, "agent-a"); + } + + // Install the hook: after snapshot is taken, gate the row — simulating + // the barrier closing the gate between snapshot and per-row submit. + let db_path_for_hook = db_path.clone(); + let pubkey_for_hook = pubkey.clone(); + set_post_snapshot_hook(move |path| { + assert_eq!(path, db_path_for_hook.as_path()); + let conn = open_retention_db(path).expect("open db in hook"); + set_publish_blocked(&conn, KIND_PERSONA, &pubkey_for_hook, "agent-a", true) + .expect("gate row in hook"); + }); + + let state = build_app_state(); + *state.keys.lock().unwrap() = keys.clone(); + *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); + + let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); + clear_post_snapshot_hook(); + + assert_eq!(flushed, 0, "row gated after snapshot must not publish"); + + let conn = open_retention_db(&db_path).expect("reopen db"); + let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "agent-a") + .unwrap() + .unwrap(); + assert!(row.pending_sync, "gated row must stay pending"); + assert!(row.publish_blocked, "publish_blocked flag must persist"); +} + +// ── (b) InProgress blocks a second claim ───────────────────────────────────── +// +// When a reconcile+barrier is in-flight (`InProgress`), a second +// `claim_in_progress` call must be rejected. This is the CAS that prevents +// the flush retry from certifying readiness against a pre-reconcile snapshot. +#[test] +fn test_in_progress_state_blocks_second_claim() { + // Reset to known state first (thread_local ensures isolation). + config_sync_readiness::mark_unready(); + + // First caller (spawn_event_sync) claims InProgress. + assert!( + config_sync_readiness::claim_in_progress(), + "first claim must succeed (Unready → InProgress)" + ); + assert!(config_sync_readiness::is_in_progress()); + + // Second caller (flush retry) must be rejected — it would run a barrier + // against the pre-reconcile database state. + assert!( + !config_sync_readiness::claim_in_progress(), + "second claim must be rejected while InProgress" + ); + + // State is still InProgress — the flush must skip this tick. + assert!(config_sync_readiness::is_in_progress()); + assert_eq!( + config_sync_readiness::readiness_state(), + Some(ReadinessState::InProgress) + ); + + config_sync_readiness::mark_unready(); // cleanup +} + +// ── (c) barrier failure resets latch to Unready for retry ──────────────────── +// +// A barrier that encounters an error must leave the scope `Unready` so the +// next flush tick can retry via `claim_in_progress`. It must NOT leave the +// latch `InProgress` (wedged forever) or `Ready` (open gate without enforcement). +#[test] +fn test_barrier_failure_resets_to_unready_for_retry() { + config_sync_readiness::mark_unready(); + + // spawn_event_sync claims InProgress before reconcile. + assert!(config_sync_readiness::claim_in_progress()); + + // Barrier error: run_boot_barrier_enforcing calls mark_unready on Err. + config_sync_readiness::mark_unready(); + + assert_eq!( + config_sync_readiness::readiness_state(), + Some(ReadinessState::Unready), + "barrier error must reset to Unready — not InProgress (wedged) or Ready (open gate)" + ); + + // Next flush tick must be able to claim for retry. + assert!( + config_sync_readiness::claim_in_progress(), + "after failure, next claim must succeed so the retry can run" + ); + + config_sync_readiness::mark_unready(); // cleanup +} + +// ── (d) Thufir's interleaving test: concurrent inline barrier cannot beat reconcile ── +// +// Scenario (reproduced deterministically using the process-global latch): +// +// 1. `spawn_event_sync` claims `InProgress` before reconcile starts. +// 2. A flush tick fires and calls `claim_in_progress` (simulating +// run_boot_barrier's CAS entry). It is rejected — InProgress is taken. +// 3. Reconcile runs and retains a new row (publish_blocked=false). +// 4. `run_boot_barrier_after_claim` runs the barrier: gates the stale row, +// then marks Ready. +// 5. Next flush tick sees Ready; but the row is blocked — 0 publish. +// +// This proves that holding InProgress through reconcile+barrier prevents an +// inline barrier from certifying readiness against the pre-reconcile snapshot. +#[tokio::test] +async fn test_inline_barrier_cannot_certify_readiness_before_reconcile() { + config_sync_readiness::mark_unready(); + + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let dir = tempdir().expect("tempdir"); + let db_path = dir.path().join("retention.db"); + + // Step 1: spawn_event_sync claims InProgress before reconcile starts. + assert!( + config_sync_readiness::claim_in_progress(), + "spawn_event_sync must win the InProgress claim" + ); + + // Step 2: flush tick fires and tries to claim — must be rejected. + assert!( + !config_sync_readiness::claim_in_progress(), + "inline flush barrier must be rejected — InProgress already claimed" + ); + assert!( + config_sync_readiness::is_in_progress(), + "latch must still be InProgress" + ); + + // Step 3: reconcile runs and retains a stale row (the race case). + { + let conn = open_retention_db(&db_path).expect("open db"); + retain_pending(&conn, &keys, "stale-agent"); + } + + // Step 4: run_boot_barrier_after_claim runs post-reconcile. It owns + // InProgress (no re-claim). The barrier's decision: no-baseline row → + // gate it. We simulate the barrier's enforcement here. + { + let conn = open_retention_db(&db_path).expect("open db for barrier"); + set_publish_blocked(&conn, KIND_PERSONA, &pubkey, "stale-agent", true) + .expect("barrier gates stale row"); + } + config_sync_readiness::mark_ready(db_path.clone()); + + // Step 5: scope is Ready; flush runs but the row is blocked — 0 publish. + assert!( + config_sync_readiness::is_ready_for(&db_path), + "scope must be Ready after barrier" + ); + + let state = build_app_state(); + *state.keys.lock().unwrap() = keys.clone(); + *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); + + let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); + assert_eq!( + flushed, 0, + "stale row gated by post-reconcile barrier must not publish even when scope is Ready" + ); + + let conn = open_retention_db(&db_path).expect("reopen db"); + let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "stale-agent") + .unwrap() + .unwrap(); + assert!(row.pending_sync, "gated row stays pending"); + assert!(row.publish_blocked, "barrier's gate persists"); + + config_sync_readiness::mark_unready(); // cleanup +} + +// ── (e) retry path: after failure, publish succeeds when scope becomes Ready ── +// +// End-to-end: spawn_event_sync claims InProgress → barrier fails (mark_unready) +// → next tick re-claims InProgress → barrier succeeds (mark_ready) → row +// publishes on the following flush call. +#[tokio::test] +async fn test_retry_after_barrier_failure_publishes_when_ready() { + config_sync_readiness::mark_unready(); + + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let dir = tempdir().expect("tempdir"); + let db_path = dir.path().join("retention.db"); + + { + let conn = open_retention_db(&db_path).expect("open db"); + retain_pending(&conn, &keys, "my-agent"); + } + + let state = build_app_state(); + *state.keys.lock().unwrap() = keys.clone(); + let relay_url = spawn_stub_relay().await; + *state.relay_url_override.lock().unwrap() = Some(relay_url); + + // Phase 1: spawn_event_sync claims InProgress → barrier fails → Unready. + assert!(config_sync_readiness::claim_in_progress()); + // Simulate barrier error: run_boot_barrier_after_claim calls mark_unready. + config_sync_readiness::mark_unready(); + + // Latch is Unready — retry is possible. + assert_eq!( + config_sync_readiness::readiness_state(), + Some(ReadinessState::Unready), + "after failure, scope must be Unready for retry" + ); + + // Phase 2: next flush tick re-claims InProgress, barrier succeeds → Ready. + assert!( + config_sync_readiness::claim_in_progress(), + "after mark_unready, claim must succeed for retry" + ); + config_sync_readiness::mark_ready(db_path.clone()); + + // Row is unblocked — flush must publish it now that scope is Ready. + let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); + assert_eq!(flushed, 1, "unblocked row must publish when scope is Ready"); + + let conn = open_retention_db(&db_path).expect("reopen db"); + let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "my-agent") + .unwrap() + .unwrap(); + assert!(!row.pending_sync, "row must be marked synced after publish"); + + config_sync_readiness::mark_unready(); // cleanup +} diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index b6d575b6b6..ce83daaea3 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -938,186 +938,4 @@ mod flush_barrier { "unrelated row marked synced" ); } - - /// (a) A row gated after its snapshot was taken cannot submit. - /// - /// Scenario: the boot barrier runs between `get_pending_sync` (which - /// snapshots rows) and the per-row `submit_signed_event_at_with_keys` - /// call. The pre-submit re-read checks `publish_blocked` on the - /// freshest DB copy — a row blocked after snapshot must not publish. - #[tokio::test] - async fn test_row_gated_after_snapshot_cannot_submit() { - let keys = nostr::Keys::generate(); - let pubkey = keys.public_key().to_hex(); - let dir = tempfile::tempdir().expect("tempdir"); - let db_path = dir.path().join("retention.db"); - - // Retain a pending row that the stub relay would happily accept. - { - let conn = open_retention_db(&db_path).expect("open db"); - retain_signed( - &conn, - &keys, - KIND_PERSONA, - "my-agent", - EventBuilder::new( - Kind::Custom(KIND_PERSONA as u16), - "{\"display_name\":\"X\"}", - ) - .tags(vec![Tag::parse(["d", "my-agent"]).unwrap()]), - 1000, - ); - } - - // Simulate the barrier setting publish_blocked AFTER the pending - // snapshot would have included this row. - { - use crate::managed_agents::retention::set_publish_blocked; - let conn = open_retention_db(&db_path).expect("open db"); - set_publish_blocked(&conn, KIND_PERSONA, &pubkey, "my-agent", true).expect("gate row"); - } - - let state = build_app_state(); - *state.keys.lock().unwrap() = keys; - *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); - - // flush_pending_events uses get_pending_sync (SQL gate: publish_blocked=0) - // AND re-reads publish_blocked immediately before submit. Both checks - // must suppress this row. - let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); - assert_eq!(flushed, 0, "gated row must not publish"); - - // Row must remain pending (not cleared) so a future resolution can act. - let conn = open_retention_db(&db_path).expect("reopen db"); - let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "my-agent") - .unwrap() - .unwrap(); - assert!( - row.pending_sync, - "gated row stays pending for future resolution" - ); - assert!(row.publish_blocked, "publish_blocked flag persists"); - } - - /// (b) Barrier error after reconcile leaves scope closed: `config_sync_ready_scope` - /// stays `None` and the readiness latch prevents any flush from proceeding. - /// - /// This tests the latch's fail-closed polarity: any error path in the - /// barrier must leave `config_sync_ready_scope` as `None`, so the flush - /// loop's readiness check correctly skips the scope on the next tick - /// instead of opening the gate without enforcement. - #[test] - fn test_barrier_error_leaves_scope_unready() { - use crate::app_state::build_app_state; - let state = build_app_state(); - let db_path = std::path::PathBuf::from("/test/scope/retention.db"); - - // Initially unset — no scope is ready. - { - let guard = state.config_sync_ready_scope.lock().unwrap(); - assert!(guard.is_none(), "scope starts unready"); - } - - // Simulate: barrier ran but failed (error path) — latch stays None. - // (In production this happens inside run_boot_barrier when - // run_boot_barrier_for_scope returns Err.) - { - let guard = state.config_sync_ready_scope.lock().unwrap(); - assert!( - guard.as_ref().is_none_or(|p| *p != db_path), - "scope is not ready after a barrier failure — flush must skip" - ); - } - - // Simulate: barrier completed successfully — latch is set. - { - let mut guard = state.config_sync_ready_scope.lock().unwrap(); - *guard = Some(db_path.clone()); - } - { - let guard = state.config_sync_ready_scope.lock().unwrap(); - assert_eq!( - guard.as_deref(), - Some(db_path.as_path()), - "scope is ready after barrier success" - ); - } - - // Simulate: workspace switches — latch is cleared before new sync. - { - let mut guard = state.config_sync_ready_scope.lock().unwrap(); - *guard = None; - } - { - let guard = state.config_sync_ready_scope.lock().unwrap(); - assert!( - guard.is_none(), - "scope is unready again after workspace switch" - ); - } - } - - /// (c) Retry path: after a barrier failure the flush skips; on a later - /// tick the barrier succeeds and the row publishes. - /// - /// Tests the `is_some_and(|ready| *ready == scope.db_path)` readiness - /// check by verifying that a row withheld while `config_sync_ready_scope` - /// is `None` publishes after the latch is set to the matching db_path. - #[tokio::test] - async fn test_retry_path_publishes_after_barrier_succeeds() { - use crate::app_state::build_app_state; - let keys = nostr::Keys::generate(); - let pubkey = keys.public_key().to_hex(); - let dir = tempfile::tempdir().expect("tempdir"); - let db_path = dir.path().join("retention.db"); - - { - let conn = open_retention_db(&db_path).expect("open db"); - retain_signed( - &conn, - &keys, - KIND_PERSONA, - "my-agent", - EventBuilder::new( - Kind::Custom(KIND_PERSONA as u16), - "{\"display_name\":\"Y\"}", - ) - .tags(vec![Tag::parse(["d", "my-agent"]).unwrap()]), - 1000, - ); - } - - let state = build_app_state(); - *state.keys.lock().unwrap() = keys.clone(); - let relay_url = spawn_stub_relay().await; - *state.relay_url_override.lock().unwrap() = Some(relay_url.clone()); - - // Scope is NOT yet ready: flush must skip (0 flushed). - // We test this via the underlying flush fn (bypass scope check) but - // verify the latch semantics: scope not in ready set → should skip. - // Direct unit-test: latch is None, check is_some_and returns false. - let ready_before = state - .config_sync_ready_scope - .lock() - .unwrap() - .as_ref() - .is_some_and(|p| *p == db_path); - assert!(!ready_before, "scope not ready before barrier"); - - // Now simulate a successful barrier: set the latch. - { - let mut guard = state.config_sync_ready_scope.lock().unwrap(); - *guard = Some(db_path.clone()); - } - - // Scope is now ready: flush proceeds and publishes the row. - let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); - assert_eq!(flushed, 1, "row publishes once scope is ready"); - - let conn = open_retention_db(&db_path).expect("reopen db"); - let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "my-agent") - .unwrap() - .unwrap(); - assert!(!row.pending_sync, "row marked synced after publish"); - } } From 441d77112780f2cd0481652c6b9641d4d7d3fd95 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 30 Jul 2026 21:18:19 -0400 Subject: [PATCH 8/8] fix(managed-agents): close apply_workspace Unready window and add RAII panic recovery apply_workspace previously called mark_unready() then relied on spawn_event_sync's inner claim_in_progress() CAS to reclaim InProgress before migration and reconcile ran. The flush loop (alive since process start) could win that CAS in the window between mark_unready() and the spawn, certifying readiness against pre-migration, pre-reconcile DB state. Two consequences: reconcile silently dropped (spawn_event_sync returns early on failed CAS), and legacy migration rows published unarbitrated. Fixes: PRIMARY: apply_workspace now calls force_claim_in_progress() atomically BEFORE migrate_legacy_retention_into. The claim (an RAII ReadinessClaim guard) is held across the entire sequence: legacy migration -> reconcile -> barrier -> mark_ready/Unready. spawn_event_sync_with_held_claim takes the held claim instead of racing its own CAS. flush loop sees InProgress throughout and cannot interleave. SECONDARY: ReadinessClaim RAII guard replaces the manual mark_unready/ mark_ready calls at barrier entry/exit sites. On success, claim.resolve() prevents the drop from resetting to Unready; on error or panic, the drop resets to Unready automatically. A panic inside run_boot_barrier_for_scope no longer leaves the latch permanently wedged at InProgress. API changes: - claim_in_progress() returns Option (was bool) - force_claim_in_progress() added: preempting, any-state -> InProgress - ReadinessClaim: RAII guard with resolve() explicit success path - run_boot_barrier_after_claim() takes ReadinessClaim (transfers ownership) - spawn_event_sync removed (dead code); spawn_event_sync_with_held_claim is now the sole spawn entry point New test (f): apply_workspace window -- force_claim preempts any state; flush claim between invalidation and reconcile is rejected; migrated and reconciled rows gated by post-reconcile barrier cannot publish. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/commands/workspace.rs | 15 +- desktop/src-tauri/src/event_sync.rs | 55 +++--- .../src/managed_agents/config_barrier.rs | 43 +++-- .../managed_agents/config_sync_readiness.rs | 171 +++++++++++++++--- .../persona_events/flush_barrier_tests.rs | 138 ++++++++++++-- 5 files changed, 328 insertions(+), 94 deletions(-) diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 237d4082de..2db2faf406 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -217,20 +217,23 @@ pub async fn apply_workspace( // collapse every community into one pending-event store. match crate::managed_agents::retention::active_retention_scope(&restore_app, &state) { Ok(scope) => { - // Clear the readiness latch before spawning so the new scope is - // unready until its own boot barrier completes. Without this, - // a prior scope's latch could let the new scope's flush loop - // publish before its gate has run. - crate::managed_agents::config_sync_readiness::mark_unready(); + // Preempt the readiness latch from any state (including a prior + // scope's Ready) and hold InProgress across the entire sequence: + // legacy migration → reconcile → barrier. This closes the window + // between the old mark_unready() and spawn_event_sync's inner CAS, + // during which the flush loop could win the claim and certify + // readiness against pre-migration, pre-reconcile database state. + let claim = crate::managed_agents::config_sync_readiness::force_claim_in_progress(); // Adopt whatever the pre-scoping release left queued in the global // retention database BEFORE the scoped reconcile and flush run, so // stranded tombstones and archive requests publish on this boot // instead of being abandoned by the storage cutover. migrate_legacy_retention_into(&restore_app, &scope); - crate::event_sync::spawn_event_sync( + crate::event_sync::spawn_event_sync_with_held_claim( restore_app.clone(), scope.owner_keys, scope.db_path, + claim, ) } Err(error) => { diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index 87db42fe4e..49e1dc9d6f 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -19,45 +19,35 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys, db_path: crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys, db_path); } -/// Spawn the best-effort event reconcile off the synchronous Tauri setup path. +/// Variant for workspace applies: spawn the reconcile+barrier with an +/// already-held `InProgress` claim. /// -/// The owner keys are cloned before spawning so the task never touches the -/// `AppState::keys` mutex. The reconcile itself is still synchronous JSON, -/// SQLite, and signing work, so it runs on the blocking pool rather than an -/// async worker. +/// `apply_workspace` calls +/// [`crate::managed_agents::config_sync_readiness::force_claim_in_progress`] +/// **before** `migrate_legacy_retention_into` runs, then passes the claim +/// here. The claim covers the entire sequence: /// -/// # Ordering: reconcile, then barrier, enforced by the single-flight latch +/// ```text +/// force_claim_in_progress() → migrate_legacy_retention_into() +/// → spawn_event_sync_with_held_claim() → run_event_sync() +/// → run_boot_barrier_after_claim() → mark_ready / Unready +/// ``` /// -/// The reconcile marks changed coordinates `pending_sync = 1`; the boot barrier -/// ([`crate::managed_agents::config_barrier::run_boot_barrier_after_claim`]) -/// then decides which may go out and gates the rest. Ordering is enforced -/// structurally: +/// This closes the `Unready` window that previously existed between +/// `mark_unready()` and `spawn_event_sync`'s inner `claim_in_progress`, during +/// which the flush loop could win the CAS and certify readiness against the +/// pre-migration, pre-reconcile database state. /// -/// 1. This function claims `InProgress` on the process-global readiness latch -/// BEFORE the reconcile task starts, preventing any concurrent flush tick -/// from running its own barrier against the pre-reconcile database state. -/// -/// 2. The blocking task runs reconcile; `run_boot_barrier_after_claim` then -/// completes the transition to `Ready(db_path)` (or back to `Unready` on -/// error). InProgress is held continuously — there is no reset between -/// reconcile and barrier, so no concurrent flush tick can win the CAS and -/// certify readiness against a pre-reconcile snapshot. -/// -/// 3. The flush loop sees `InProgress` during the entire reconcile+barrier -/// window and skips its tick rather than racing to certify readiness early. -pub fn spawn_event_sync( +/// The reconcile itself is still synchronous JSON, SQLite, and signing work, +/// so it runs on the blocking pool rather than an async worker. The owner keys +/// are cloned before spawning so the task never touches the `AppState::keys` +/// mutex. +pub fn spawn_event_sync_with_held_claim( app: tauri::AppHandle, owner_keys: nostr::Keys, db_path: std::path::PathBuf, + claim: crate::managed_agents::config_sync_readiness::ReadinessClaim, ) { - // Claim the single-flight slot synchronously before spawning. The flush - // loop sees InProgress immediately and skips until reconcile+barrier - // completes — no concurrent inline barrier can beat the reconcile. - if !crate::managed_agents::config_sync_readiness::claim_in_progress() { - // Already InProgress or Ready: another path owns this cycle. - return; - } - tauri::async_runtime::spawn(async move { let barrier_app = app.clone(); if let Err(e) = tauri::async_runtime::spawn_blocking(move || { @@ -70,7 +60,8 @@ pub fn spawn_event_sync( // InProgress is still held here — no mark_unready between reconcile // and the barrier. run_boot_barrier_after_claim owns the transition // to Ready or Unready without re-claiming. - crate::managed_agents::config_barrier::run_boot_barrier_after_claim(&barrier_app).await; + crate::managed_agents::config_barrier::run_boot_barrier_after_claim(&barrier_app, claim) + .await; }); } diff --git a/desktop/src-tauri/src/managed_agents/config_barrier.rs b/desktop/src-tauri/src/managed_agents/config_barrier.rs index c3e5559f5e..4fe7d77767 100644 --- a/desktop/src-tauri/src/managed_agents/config_barrier.rs +++ b/desktop/src-tauri/src/managed_agents/config_barrier.rs @@ -65,34 +65,42 @@ pub async fn run_boot_barrier(app: &tauri::AppHandle) { // Single-flight: only the first caller to claim InProgress runs the // barrier. If the state is already InProgress (spawn_event_sync's // reconcile is in-flight) or Ready, skip. - if !config_sync_readiness::claim_in_progress() { + let Some(claim) = config_sync_readiness::claim_in_progress() else { return; - } + }; - run_boot_barrier_enforcing(app).await; + run_boot_barrier_enforcing(app, claim).await; } /// Execute the barrier enforcement assuming the caller already claimed /// `InProgress`. /// -/// Called from `spawn_event_sync` after reconcile completes. The caller holds -/// `InProgress` through the entire reconcile+barrier window, preventing any -/// concurrent flush tick from certifying readiness against the pre-reconcile -/// database state. This function never calls `claim_in_progress` — ownership -/// is the caller's invariant. +/// Called from `spawn_event_sync` after reconcile completes, and from +/// `apply_workspace` (via `spawn_event_sync_with_held_claim`) after legacy +/// migration + reconcile. The caller holds a [`ReadinessClaim`] through the +/// entire reconcile+barrier window, preventing any concurrent flush tick from +/// running its own barrier against the pre-reconcile database state. /// /// On success: transitions to `Ready(db_path)`. On any error: resets to /// `Unready` (fail-closed, retryable on the next flush tick). -pub(crate) async fn run_boot_barrier_after_claim(app: &tauri::AppHandle) { - run_boot_barrier_enforcing(app).await; +pub(crate) async fn run_boot_barrier_after_claim( + app: &tauri::AppHandle, + claim: super::config_sync_readiness::ReadinessClaim, +) { + run_boot_barrier_enforcing(app, claim).await; } /// Shared enforcement body: scope resolution, relay lookups, decision pass. /// -/// Caller must own `InProgress` (either via `claim_in_progress` in -/// `run_boot_barrier`, or via the pre-spawn claim in `spawn_event_sync` passed -/// through `run_boot_barrier_after_claim`). -async fn run_boot_barrier_enforcing(app: &tauri::AppHandle) { +/// Takes ownership of the `ReadinessClaim` RAII guard. On the success path, +/// the guard is resolved before `mark_ready` sets the final state. On any +/// error path (including panics), the guard's `Drop` impl resets to `Unready` +/// — so a panic inside `run_boot_barrier_for_scope` can never leave the latch +/// permanently wedged at `InProgress`. +async fn run_boot_barrier_enforcing( + app: &tauri::AppHandle, + claim: super::config_sync_readiness::ReadinessClaim, +) { use super::config_sync_readiness; let state = app.state::(); @@ -100,13 +108,15 @@ async fn run_boot_barrier_enforcing(app: &tauri::AppHandle) { Ok(scope) => scope, Err(error) => { tracing::warn!(target: "buzz::config_sync", %error, "boot barrier skipped: no active scope"); - config_sync_readiness::mark_unready(); + // claim drops here → mark_unready via RAII return; } }; match run_boot_barrier_for_scope(app, &state, &scope).await { Ok(()) => { + // Resolve the claim so the RAII drop does not reset to Unready. + claim.resolve(); config_sync_readiness::mark_ready(scope.db_path.clone()); tracing::info!( target: "buzz::config_sync", @@ -116,7 +126,8 @@ async fn run_boot_barrier_enforcing(app: &tauri::AppHandle) { } Err(error) => { tracing::warn!(target: "buzz::config_sync", %error, "boot barrier failed; scope stays closed for retry"); - config_sync_readiness::mark_unready(); + // claim drops here → mark_unready via RAII (explicit call would + // double-reset, but that is idempotent; RAII is the safety net). } } } diff --git a/desktop/src-tauri/src/managed_agents/config_sync_readiness.rs b/desktop/src-tauri/src/managed_agents/config_sync_readiness.rs index b2f435ef02..bea5627d01 100644 --- a/desktop/src-tauri/src/managed_agents/config_sync_readiness.rs +++ b/desktop/src-tauri/src/managed_agents/config_sync_readiness.rs @@ -2,26 +2,34 @@ //! //! Tracks the single-flight state of the `Unready → InProgress → Ready` //! transition for the active retention scope, preventing a concurrent inline -//! flush barrier from certifying readiness before the reconcile that follows -//! `spawn_event_sync` has finished queuing its rows. +//! flush barrier from certifying readiness before the reconcile+migration that +//! follows `spawn_event_sync_with_held_claim` has finished queuing its rows. //! //! # State machine //! //! ```text -//! apply_workspace ─────────────────────────────────► Unready -//! spawn_event_sync (claim) ─────────────────────────► InProgress +//! apply_workspace (force) ─────────────────────────► InProgress (from any state) //! flush retry (claim, only when Unready) ───────────► InProgress //! run_boot_barrier success ─────────────────────────► Ready(db_path) //! run_boot_barrier error / abandon ─────────────────► Unready //! flush: InProgress → skip tick; Ready(p) → publish //! ``` //! -//! Exactly one caller at a time owns the `Unready → InProgress` transition. -//! `claim_in_progress` is a compare-and-swap under the process-global lock: -//! it succeeds only from `Unready` and returns `false` when the state is -//! already `InProgress` or `Ready`. An inline flush retry that fires while -//! `spawn_event_sync`'s reconcile is running will see `InProgress` and skip -//! its tick — it cannot certify readiness against the pre-reconcile database. +//! Two claim variants: +//! +//! - [`claim_in_progress`] — CAS from `Unready` only. Returns `None` when +//! the state is already `InProgress` or `Ready`. Used by the flush retry +//! path. +//! +//! - [`force_claim_in_progress`] — preempting claim; transitions from **any** +//! state (including `InProgress` or `Ready`) to `InProgress`. Used by +//! `apply_workspace`, which invalidates whatever the prior scope certified. +//! +//! Both variants return a [`ReadinessClaim`] RAII guard. The guard resets the +//! latch to `Unready` on drop unless [`ReadinessClaim::resolve`] has been +//! called first. This ensures a panic inside the reconcile+barrier window +//! always leaves the scope fail-closed and retryable rather than permanently +//! wedged at `InProgress`. use std::path::{Path, PathBuf}; @@ -50,13 +58,52 @@ thread_local! { // ── Public interface ────────────────────────────────────────────────────────── +/// RAII guard that holds the `InProgress` claim. +/// +/// Resets the latch to `Unready` on drop unless [`ReadinessClaim::resolve`] +/// has been called first. This ensures a panic (or early return) inside the +/// reconcile+barrier window always leaves the scope fail-closed and retryable. +/// +/// Created by [`claim_in_progress`] or [`force_claim_in_progress`]. +#[must_use = "dropping a ReadinessClaim without calling resolve() resets latch to Unready"] +pub struct ReadinessClaim { + resolved: bool, +} + +impl ReadinessClaim { + fn new() -> Self { + Self { resolved: false } + } + + /// Mark the claim as explicitly resolved (either `mark_ready` or + /// `mark_unready` will handle the final state transition). Call this + /// before the explicit resolution to prevent the drop guard from + /// redundantly resetting to `Unready` again. + pub fn resolve(mut self) { + self.resolved = true; + // Drop runs immediately but the flag prevents the reset. + } +} + +impl Drop for ReadinessClaim { + fn drop(&mut self) { + if !self.resolved { + // Panic or early return: leave the scope fail-closed and retryable. + mark_unready(); + } + } +} + /// Attempt a CAS from `Unready` → `InProgress`. /// -/// Returns `true` if the caller won the transition and now owns the -/// reconcile+barrier run. Returns `false` if the state was already +/// Returns `Some(ReadinessClaim)` if the caller won the transition and now +/// owns the reconcile+barrier run. Returns `None` if the state was already /// `InProgress` or `Ready` — the caller must not start a barrier. -pub fn claim_in_progress() -> bool { - with_lock(|state| { +/// +/// The returned guard resets to `Unready` on drop unless +/// [`ReadinessClaim::resolve`] is called first. +pub fn claim_in_progress() -> Option { + let won = with_lock(|state| { if *state == ReadinessState::Unready { *state = ReadinessState::InProgress; true @@ -64,7 +111,28 @@ pub fn claim_in_progress() -> bool { false } }) - .unwrap_or(false) + .unwrap_or(false); + if won { + Some(ReadinessClaim::new()) + } else { + None + } +} + +/// Preempting claim: transitions from **any** state (including `InProgress` +/// or `Ready`) to `InProgress`. +/// +/// Used by `apply_workspace`, which invalidates whatever the prior scope +/// certified. Because it transitions unconditionally, the flush loop will see +/// `InProgress` immediately after this call and skip its tick — even if the +/// prior scope was `Ready`. +/// +/// Returns a `ReadinessClaim` RAII guard (always succeeds). +pub fn force_claim_in_progress() -> ReadinessClaim { + let _ = with_lock(|state| { + *state = ReadinessState::InProgress; + }); + ReadinessClaim::new() } /// Mark the scope `Ready` for the given `db_path`. @@ -127,19 +195,23 @@ mod tests { #[test] fn test_claim_in_progress_from_unready_succeeds() { mark_unready(); - assert!(claim_in_progress(), "first claim must succeed"); + let claim = claim_in_progress(); + assert!(claim.is_some(), "first claim must succeed"); assert!(is_in_progress()); + claim.unwrap().resolve(); mark_unready(); // cleanup } #[test] fn test_claim_in_progress_is_single_flight() { mark_unready(); - assert!(claim_in_progress(), "first claim wins"); + let claim = claim_in_progress(); + assert!(claim.is_some(), "first claim wins"); assert!( - !claim_in_progress(), + claim_in_progress().is_none(), "second claim rejected while InProgress" ); + claim.unwrap().resolve(); mark_unready(); // cleanup } @@ -148,7 +220,7 @@ mod tests { mark_unready(); let path = PathBuf::from("/scope/db"); mark_ready(path.clone()); - assert!(!claim_in_progress(), "cannot claim when Ready"); + assert!(claim_in_progress().is_none(), "cannot claim when Ready"); assert!(is_ready_for(&path)); mark_unready(); // cleanup } @@ -157,7 +229,8 @@ mod tests { fn test_mark_ready_then_is_ready_for() { mark_unready(); let path = PathBuf::from("/scope/db"); - claim_in_progress(); + let claim = claim_in_progress().unwrap(); + claim.resolve(); mark_ready(path.clone()); assert!(is_ready_for(&path)); assert!(!is_ready_for(Path::new("/other/db"))); @@ -167,10 +240,13 @@ mod tests { #[test] fn test_mark_unready_resets_to_claimable() { mark_unready(); - claim_in_progress(); + let claim = claim_in_progress().unwrap(); + claim.resolve(); mark_unready(); assert_eq!(readiness_state(), Some(ReadinessState::Unready)); - assert!(claim_in_progress(), "after reset, claim must succeed"); + let claim2 = claim_in_progress(); + assert!(claim2.is_some(), "after reset, claim must succeed"); + claim2.unwrap().resolve(); mark_unready(); // cleanup } @@ -178,14 +254,61 @@ mod tests { fn test_workspace_switch_cycle() { mark_unready(); let path = PathBuf::from("/scope/db"); - claim_in_progress(); + let claim = claim_in_progress().unwrap(); + claim.resolve(); mark_ready(path.clone()); assert!(is_ready_for(&path)); mark_unready(); assert!(!is_ready_for(&path)); - assert!(claim_in_progress()); + let claim2 = claim_in_progress().unwrap(); + claim2.resolve(); mark_ready(path.clone()); assert!(is_ready_for(&path)); mark_unready(); // cleanup } + + #[test] + fn test_claim_drop_without_resolve_resets_to_unready() { + mark_unready(); + { + let _claim = claim_in_progress().unwrap(); + assert!(is_in_progress()); + // drop without resolve + } + assert_eq!( + readiness_state(), + Some(ReadinessState::Unready), + "dropped claim without resolve must reset to Unready" + ); + } + + #[test] + fn test_force_claim_preempts_ready() { + mark_unready(); + let path = PathBuf::from("/scope/db"); + mark_ready(path.clone()); + assert!(is_ready_for(&path)); + // force_claim must preempt Ready + let claim = force_claim_in_progress(); + assert!( + is_in_progress(), + "force_claim must set InProgress even from Ready" + ); + claim.resolve(); + mark_unready(); // cleanup + } + + #[test] + fn test_force_claim_preempts_in_progress() { + mark_unready(); + let first = claim_in_progress().unwrap(); + assert!(is_in_progress()); + // force_claim must preempt even an existing InProgress + let second = force_claim_in_progress(); + assert!(is_in_progress()); + // resolve the first claim (already replaced, no-op since resolved=true) + first.resolve(); + second.resolve(); + mark_unready(); // cleanup + } } diff --git a/desktop/src-tauri/src/managed_agents/persona_events/flush_barrier_tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/flush_barrier_tests.rs index d6bb945bb7..31aa61720a 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/flush_barrier_tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/flush_barrier_tests.rs @@ -145,8 +145,9 @@ fn test_in_progress_state_blocks_second_claim() { config_sync_readiness::mark_unready(); // First caller (spawn_event_sync) claims InProgress. + let first_claim = config_sync_readiness::claim_in_progress(); assert!( - config_sync_readiness::claim_in_progress(), + first_claim.is_some(), "first claim must succeed (Unready → InProgress)" ); assert!(config_sync_readiness::is_in_progress()); @@ -154,7 +155,7 @@ fn test_in_progress_state_blocks_second_claim() { // Second caller (flush retry) must be rejected — it would run a barrier // against the pre-reconcile database state. assert!( - !config_sync_readiness::claim_in_progress(), + config_sync_readiness::claim_in_progress().is_none(), "second claim must be rejected while InProgress" ); @@ -165,6 +166,7 @@ fn test_in_progress_state_blocks_second_claim() { Some(ReadinessState::InProgress) ); + first_claim.unwrap().resolve(); config_sync_readiness::mark_unready(); // cleanup } @@ -178,10 +180,12 @@ fn test_barrier_failure_resets_to_unready_for_retry() { config_sync_readiness::mark_unready(); // spawn_event_sync claims InProgress before reconcile. - assert!(config_sync_readiness::claim_in_progress()); + let claim = config_sync_readiness::claim_in_progress(); + assert!(claim.is_some()); - // Barrier error: run_boot_barrier_enforcing calls mark_unready on Err. - config_sync_readiness::mark_unready(); + // Barrier error: dropping the claim without resolve() resets to Unready + // (RAII guard). Simulates run_boot_barrier_enforcing returning Err. + drop(claim); // intentional drop-without-resolve assert_eq!( config_sync_readiness::readiness_state(), @@ -190,11 +194,13 @@ fn test_barrier_failure_resets_to_unready_for_retry() { ); // Next flush tick must be able to claim for retry. + let retry_claim = config_sync_readiness::claim_in_progress(); assert!( - config_sync_readiness::claim_in_progress(), + retry_claim.is_some(), "after failure, next claim must succeed so the retry can run" ); + retry_claim.unwrap().resolve(); config_sync_readiness::mark_unready(); // cleanup } @@ -222,14 +228,15 @@ async fn test_inline_barrier_cannot_certify_readiness_before_reconcile() { let db_path = dir.path().join("retention.db"); // Step 1: spawn_event_sync claims InProgress before reconcile starts. + let claim = config_sync_readiness::claim_in_progress(); assert!( - config_sync_readiness::claim_in_progress(), + claim.is_some(), "spawn_event_sync must win the InProgress claim" ); // Step 2: flush tick fires and tries to claim — must be rejected. assert!( - !config_sync_readiness::claim_in_progress(), + config_sync_readiness::claim_in_progress().is_none(), "inline flush barrier must be rejected — InProgress already claimed" ); assert!( @@ -251,6 +258,7 @@ async fn test_inline_barrier_cannot_certify_readiness_before_reconcile() { set_publish_blocked(&conn, KIND_PERSONA, &pubkey, "stale-agent", true) .expect("barrier gates stale row"); } + claim.unwrap().resolve(); config_sync_readiness::mark_ready(db_path.clone()); // Step 5: scope is Ready; flush runs but the row is blocked — 0 publish. @@ -281,9 +289,9 @@ async fn test_inline_barrier_cannot_certify_readiness_before_reconcile() { // ── (e) retry path: after failure, publish succeeds when scope becomes Ready ── // -// End-to-end: spawn_event_sync claims InProgress → barrier fails (mark_unready) -// → next tick re-claims InProgress → barrier succeeds (mark_ready) → row -// publishes on the following flush call. +// End-to-end: spawn_event_sync claims InProgress → barrier fails (RAII drop +// resets to Unready) → next tick re-claims InProgress → barrier succeeds +// (mark_ready) → row publishes on the following flush call. #[tokio::test] async fn test_retry_after_barrier_failure_publishes_when_ready() { config_sync_readiness::mark_unready(); @@ -303,10 +311,14 @@ async fn test_retry_after_barrier_failure_publishes_when_ready() { let relay_url = spawn_stub_relay().await; *state.relay_url_override.lock().unwrap() = Some(relay_url); - // Phase 1: spawn_event_sync claims InProgress → barrier fails → Unready. - assert!(config_sync_readiness::claim_in_progress()); - // Simulate barrier error: run_boot_barrier_after_claim calls mark_unready. - config_sync_readiness::mark_unready(); + // Phase 1: spawn_event_sync claims InProgress → barrier fails → drop + // resets to Unready via RAII. + { + let claim = config_sync_readiness::claim_in_progress(); + assert!(claim.is_some()); + // Simulate barrier error: drop without resolve(). + drop(claim); + } // Latch is Unready — retry is possible. assert_eq!( @@ -316,10 +328,12 @@ async fn test_retry_after_barrier_failure_publishes_when_ready() { ); // Phase 2: next flush tick re-claims InProgress, barrier succeeds → Ready. + let retry_claim = config_sync_readiness::claim_in_progress(); assert!( - config_sync_readiness::claim_in_progress(), + retry_claim.is_some(), "after mark_unready, claim must succeed for retry" ); + retry_claim.unwrap().resolve(); config_sync_readiness::mark_ready(db_path.clone()); // Row is unblocked — flush must publish it now that scope is Ready. @@ -334,3 +348,95 @@ async fn test_retry_after_barrier_failure_publishes_when_ready() { config_sync_readiness::mark_unready(); // cleanup } + +// ── (f) apply_workspace window: flush claim between invalidation and reconcile ── +// +// Paul's bounce defect: between `mark_unready()` and `spawn_event_sync`'s +// inner `claim_in_progress()`, the flush loop could win the CAS and certify +// readiness against the pre-migration, pre-reconcile database state. Migrated +// or reconciled rows then publish unarbitrated. +// +// Fix: `apply_workspace` calls `force_claim_in_progress()` BEFORE migration, +// then passes the held claim to `spawn_event_sync_with_held_claim`. The flush +// loop sees `InProgress` throughout and cannot interleave. +// +// This test reproduces the race deterministically: +// 1. `apply_workspace` calls `force_claim_in_progress()` (InProgress from any state). +// 2. A flush tick fires immediately and calls `claim_in_progress()` — rejected. +// 3. Legacy migration runs and retains a row with `publish_blocked=false`. +// 4. Reconcile retains a second row. +// 5. Post-reconcile barrier gates both rows (sets `publish_blocked=true`). +// 6. Scope transitions to Ready. +// 7. Flush runs — both rows are blocked, 0 published. +#[tokio::test] +async fn test_apply_workspace_flush_cannot_interleave_before_reconcile() { + config_sync_readiness::mark_unready(); + + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let dir = tempdir().expect("tempdir"); + let db_path = dir.path().join("retention.db"); + + // Step 1: apply_workspace atomically claims InProgress (preempting). + let claim = config_sync_readiness::force_claim_in_progress(); + assert!( + config_sync_readiness::is_in_progress(), + "force_claim must set InProgress" + ); + + // Step 2: flush tick fires and tries to claim — must be rejected. + assert!( + config_sync_readiness::claim_in_progress().is_none(), + "flush claim during apply_workspace window must be rejected" + ); + + // Step 3: legacy migration runs and retains a row. + { + let conn = open_retention_db(&db_path).expect("open db"); + retain_pending(&conn, &keys, "legacy-row"); + } + + // Step 4: reconcile retains a second row. + { + let conn = open_retention_db(&db_path).expect("open db"); + retain_pending(&conn, &keys, "reconciled-row"); + } + + // Step 5: post-reconcile barrier gates both rows. + { + let conn = open_retention_db(&db_path).expect("open db for barrier"); + set_publish_blocked(&conn, KIND_PERSONA, &pubkey, "legacy-row", true) + .expect("gate legacy row"); + set_publish_blocked(&conn, KIND_PERSONA, &pubkey, "reconciled-row", true) + .expect("gate reconciled row"); + } + + // Step 6: barrier completes — resolve claim and mark Ready. + claim.resolve(); + config_sync_readiness::mark_ready(db_path.clone()); + + // Step 7: flush runs — both rows are blocked, 0 published. + let state = build_app_state(); + *state.keys.lock().unwrap() = keys.clone(); + *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); + + let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); + assert_eq!( + flushed, 0, + "migrated and reconciled rows gated by post-reconcile barrier must not publish" + ); + + let conn = open_retention_db(&db_path).expect("reopen db"); + for d_tag in ["legacy-row", "reconciled-row"] { + let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag) + .unwrap() + .unwrap(); + assert!( + row.publish_blocked, + "{d_tag}: publish_blocked must persist after gating" + ); + assert!(row.pending_sync, "{d_tag}: gated row must stay pending"); + } + + config_sync_readiness::mark_unready(); // cleanup +}