diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index f8b8a2eb56..b3613dedc7 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -41,6 +41,10 @@ pub enum DbError { #[error("serialization error: {0}")] Serde(#[from] serde_json::Error), + /// A private managed-agent compare-and-swap conflicted with current authority. + #[error("managed-agent conflict: {0}")] + ManagedAgentConflict(String), + /// A value in the database is malformed or unexpected. #[error("invalid data: {0}")] InvalidData(String), diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index e1b45aa3a1..e93e5511af 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -10,8 +10,8 @@ use sqlx::{PgPool, Postgres, QueryBuilder, Row, Transaction}; use uuid::Uuid; use buzz_core::kind::{ - event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER, - KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, + event_kind_i32, is_ephemeral, is_parameterized_replaceable, AUTHOR_ONLY_KINDS, KIND_AUTH, + KIND_EVENT_REMINDER, KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, }; use buzz_core::{CommunityId, StoredEvent}; @@ -78,6 +78,11 @@ pub struct EventQuery { /// the COUNT fallback path, which needs to fetch all matching events for /// post-filter counting. When None, the default clamp applies. pub max_limit: Option, + /// Author-only visibility reader: when set, append an SQL visibility clause + /// for every kind in [`buzz_core::kind::AUTHOR_ONLY_KINDS`] before + /// ORDER/LIMIT. This prevents newer foreign private rows from starving the + /// authenticated owner's visible rows off a bounded page. + pub author_only_reader: Option>, /// Shared-gated visibility reader: when set, append an SQL visibility /// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so /// private events are excluded from the candidate page rather than @@ -123,6 +128,7 @@ impl EventQuery { e_tags: None, channel_ids: None, max_limit: None, + author_only_reader: None, shared_gated_reader: None, } } @@ -521,6 +527,21 @@ pub(crate) async fn query_events_on( } } + // Author-only visibility pushdown: exclude private rows not authored by + // the authenticated reader before ORDER/LIMIT. Post-filtering alone is not + // sufficient: a foreign owner can otherwise fill the candidate page and + // starve the reader's older rows. + if let Some(ref reader_bytes) = q.author_only_reader { + qb.push(format!(" AND ({col_prefix}kind NOT IN (")); + let mut sep = qb.separated(", "); + for kind in AUTHOR_ONLY_KINDS { + sep.push_bind(*kind as i32); + } + qb.push(format!(") OR {col_prefix}pubkey = ")); + qb.push_bind(reader_bytes.clone()); + qb.push(")"); + } + // Shared-gated visibility pushdown: exclude SHARED_GATED_KINDS events that // are neither authored by the reader nor explicitly shared. Applied BEFORE // ORDER/LIMIT so that a page of newer private events does not push visible @@ -761,6 +782,38 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer } } + // Apply the same author-only SQL visibility gate used by query_events. + // COUNT must not leak foreign event existence, including for mixed-kind + // filters and kindless id lookups. + if let Some(ref reader_bytes) = q.author_only_reader { + qb.push(format!(" AND ({col_prefix}kind NOT IN (")); + let mut sep = qb.separated(", "); + for kind in AUTHOR_ONLY_KINDS { + sep.push_bind(*kind as i32); + } + qb.push(format!(") OR {col_prefix}pubkey = ")); + qb.push_bind(reader_bytes.clone()); + qb.push(")"); + } + + // Shared-gated visibility belongs in SQL for COUNT as well. Although relay + // callers currently use the per-event fallback for these kinds, keeping the + // query primitive complete prevents a future direct caller from leaking + // foreign unshared rows. + if let Some(ref reader_bytes) = q.shared_gated_reader { + let shared_containment = serde_json::json!([["shared", "true"]]); + qb.push(format!(" AND ({col_prefix}kind NOT IN (")); + let mut sep = qb.separated(", "); + for kind in SHARED_GATED_KINDS { + sep.push_bind(*kind as i32); + } + qb.push(format!(") OR {col_prefix}pubkey = ")); + qb.push_bind(reader_bytes.clone()); + qb.push(format!(" OR {col_prefix}tags @> ")); + qb.push_bind(shared_containment); + qb.push(")"); + } + let row = qb.build().fetch_one(&mut *conn).await?; let cnt: i64 = row.try_get("cnt")?; @@ -827,6 +880,32 @@ pub async fn soft_delete_by_coordinate( ) -> Result { let deletion_created_at = DateTime::from_timestamp(deletion_created_at_secs, 0) .ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?; + let mut tx = pool.begin().await?; + let lock_key = + super::event_replacement_lock_key(community_id, kind, pubkey, Some(d_tag.as_bytes())); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + if kind as u32 == buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT { + tx.rollback().await?; + return Ok(false); + } + if matches!( + kind as u32, + buzz_core::kind::KIND_PERSONA | buzz_core::kind::KIND_MANAGED_AGENT + ) && crate::managed_agent::projection_coordinate_is_authoritative_on( + &mut tx, + community_id, + kind as u32, + pubkey, + d_tag, + ) + .await? + { + tx.rollback().await?; + return Ok(false); + } let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ @@ -837,8 +916,9 @@ pub async fn soft_delete_by_coordinate( .bind(pubkey) .bind(d_tag) .bind(deletion_created_at) - .execute(pool) + .execute(&mut *tx) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -857,6 +937,46 @@ pub async fn soft_delete_event_and_update_thread( ) -> Result { let mut tx = pool.begin().await?; + let coordinate: Option<(i32, Vec, Option)> = + sqlx::query_as("SELECT kind,pubkey,d_tag FROM events WHERE community_id=$1 AND id=$2") + .bind(community_id.as_uuid()) + .bind(event_id) + .fetch_optional(&mut *tx) + .await?; + if let Some((kind, pubkey, Some(d_tag))) = coordinate { + if kind as u32 == buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT { + tx.rollback().await?; + return Ok(false); + } + if matches!( + kind as u32, + buzz_core::kind::KIND_PERSONA | buzz_core::kind::KIND_MANAGED_AGENT + ) { + let lock_key = super::event_replacement_lock_key( + community_id, + kind, + &pubkey, + Some(d_tag.as_bytes()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + if crate::managed_agent::projection_coordinate_is_authoritative_on( + &mut tx, + community_id, + kind as u32, + &pubkey, + &d_tag, + ) + .await? + { + tx.rollback().await?; + return Ok(false); + } + } + } + let result = sqlx::query( "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) @@ -1111,7 +1231,7 @@ pub struct ThreadMetadataParams<'a> { pub broadcast: bool, } -async fn insert_event_with_thread_metadata_tx( +pub(crate) async fn insert_event_with_thread_metadata_tx( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, event: &Event, @@ -1878,6 +1998,163 @@ mod tests { .expect("sign timestamped event") } + fn make_event_at_with_keys( + keys: &Keys, + kind: u16, + content: &str, + created_at: u64, + tags: Vec, + ) -> nostr::Event { + EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(keys) + .expect("sign timestamped event") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn author_only_visibility_is_applied_before_historical_page_limit() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let owner = Keys::generate(); + let foreign_owner = Keys::generate(); + let base = 1_800_100_000; + + // More than LIMIT newer foreign rows must not starve the owner's older + // row. Distinct d tags prevent NIP-33 replacement from collapsing the + // setup into one row per author. + for offset in 10..14 { + let event = make_event_at_with_keys( + &foreign_owner, + 30_179, + "newer foreign private aggregate", + base + offset, + vec![Tag::parse(["d", &format!("foreign-{offset}")]).unwrap()], + ); + insert_event(&pool, community, &event, None) + .await + .expect("insert foreign private aggregate"); + } + let owner_event = make_event_at_with_keys( + &owner, + 30_179, + "older owner private aggregate", + base + 1, + vec![Tag::parse(["d", "owner"]).unwrap()], + ); + insert_event(&pool, community, &owner_event, None) + .await + .expect("insert owner private aggregate"); + + let events = query_events( + &pool, + &EventQuery { + kinds: Some(vec![30_179]), + limit: Some(2), + author_only_reader: Some(owner.public_key().to_bytes().to_vec()), + ..EventQuery::for_community(community) + }, + ) + .await + .expect("query owner-visible private aggregates"); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].event.id, owner_event.id); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn author_only_count_hides_foreign_explicit_mixed_and_kindless_queries() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let owner = Keys::generate(); + let foreign_owner = Keys::generate(); + let owner_event = make_event_at_with_keys( + &owner, + 30_179, + "owner private aggregate", + 1_800_200_001, + vec![Tag::parse(["d", "owner"]).unwrap()], + ); + let foreign_event = make_event_at_with_keys( + &foreign_owner, + 30_179, + "foreign private aggregate", + 1_800_200_002, + vec![Tag::parse(["d", "foreign"]).unwrap()], + ); + for event in [&owner_event, &foreign_event] { + insert_event(&pool, community, event, None) + .await + .expect("insert private aggregate"); + } + + let reader = owner.public_key().to_bytes().to_vec(); + let kindless_foreign_query = EventQuery { + ids: Some(vec![foreign_event.id.as_bytes().to_vec()]), + author_only_reader: Some(reader.clone()), + ..EventQuery::for_community(community) + }; + assert!( + query_events(&pool, &kindless_foreign_query) + .await + .expect("query kindless foreign id") + .is_empty(), + "a kindless ID query must not reveal a foreign author-only row" + ); + + for (label, query) in [ + ( + "explicit kind", + EventQuery { + kinds: Some(vec![30_179]), + authors: Some(vec![foreign_owner.public_key().to_bytes().to_vec()]), + author_only_reader: Some(reader.clone()), + ..EventQuery::for_community(community) + }, + ), + ( + "mixed kinds", + EventQuery { + kinds: Some(vec![1, 30_179]), + authors: Some(vec![foreign_owner.public_key().to_bytes().to_vec()]), + author_only_reader: Some(reader.clone()), + ..EventQuery::for_community(community) + }, + ), + ( + "kindless id", + EventQuery { + ids: Some(vec![foreign_event.id.as_bytes().to_vec()]), + author_only_reader: Some(reader.clone()), + ..EventQuery::for_community(community) + }, + ), + ] { + assert_eq!( + count_events(&pool, &query).await.expect(label), + 0, + "{label} must not disclose a foreign author-only row" + ); + } + + assert_eq!( + count_events( + &pool, + &EventQuery { + kinds: Some(vec![30_179]), + author_only_reader: Some(reader), + ..EventQuery::for_community(community) + }, + ) + .await + .expect("count owner private aggregate"), + 1, + "the owner's own count remains exact" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn access_scope_is_applied_before_historical_page_limit() { diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9b26876747..998336c3d9 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -27,6 +27,8 @@ pub mod event; pub mod feed; /// Git repository name registry (NIP-34 kind:30617). pub mod git_repo; +/// Transactional private managed-agent authority. +pub mod managed_agent; /// Embedded database migrations. pub mod migration; /// Community moderation: reports, bans/timeouts, audit actions. @@ -4777,6 +4779,53 @@ impl Db { /// Atomically replace a NIP-33 parameterized replaceable event (kind 30000–39999). /// + /// Atomically commit a relay-verifiable private managed-agent aggregate. + pub async fn commit_managed_agent_aggregate( + &self, + community_id: CommunityId, + request: &managed_agent::AggregateRequest, + ) -> Result { + managed_agent::commit_aggregate(&self.pool, community_id, request).await + } + + /// Whether an owner-bound deleted PMA head revokes this principal. + pub async fn managed_agent_participation_is_revoked( + &self, + community_id: CommunityId, + agent: &[u8], + proven_owner: Option<&[u8]>, + ) -> Result { + managed_agent::participation_is_revoked(&self.pool, community_id, agent, proven_owner).await + } + + /// Whether an ordinary projection targets PMA-authoritative state. + pub async fn managed_agent_projection_is_authoritative( + &self, + community_id: CommunityId, + event: &nostr::Event, + d_tag: &str, + ) -> Result { + managed_agent::projection_is_authoritative(&self.pool, community_id, event, d_tag).await + } + + /// Whether a projection coordinate is controlled by a PMA head. + pub async fn managed_agent_projection_coordinate_is_authoritative( + &self, + community_id: CommunityId, + kind: u32, + owner: &[u8], + d_tag: &str, + ) -> Result { + managed_agent::projection_coordinate_is_authoritative( + &self.pool, + community_id, + kind, + owner, + d_tag, + ) + .await + } + /// Keeps only the event with the highest `created_at` per `(kind, pubkey, d_tag)`. /// Same-second ties are broken by lowest event `id` (deterministic ordering). /// The entire check → retire old payload → insert runs in a single transaction @@ -4823,6 +4872,26 @@ impl Db { .execute(&mut *tx) .await?; + // The pre-ingest authority check is only an early rejection. Recheck + // after taking the coordinate lock so a first aggregate that raced us + // cannot be overwritten after binding this projection coordinate. + if matches!( + kind_i32 as u32, + buzz_core::kind::KIND_PERSONA | buzz_core::kind::KIND_MANAGED_AGENT + ) && managed_agent::projection_coordinate_is_authoritative_on( + &mut tx, + community_id, + kind_i32 as u32, + pubkey_bytes.as_slice(), + d_tag, + ) + .await? + { + return Err(DbError::ManagedAgentConflict( + "projection is controlled by a private managed-agent aggregate".into(), + )); + } + let d_tag_count = event .tags .iter() diff --git a/crates/buzz-db/src/managed_agent.rs b/crates/buzz-db/src/managed_agent.rs new file mode 100644 index 0000000000..f3a1dae297 --- /dev/null +++ b/crates/buzz-db/src/managed_agent.rs @@ -0,0 +1,1116 @@ +//! Transactional relay authority for NIP-PMA private managed agents. + +use buzz_core::private_managed_agent::{self, Envelope, State}; +use buzz_core::{CommunityId, StoredEvent}; +use nostr::Event; +use sqlx::{PgPool, Postgres, Row, Transaction}; + +use crate::{DbError, Result}; + +/// Relay-verifiable aggregate submission. Plaintext and agent secrets never +/// cross this boundary: Desktop validates ciphertext/binding consistency after +/// writer-consistent read-back. +#[derive(Debug, Clone)] +pub struct AggregateRequest { + /// Signed encrypted kind:30179 head. + pub private_event: Event, + /// Signed kind:30175 candidate, required for active heads. + pub definition_event: Option, + /// Signed kind:30177 candidate, required for active heads. + pub instance_event: Option, + /// Caller-supplied cleartext revision expected for the active definition. + /// The relay cannot inspect the encrypted payload; it CAS-checks this value + /// against the locked definition head, while Desktop verifies the encrypted + /// binding after writer-consistent read-back. + pub expected_definition_revision: Option, +} + +/// Events committed by an aggregate submission, suitable for post-commit fan-out. +#[derive(Debug, Clone)] +pub struct AggregateCommit { + /// Writer-consistent committed head metadata. + pub head: ManagedAgentHead, + /// Newly committed events. Empty for a byte-identical retry. + pub events: Vec, + /// Whether this call committed a new generation. + pub inserted: bool, + /// Transactionally confirmed definition revision for active heads. + pub definition_revision: Option, + /// Writer-consistent signed private head. + pub private_event: Event, + /// Writer-consistent signed definition binding. + pub definition_event: Option, + /// Writer-consistent signed instance binding. + pub instance_event: Option, +} + +/// Writer-consistent PMA authority head. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedAgentHead { + /// Owner pubkey. + pub owner_pubkey: Vec, + /// Agent pubkey. + pub agent_pubkey: Vec, + /// Current generation floor. + pub generation: u64, + /// Exact current private event ID. + pub event_id: Vec, + /// Whether the current head is active. + pub active: bool, +} + +fn invalid(message: impl Into) -> DbError { + DbError::InvalidData(message.into()) +} + +fn exact_d(event: &Event) -> Result<&str> { + let values: Vec<_> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().is_some_and(|part| part == "d")).then_some(parts) + }) + .collect(); + if values.len() != 1 || values[0].len() != 2 || values[0][1].is_empty() { + return Err(invalid( + "projection requires exactly one two-element non-empty d tag", + )); + } + Ok(&values[0][1]) +} + +fn validate_projection(event: &Event, owner: &nostr::PublicKey, kind: u32) -> Result { + if event.kind.as_u16() as u32 != kind + || &event.pubkey != owner + || !event.verify_id() + || !event.verify_signature() + { + return Err(invalid(format!( + "invalid signed kind:{kind} owner projection" + ))); + } + Ok(exact_d(event)?.to_owned()) +} + +fn validate_request(request: &AggregateRequest) -> Result<(Envelope, Vec, Option)> { + let owner = request.private_event.pubkey; + let envelope = private_managed_agent::validate_envelope(&request.private_event, &owner) + .map_err(|e| invalid(e.to_string()))?; + match envelope.state { + State::Active => { + request + .expected_definition_revision + .filter(|revision| *revision > 0 && *revision <= i64::MAX as u64) + .ok_or_else(|| { + invalid("active aggregate requires a valid expected definition revision") + })?; + let definition = request + .definition_event + .as_ref() + .ok_or_else(|| invalid("active aggregate missing definition projection"))?; + let instance = request + .instance_event + .as_ref() + .ok_or_else(|| invalid("active aggregate missing instance projection"))?; + let definition_d = validate_projection(definition, &owner, 30175)?; + let instance_d = validate_projection(instance, &owner, 30177)?; + if instance_d != envelope.agent_pubkey.to_hex() { + return Err(invalid( + "instance projection d does not match agent coordinate", + )); + } + Ok(( + envelope, + vec![definition.clone(), instance.clone()], + Some(definition_d), + )) + } + State::Deleted => { + if request.definition_event.is_some() + || request.instance_event.is_some() + || request.expected_definition_revision.is_some() + { + return Err(invalid( + "deleted aggregate must not carry projections or a definition revision", + )); + } + Ok((envelope, vec![], None)) + } + } +} + +fn decode_event(value: serde_json::Value, label: &str) -> Result { + serde_json::from_value(value) + .map_err(|error| invalid(format!("invalid stored {label}: {error}"))) +} + +async fn read_snapshot_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + owner: &[u8], + agent: &[u8], + generation: u64, + inserted: bool, + events: Vec, +) -> Result { + let row = sqlx::query( + "SELECT h.owner_pubkey,h.agent_pubkey,h.generation,h.event_id,h.state,\ + r.definition_revision,r.private_event,r.definition_event,r.instance_event \ + FROM managed_agent_heads h \ + JOIN managed_agent_revisions r ON r.community_id=h.community_id \ + AND r.owner_pubkey=h.owner_pubkey AND r.agent_pubkey=h.agent_pubkey \ + AND r.generation=h.generation \ + WHERE h.community_id=$1 AND h.owner_pubkey=$2 AND h.agent_pubkey=$3 \ + AND h.generation=$4", + ) + .bind(community.as_uuid()) + .bind(owner) + .bind(agent) + .bind(generation as i64) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| invalid("committed aggregate snapshot missing"))?; + let definition = row + .try_get::, _>("definition_event")? + .map(|value| decode_event(value, "definition event")) + .transpose()?; + let instance = row + .try_get::, _>("instance_event")? + .map(|value| decode_event(value, "instance event")) + .transpose()?; + Ok(AggregateCommit { + head: ManagedAgentHead { + owner_pubkey: row.try_get("owner_pubkey")?, + agent_pubkey: row.try_get("agent_pubkey")?, + generation: row.try_get::("generation")? as u64, + event_id: row.try_get("event_id")?, + active: row.try_get::("state")? == "active", + }, + events, + inserted, + definition_revision: row + .try_get::, _>("definition_revision")? + .map(|value| value as u64), + private_event: decode_event(row.try_get("private_event")?, "private event")?, + definition_event: definition, + instance_event: instance, + }) +} + +/// Atomically CAS and persist a PMA head and its exact projections. +/// Serialization failures are retried at most twice with the same already-owned +/// request object. Validation is deterministic and no CAS conflict is retried. +pub async fn commit_aggregate( + pool: &PgPool, + community: CommunityId, + request: &AggregateRequest, +) -> Result { + validate_request(request)?; + let mut serialization_retries = 0; + loop { + let result = commit_aggregate_once(pool, community, request).await; + let retryable = matches!( + &result, + Err(DbError::Sqlx(sqlx::Error::Database(error))) + if error.code().as_deref() == Some("40001") + ); + if retryable && serialization_retries < 2 { + serialization_retries += 1; + continue; + } + return result; + } +} + +async fn commit_aggregate_once( + pool: &PgPool, + community: CommunityId, + request: &AggregateRequest, +) -> Result { + let (envelope, projections, definition_d) = validate_request(request)?; + let owner = envelope.owner_pubkey.to_bytes().to_vec(); + let agent = envelope.agent_pubkey.to_bytes().to_vec(); + let event_id = request.private_event.id.to_bytes().to_vec(); + let previous = envelope.previous_event_id.map(|id| id.to_bytes().to_vec()); + let definition_event_id = projections + .first() + .map(|event| event.id.to_bytes().to_vec()); + let definition_hash = projections + .first() + .map(|event| private_managed_agent::content_sha256(event.content.as_bytes())) + .map(|hash| hex::decode(hash).expect("sha256 hex")); + let instance_event_id = projections.get(1).map(|event| event.id.to_bytes().to_vec()); + let instance_hash = projections + .get(1) + .map(|event| private_managed_agent::content_sha256(event.content.as_bytes())) + .map(|hash| hex::decode(hash).expect("sha256 hex")); + + let mut tx = pool.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") + .execute(&mut *tx) + .await?; + let lock = super::event_replacement_lock_key(community, 30179, &owner, Some(&agent)); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock) + .execute(&mut *tx) + .await?; + + // The aggregate itself is the owner's authenticated, signed assertion that + // this pubkey is their managed agent. Materialize that binding in the SAME + // transaction as the head so a directly enrolled agent that never presents + // a NIP-OA auth tag is still revoked by a later tombstone. Do this before + // the idempotent-return path as well: retrying a head committed by an older + // relay must repair the formerly missing binding. Insert the owner first to + // satisfy the community-scoped FK, then set the agent mapping only when it + // is absent or already agrees. A conflicting pre-existing owner is a hard + // conflict; committing or serving the head without its revocation authority + // would leave a deleted agent able to participate. + sqlx::query("INSERT INTO users (community_id,pubkey) VALUES ($1,$2) ON CONFLICT DO NOTHING") + .bind(community.as_uuid()) + .bind(&owner) + .execute(&mut *tx) + .await?; + let bound_agent = sqlx::query_scalar::<_, Vec>( + "INSERT INTO users (community_id,pubkey,agent_owner_pubkey) VALUES ($1,$2,$3) \ + ON CONFLICT (community_id,pubkey) DO UPDATE \ + SET agent_owner_pubkey=EXCLUDED.agent_owner_pubkey \ + WHERE users.agent_owner_pubkey IS NULL \ + OR users.agent_owner_pubkey=EXCLUDED.agent_owner_pubkey \ + RETURNING agent_owner_pubkey", + ) + .bind(community.as_uuid()) + .bind(&agent) + .bind(&owner) + .fetch_optional(&mut *tx) + .await?; + if bound_agent.as_deref() != Some(owner.as_slice()) { + return Err(DbError::ManagedAgentConflict( + "agent is already bound to a different owner".into(), + )); + } + + let current = sqlx::query("SELECT generation,event_id,state,definition_revision,definition_event_id,definition_content_sha256,instance_event_id,instance_content_sha256 FROM managed_agent_heads WHERE community_id=$1 AND owner_pubkey=$2 AND agent_pubkey=$3 FOR UPDATE") + .bind(community.as_uuid()).bind(&owner).bind(&agent).fetch_optional(&mut *tx).await?; + if let Some(row) = ¤t { + let same_head = row.try_get::("generation")? as u64 == envelope.generation + && row.try_get::, _>("event_id")? == event_id; + if same_head { + let same_bindings = row.try_get::("state")? + == match envelope.state { + State::Active => "active", + State::Deleted => "deleted", + } + && row.try_get::>, _>("definition_event_id")? == definition_event_id + && row.try_get::>, _>("definition_content_sha256")? + == definition_hash + && row.try_get::>, _>("instance_event_id")? == instance_event_id + && row.try_get::>, _>("instance_content_sha256")? == instance_hash + && row + .try_get::, _>("definition_revision")? + .map(|revision| revision as u64) + == request.expected_definition_revision; + if !same_bindings { + return Err(DbError::ManagedAgentConflict( + "idempotent retry changed projection bindings".into(), + )); + } + let snapshot = read_snapshot_tx( + &mut tx, + community, + &owner, + &agent, + envelope.generation, + false, + vec![], + ) + .await?; + tx.commit().await?; + return Ok(snapshot); + } + } + match ¤t { + None if envelope.generation == 1 && previous.is_none() => {} + Some(row) + if envelope.generation == row.try_get::("generation")? as u64 + 1 + && previous.as_deref() + == Some(row.try_get::, _>("event_id")?.as_slice()) => {} + None => { + return Err(DbError::ManagedAgentConflict( + "genesis requires generation 1 without predecessor".into(), + )) + } + Some(_) => { + return Err(DbError::ManagedAgentConflict( + "predecessor or generation conflict".into(), + )) + } + } + + let instance_d = envelope.agent_pubkey.to_hex(); + let instance_lock = + super::event_replacement_lock_key(community, 30177, &owner, Some(instance_d.as_bytes())); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(instance_lock) + .execute(&mut *tx) + .await?; + + let definition_revision = if let (Some(d), Some(definition_id), Some(hash)) = + (&definition_d, &definition_event_id, &definition_hash) + { + let definition_lock = + super::event_replacement_lock_key(community, 30175, &owner, Some(d.as_bytes())); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(definition_lock) + .execute(&mut *tx) + .await?; + let definition_head: Option<(i64, Vec, Vec)> = sqlx::query_as("SELECT revision,event_id,content_sha256 FROM managed_agent_definition_heads WHERE community_id=$1 AND owner_pubkey=$2 AND definition_d=$3 FOR UPDATE") + .bind(community.as_uuid()).bind(&owner).bind(d).fetch_optional(&mut *tx).await?; + let revision = match definition_head { + Some((revision, old_id, old_hash)) if old_id == *definition_id && old_hash == *hash => { + revision + } + Some((revision, _, _)) => revision + 1, + None => 1, + }; + if request.expected_definition_revision != Some(revision as u64) { + return Err(DbError::ManagedAgentConflict(format!( + "expected definition revision does not match current definition head; expected {revision}" + ))); + } + sqlx::query("INSERT INTO managed_agent_definition_heads (community_id,owner_pubkey,definition_d,revision,event_id,content_sha256) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (community_id,owner_pubkey,definition_d) DO UPDATE SET revision=EXCLUDED.revision,event_id=EXCLUDED.event_id,content_sha256=EXCLUDED.content_sha256") + .bind(community.as_uuid()).bind(&owner).bind(d).bind(revision).bind(definition_id).bind(hash).execute(&mut *tx).await?; + Some(revision) + } else { + None + }; + + // Insert first, then atomically reactivate the exact submitted IDs while + // retiring every competing live row at each coordinate. + let mut stored = Vec::with_capacity(1 + projections.len()); + for event in projections + .iter() + .chain(std::iter::once(&request.private_event)) + { + let was_deleted: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM events WHERE community_id=$1 AND id=$2 AND deleted_at IS NOT NULL)") + .bind(community.as_uuid()).bind(event.id.to_bytes()).fetch_one(&mut *tx).await?; + let (row, inserted) = crate::event::insert_event_with_thread_metadata_tx( + &mut tx, community, event, None, None, + ) + .await?; + let d = exact_d(event)?; + let activated = sqlx::query("UPDATE events SET deleted_at=CASE WHEN id=$5 THEN NULL ELSE now() END WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 AND (deleted_at IS NULL OR id=$5)") + .bind(community.as_uuid()).bind(event.kind.as_u16() as i32).bind(event.pubkey.to_bytes()).bind(d).bind(event.id.to_bytes()).execute(&mut *tx).await?; + if activated.rows_affected() == 0 { + return Err(DbError::ManagedAgentConflict( + "submitted event ID is not stored at its signed coordinate".into(), + )); + } + if inserted || was_deleted { + stored.push(row); + } + } + + // Tombstones carry no projections, but their prior public instance must not + // remain discoverable as an active managed agent. Definitions are shared + // and revision-pinned, so deleting one agent must not retire that row. + if envelope.state == State::Deleted { + sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND kind=30177 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL") + .bind(community.as_uuid()) + .bind(&owner) + .bind(envelope.agent_pubkey.to_hex()) + .execute(&mut *tx) + .await?; + } + + let state = match envelope.state { + State::Active => "active", + State::Deleted => "deleted", + }; + sqlx::query("INSERT INTO managed_agent_heads (community_id,owner_pubkey,agent_pubkey,generation,event_id,state,definition_d,definition_revision,definition_event_id,definition_content_sha256,instance_event_id,instance_content_sha256) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (community_id,owner_pubkey,agent_pubkey) DO UPDATE SET generation=EXCLUDED.generation,event_id=EXCLUDED.event_id,state=EXCLUDED.state,definition_d=EXCLUDED.definition_d,definition_revision=EXCLUDED.definition_revision,definition_event_id=EXCLUDED.definition_event_id,definition_content_sha256=EXCLUDED.definition_content_sha256,instance_event_id=EXCLUDED.instance_event_id,instance_content_sha256=EXCLUDED.instance_content_sha256,updated_at=now()") + .bind(community.as_uuid()).bind(&owner).bind(&agent).bind(envelope.generation as i64).bind(&event_id).bind(state).bind(&definition_d).bind(definition_revision).bind(&definition_event_id).bind(&definition_hash).bind(&instance_event_id).bind(&instance_hash).execute(&mut *tx).await?; + sqlx::query("INSERT INTO managed_agent_revisions (community_id,owner_pubkey,agent_pubkey,generation,event_id,previous_event_id,state,definition_d,definition_revision,definition_event_id,definition_content_sha256,instance_event_id,instance_content_sha256,private_event,definition_event,instance_event) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)") + .bind(community.as_uuid()).bind(&owner).bind(&agent).bind(envelope.generation as i64).bind(&event_id).bind(&previous).bind(state).bind(&definition_d).bind(definition_revision).bind(&definition_event_id).bind(&definition_hash).bind(&instance_event_id).bind(&instance_hash) + .bind(serde_json::to_value(&request.private_event)?).bind(request.definition_event.as_ref().map(serde_json::to_value).transpose()?).bind(request.instance_event.as_ref().map(serde_json::to_value).transpose()?).execute(&mut *tx).await?; + let snapshot = read_snapshot_tx( + &mut tx, + community, + &owner, + &agent, + envelope.generation, + true, + stored, + ) + .await?; + tx.commit().await?; + Ok(snapshot) +} + +/// Read a head from the writer pool, never a lagging replica. +pub async fn read_head( + pool: &PgPool, + community: CommunityId, + owner: &[u8], + agent: &[u8], +) -> Result> { + let row = sqlx::query("SELECT owner_pubkey,agent_pubkey,generation,event_id,state FROM managed_agent_heads WHERE community_id=$1 AND owner_pubkey=$2 AND agent_pubkey=$3") + .bind(community.as_uuid()).bind(owner).bind(agent).fetch_optional(pool).await?; + row.map(|r| { + Ok(ManagedAgentHead { + owner_pubkey: r.try_get("owner_pubkey")?, + agent_pubkey: r.try_get("agent_pubkey")?, + generation: r.try_get::("generation")? as u64, + event_id: r.try_get("event_id")?, + active: r.try_get::("state")? == "active", + }) + }) + .transpose() +} + +/// Whether a principal is revoked by a deleted PMA head in this community. +/// +/// `proven_owner` is cryptographically verified NIP-OA evidence from the +/// current request. The durable user mapping is checked as an independent +/// owner binding so a caller cannot bypass an existing tombstone by presenting +/// a second delegation. Rows belonging only to unrelated owners or communities +/// never revoke the principal. +pub async fn participation_is_revoked( + pool: &PgPool, + community: CommunityId, + agent: &[u8], + proven_owner: Option<&[u8]>, +) -> Result { + sqlx::query_scalar( + "SELECT EXISTS(\ + SELECT 1 FROM managed_agent_heads h \ + WHERE h.community_id=$1 AND h.agent_pubkey=$2 AND h.state='deleted' \ + AND (h.owner_pubkey=$3::bytea OR h.owner_pubkey=(\ + SELECT u.agent_owner_pubkey FROM users u \ + WHERE u.community_id=$1 AND u.pubkey=$2\ + ))\ + )", + ) + .bind(community.as_uuid()) + .bind(agent) + .bind(proven_owner) + .fetch_one(pool) + .await + .map_err(Into::into) +} + +/// Whether a projection coordinate is controlled by a PMA head. +pub async fn projection_coordinate_is_authoritative( + pool: &PgPool, + community: CommunityId, + kind: u32, + owner: &[u8], + d_tag: &str, +) -> Result { + let mut connection = pool.acquire().await?; + projection_coordinate_is_authoritative_on(&mut connection, community, kind, owner, d_tag).await +} + +pub(crate) async fn projection_coordinate_is_authoritative_on( + connection: &mut sqlx::PgConnection, + community: CommunityId, + kind: u32, + owner: &[u8], + d_tag: &str, +) -> Result { + let exists = match kind { + 30177 => { + let agent = hex::decode(d_tag) + .map_err(|_| invalid("invalid managed-agent d tag"))?; + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM managed_agent_heads WHERE community_id=$1 AND owner_pubkey=$2 AND agent_pubkey=$3)") + .bind(community.as_uuid()) + .bind(owner) + .bind(agent) + .fetch_one(&mut *connection) + .await? + } + 30175 => sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM managed_agent_heads WHERE community_id=$1 AND owner_pubkey=$2 AND definition_d=$3)") + .bind(community.as_uuid()) + .bind(owner) + .bind(d_tag) + .fetch_one(&mut *connection) + .await?, + _ => false, + }; + Ok(exists) +} + +/// Whether an ordinary projection write targets PMA-authoritative state. +pub async fn projection_is_authoritative( + pool: &PgPool, + community: CommunityId, + event: &Event, + d_tag: &str, +) -> Result { + projection_coordinate_is_authoritative( + pool, + community, + event.kind.as_u16() as u32, + &event.pubkey.to_bytes(), + d_tag, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn projection(keys: &Keys, kind: u16, d: &str) -> Event { + EventBuilder::new(Kind::Custom(kind), "projection") + .tag(Tag::parse(["d", d]).expect("d tag")) + .sign_with_keys(keys) + .expect("sign projection") + } + + #[test] + fn active_projection_validation_binds_owner_kind_signature_and_agent_coordinate() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let definition = projection(&owner, 30175, "definition"); + let instance = projection(&owner, 30177, &agent.public_key().to_hex()); + assert_eq!( + validate_projection(&definition, &owner.public_key(), 30175).unwrap(), + "definition" + ); + assert_eq!( + validate_projection(&instance, &owner.public_key(), 30177).unwrap(), + agent.public_key().to_hex() + ); + assert!(validate_projection(&instance, &owner.public_key(), 30175).is_err()); + assert!(validate_projection(&instance, &Keys::generate().public_key(), 30177).is_err()); + } + + fn private_head( + owner: &Keys, + agent: &Keys, + generation: u64, + previous: Option<&nostr::EventId>, + state: &str, + ) -> Event { + let mut tags = vec![ + Tag::parse(["d", agent.public_key().to_hex().as_str()]).unwrap(), + Tag::parse(["g", generation.to_string().as_str()]).unwrap(), + Tag::parse(["state", state]).unwrap(), + ]; + if let Some(previous) = previous { + tags.push(Tag::parse(["prev", previous.to_hex().as_str()]).unwrap()); + } + EventBuilder::new(Kind::Custom(30179), "encrypted-ciphertext") + .tags(tags) + .sign_with_keys(owner) + .unwrap() + } + + async fn postgres_fixture() -> Option<(PgPool, CommunityId)> { + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .ok()?; + let pool = PgPool::connect(&url).await.ok()?; + let id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") + .bind(id) + .bind(format!("pma-test-{}.example", id.simple())) + .execute(&pool) + .await + .ok()?; + Some((pool, CommunityId::from_uuid(id))) + } + + #[tokio::test] + #[ignore = "requires migrated Postgres via BUZZ_TEST_DATABASE_URL"] + async fn aggregate_readback_retry_reactivation_retirement_and_shared_pinning() { + let (pool, community) = postgres_fixture().await.expect("Postgres fixture"); + let owner = Keys::generate(); + let first_agent = Keys::generate(); + let second_agent = Keys::generate(); + let definition_v1 = projection(&owner, 30175, "shared"); + let first_instance = projection(&owner, 30177, &first_agent.public_key().to_hex()); + let first_private = private_head(&owner, &first_agent, 1, None, "active"); + let first = AggregateRequest { + private_event: first_private.clone(), + definition_event: Some(definition_v1.clone()), + instance_event: Some(first_instance.clone()), + expected_definition_revision: Some(1), + }; + let committed = commit_aggregate(&pool, community, &first).await.unwrap(); + assert!(committed.inserted); + assert_eq!(committed.definition_revision, Some(1)); + assert_eq!(committed.private_event, first_private); + assert_eq!(committed.definition_event, Some(definition_v1.clone())); + + let retried = commit_aggregate(&pool, community, &first).await.unwrap(); + assert!(!retried.inserted); + assert_eq!(retried.private_event, committed.private_event); + assert_eq!(retried.definition_event, committed.definition_event); + assert_eq!(retried.instance_event, committed.instance_event); + let changed_binding = AggregateRequest { + private_event: first.private_event.clone(), + definition_event: Some(projection(&owner, 30175, "different-definition")), + instance_event: first.instance_event.clone(), + expected_definition_revision: first.expected_definition_revision, + }; + assert!(matches!( + commit_aggregate(&pool, community, &changed_binding).await, + Err(DbError::ManagedAgentConflict(message)) + if message.contains("changed projection bindings") + )); + + // A previously soft-deleted exact projection is made live again. + sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(definition_v1.id.to_bytes()) + .execute(&pool) + .await + .unwrap(); + let first_v2 = AggregateRequest { + private_event: private_head(&owner, &first_agent, 2, Some(&first_private.id), "active"), + definition_event: Some(definition_v1.clone()), + instance_event: Some(first_instance), + expected_definition_revision: Some(1), + }; + commit_aggregate(&pool, community, &first_v2).await.unwrap(); + let definition_live: bool = sqlx::query_scalar( + "SELECT deleted_at IS NULL FROM events WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(definition_v1.id.to_bytes()) + .fetch_one(&pool) + .await + .unwrap(); + assert!(definition_live); + let first_private_live: bool = sqlx::query_scalar( + "SELECT deleted_at IS NULL FROM events WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(first_private.id.to_bytes()) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!first_private_live); + + // Advancing a shared definition for another agent does not invalidate + // the first agent's pinned immutable revision bytes. + let definition_v2 = EventBuilder::new(Kind::Custom(30175), "projection-v2") + .tag(Tag::parse(["d", "shared"]).unwrap()) + .sign_with_keys(&owner) + .unwrap(); + let second = AggregateRequest { + private_event: private_head(&owner, &second_agent, 1, None, "active"), + definition_event: Some(definition_v2.clone()), + instance_event: Some(projection( + &owner, + 30177, + &second_agent.public_key().to_hex(), + )), + expected_definition_revision: Some(2), + }; + let second_commit = commit_aggregate(&pool, community, &second).await.unwrap(); + assert_eq!(second_commit.definition_revision, Some(2)); + let pinned: serde_json::Value = sqlx::query_scalar("SELECT definition_event FROM managed_agent_revisions WHERE community_id=$1 AND owner_pubkey=$2 AND agent_pubkey=$3 AND generation=2") + .bind(community.as_uuid()).bind(owner.public_key().to_bytes()).bind(first_agent.public_key().to_bytes()).fetch_one(&pool).await.unwrap(); + assert_eq!( + decode_event(pinned, "pinned definition").unwrap(), + definition_v1 + ); + + // Two distinct candidates that both predict current+1 cannot both + // commit. The loser observes the advanced definition under the lock and + // gets a conflict before its own managed-agent generation is written. + let race_agent_a = Keys::generate(); + let race_agent_b = Keys::generate(); + let race_a = AggregateRequest { + private_event: private_head(&owner, &race_agent_a, 1, None, "active"), + definition_event: Some( + EventBuilder::new(Kind::Custom(30175), "race-a") + .tag(Tag::parse(["d", "shared"]).unwrap()) + .sign_with_keys(&owner) + .unwrap(), + ), + instance_event: Some(projection( + &owner, + 30177, + &race_agent_a.public_key().to_hex(), + )), + expected_definition_revision: Some(3), + }; + let race_b = AggregateRequest { + private_event: private_head(&owner, &race_agent_b, 1, None, "active"), + definition_event: Some( + EventBuilder::new(Kind::Custom(30175), "race-b") + .tag(Tag::parse(["d", "shared"]).unwrap()) + .sign_with_keys(&owner) + .unwrap(), + ), + instance_event: Some(projection( + &owner, + 30177, + &race_agent_b.public_key().to_hex(), + )), + expected_definition_revision: Some(3), + }; + let (result_a, result_b) = tokio::join!( + commit_aggregate(&pool, community, &race_a), + commit_aggregate(&pool, community, &race_b) + ); + assert_eq!( + usize::from(result_a.is_ok()) + usize::from(result_b.is_ok()), + 1 + ); + let conflict = if let Err(error) = result_a { + error + } else { + result_b.unwrap_err() + }; + assert!(matches!( + conflict, + DbError::ManagedAgentConflict(message) + if message.contains("expected definition revision") + )); + let race_heads: i64 = sqlx::query_scalar("SELECT count(*) FROM managed_agent_heads WHERE community_id=$1 AND owner_pubkey=$2 AND agent_pubkey IN ($3,$4)") + .bind(community.as_uuid()).bind(owner.public_key().to_bytes()).bind(race_agent_a.public_key().to_bytes()).bind(race_agent_b.public_key().to_bytes()).fetch_one(&pool).await.unwrap(); + assert_eq!(race_heads, 1); + + // A generic instance ingest that starts before the first aggregate + // commits must lose after the aggregate binds the coordinate. Hold the + // aggregate on its definition lock after it has acquired the instance + // replacement lock, then start the generic writer behind it. + let ingest_race_agent = Keys::generate(); + let ingest_race_d = ingest_race_agent.public_key().to_hex(); + let ingest_race_definition = projection(&owner, 30175, "ingest-race-definition"); + let expected_ingest_race_definition_id = ingest_race_definition.id.to_bytes(); + let ingest_race_instance = projection(&owner, 30177, &ingest_race_d); + let expected_ingest_race_instance_id = ingest_race_instance.id.to_bytes(); + let ingest_race_request = AggregateRequest { + private_event: private_head(&owner, &ingest_race_agent, 1, None, "active"), + definition_event: Some(ingest_race_definition), + instance_event: Some(ingest_race_instance.clone()), + expected_definition_revision: Some(1), + }; + let definition_lock = super::super::event_replacement_lock_key( + community, + 30175, + &owner.public_key().to_bytes(), + Some(b"ingest-race-definition"), + ); + let instance_lock = super::super::event_replacement_lock_key( + community, + 30177, + &owner.public_key().to_bytes(), + Some(ingest_race_d.as_bytes()), + ); + let mut blocker = pool.begin().await.unwrap(); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(definition_lock) + .execute(&mut *blocker) + .await + .unwrap(); + + let aggregate_pool = pool.clone(); + let aggregate = tokio::spawn(async move { + commit_aggregate(&aggregate_pool, community, &ingest_race_request).await + }); + let mut aggregate_has_instance_lock = false; + let mut probe = pool.acquire().await.unwrap(); + for _ in 0..1_000 { + let acquired: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)") + .bind(instance_lock) + .fetch_one(&mut *probe) + .await + .unwrap(); + if acquired { + sqlx::query("SELECT pg_advisory_unlock($1)") + .bind(instance_lock) + .execute(&mut *probe) + .await + .unwrap(); + tokio::task::yield_now().await; + } else { + aggregate_has_instance_lock = true; + break; + } + } + drop(probe); + assert!( + aggregate_has_instance_lock, + "aggregate never reached the instance replacement lock" + ); + + let generic_db = crate::Db::from_pool(pool.clone()); + let generic_d = ingest_race_d.clone(); + let generic = tokio::spawn(async move { + generic_db + .replace_parameterized_event(community, &ingest_race_instance, &generic_d, None) + .await + }); + tokio::task::yield_now().await; + blocker.commit().await.unwrap(); + assert!(aggregate.await.unwrap().is_ok()); + assert!(matches!( + generic.await.unwrap(), + Err(DbError::ManagedAgentConflict(message)) + if message.contains("projection is controlled") + )); + let live_instance: Vec = sqlx::query_scalar( + "SELECT id FROM events WHERE community_id=$1 AND kind=30177 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(owner.public_key().to_bytes()) + .bind(&ingest_race_d) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(live_instance, expected_ingest_race_instance_id); + + // Both legacy kind:5 mutation shapes are fenced at the DB boundary: + // a-tags delete by coordinate, while e-tags delete the resolved event ID. + let first_agent_d = first_agent.public_key().to_hex(); + for (kind, d_tag, event_id) in [ + ( + 30175, + "ingest-race-definition", + expected_ingest_race_definition_id.as_slice(), + ), + ( + 30177, + ingest_race_d.as_str(), + expected_ingest_race_instance_id.as_slice(), + ), + ( + buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT as i32, + first_agent_d.as_str(), + first_v2.private_event.id.as_bytes(), + ), + ] { + assert!( + !crate::event::soft_delete_by_coordinate( + &pool, + community, + kind, + &owner.public_key().to_bytes(), + d_tag, + chrono::Utc::now().timestamp() + 60, + ) + .await + .unwrap(), + "a-tag deletion must not retire PMA-bound kind {kind}" + ); + assert!( + !crate::event::soft_delete_event_and_update_thread( + &pool, community, event_id, None, None, + ) + .await + .unwrap(), + "e-tag deletion must not retire PMA-bound kind {kind}" + ); + let live: bool = sqlx::query_scalar( + "SELECT deleted_at IS NULL FROM events WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(event_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(live, "PMA-bound kind {kind} must remain live"); + } + + // Deletion advances the same private CAS chain, returns no public + // bindings, and retires the instance projection. Neither a stale + // pre-tombstone aggregate nor ordinary projection ingest may resurrect + // an authority coordinate after the tombstone commits. + let tombstone_private = private_head( + &owner, + &first_agent, + 3, + Some(&first_v2.private_event.id), + "deleted", + ); + let tombstone = AggregateRequest { + private_event: tombstone_private.clone(), + definition_event: None, + instance_event: None, + expected_definition_revision: None, + }; + let deleted = commit_aggregate(&pool, community, &tombstone) + .await + .unwrap(); + assert!(deleted.inserted); + assert!(!deleted.head.active); + assert_eq!(deleted.head.generation, 3); + assert_eq!(deleted.private_event, tombstone_private); + assert_eq!(deleted.definition_event, None); + assert_eq!(deleted.instance_event, None); + assert_eq!(deleted.definition_revision, None); + + let instance_live: bool = sqlx::query_scalar( + "SELECT deleted_at IS NULL FROM events WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(first_v2.instance_event.as_ref().unwrap().id.to_bytes()) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!instance_live); + assert!( + participation_is_revoked( + &pool, + community, + first_agent.public_key().as_bytes(), + Some(owner.public_key().as_bytes()), + ) + .await + .unwrap(), + "the exact deleted owner-agent coordinate revokes participation" + ); + let foreign_owner = Keys::generate(); + assert!( + participation_is_revoked( + &pool, + community, + first_agent.public_key().as_bytes(), + Some(foreign_owner.public_key().as_bytes()), + ) + .await + .unwrap(), + "foreign delegation evidence cannot bypass the aggregate's durable owner binding" + ); + assert!( + participation_is_revoked(&pool, community, first_agent.public_key().as_bytes(), None,) + .await + .unwrap(), + "the aggregate transaction materializes durable revocation authority without request delegation evidence" + ); + let materialized_owner: Option> = sqlx::query_scalar( + "SELECT agent_owner_pubkey FROM users WHERE community_id=$1 AND pubkey=$2", + ) + .bind(community.as_uuid()) + .bind(first_agent.public_key().as_bytes()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + materialized_owner.as_deref(), + Some(owner.public_key().as_bytes().as_slice()), + "aggregate commit must bind a direct member to its owner atomically" + ); + + // Brownfield repair: a head committed by an older relay may predate the + // aggregate-owned binding. Replaying the exact tombstone must restore + // revocation authority even though the head itself is an idempotent + // no-op. + sqlx::query("UPDATE users SET agent_owner_pubkey=NULL WHERE community_id=$1 AND pubkey=$2") + .bind(community.as_uuid()) + .bind(first_agent.public_key().as_bytes()) + .execute(&pool) + .await + .unwrap(); + assert!( + !participation_is_revoked(&pool, community, first_agent.public_key().as_bytes(), None) + .await + .unwrap(), + "the fixture must reproduce the old no-binding revocation gap" + ); + let repaired = commit_aggregate(&pool, community, &tombstone) + .await + .unwrap(); + assert!(!repaired.inserted); + assert!( + participation_is_revoked(&pool, community, first_agent.public_key().as_bytes(), None) + .await + .unwrap(), + "an exact aggregate retry must repair missing durable revocation authority" + ); + assert!( + !participation_is_revoked( + &pool, + community, + second_agent.public_key().as_bytes(), + Some(owner.public_key().as_bytes()), + ) + .await + .unwrap(), + "an active head does not revoke participation" + ); + let other_community = postgres_fixture().await.unwrap().1; + assert!( + !participation_is_revoked( + &pool, + other_community, + first_agent.public_key().as_bytes(), + Some(owner.public_key().as_bytes()), + ) + .await + .unwrap(), + "a tombstone in another community does not revoke participation" + ); + assert!( + !participation_is_revoked( + &pool, + community, + Keys::generate().public_key().as_bytes(), + Some(owner.public_key().as_bytes()), + ) + .await + .unwrap(), + "an absent head does not revoke participation" + ); + assert!( + projection_is_authoritative( + &pool, + community, + first_v2.instance_event.as_ref().unwrap(), + &first_agent.public_key().to_hex(), + ) + .await + .unwrap(), + "deleted heads retain the authority fence" + ); + + let stale_resurrection = AggregateRequest { + private_event: private_head( + &owner, + &first_agent, + 3, + Some(&first_v2.private_event.id), + "active", + ), + definition_event: Some(definition_v1), + instance_event: first_v2.instance_event, + expected_definition_revision: Some(1), + }; + assert!(matches!( + commit_aggregate(&pool, community, &stale_resurrection).await, + Err(DbError::ManagedAgentConflict(message)) + if message.contains("predecessor or generation conflict") + )); + let head = read_head( + &pool, + community, + &owner.public_key().to_bytes(), + &first_agent.public_key().to_bytes(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(head.generation, 3); + assert!(!head.active); + } + + #[test] + fn projection_rejects_ambiguous_d_tags() { + let owner = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(30175), "projection") + .tags([ + Tag::parse(["d", "one"]).unwrap(), + Tag::parse(["d", "two"]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + assert!(validate_projection(&event, &owner.public_key(), 30175).is_err()); + } +} diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 37f54d0fa2..f9297330a4 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -561,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 28); + assert_eq!(migrations.len(), 29); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -940,12 +940,28 @@ mod tests { "desired-state schema must carry the channel-id lookup index", ); + // Long reactions expand to the normalized shortcode ceiling. assert_eq!(migrations[27].version, 28); let long_reactions = migrations[27].sql.as_str(); assert!( long_reactions.contains("ALTER TABLE reactions ALTER COLUMN emoji TYPE VARCHAR(66)") ); assert!(desired_schema.contains("emoji VARCHAR(66) NOT NULL")); + + // Private managed-agent ciphertext is author-only and must remain + // unsearchable on brownfield databases without changing 0001's sqlx + // checksum. Migration 0029 also installs the relay authority tables. + assert_eq!(migrations[28].version, 29); + let private_managed_agent = migrations[28].sql.as_str(); + assert!(private_managed_agent.contains("CREATE TABLE managed_agent_heads")); + assert!(private_managed_agent.contains("CREATE TABLE managed_agent_revisions")); + assert!(private_managed_agent.contains("kind = 30179")); + assert!(private_managed_agent.contains("pg_get_expr")); + assert!(private_managed_agent.contains("search_tsv")); + assert!(desired_schema.contains("CREATE TABLE managed_agent_heads")); + assert!(desired_schema.contains("CREATE TABLE managed_agent_revisions")); + assert!(desired_schema.contains("30179")); + assert!(!migrations[0].sql.as_str().contains("30179")); } #[test] diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453f..2eb838badc 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -21,7 +21,7 @@ use crate::state::AppState; use super::{api_error, internal_error, not_found}; -async fn enforce_http_admission( +pub(crate) async fn enforce_http_admission( state: &AppState, tenant: &TenantContext, pubkey: &nostr::PublicKey, @@ -824,12 +824,19 @@ async fn submit_event_authed( } }; if let Some(owner) = nip_oa_owner { - super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; + if !super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await { + let e = internal_error("verified NIP-OA owner could not be materialized"); + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } } let kind_u32 = buzz_core::kind::event_kind_u32(&event); let auth = IngestAuth::Http { pubkey, + agent_owner_pubkey: nip_oa_owner, scopes: buzz_auth::Scope::all_known(), // Pure Nostr: full scopes, channel access via membership auth_method: crate::handlers::ingest::HttpAuthMethod::Nip98, }; diff --git a/crates/buzz-relay/src/api/managed_agents.rs b/crates/buzz-relay/src/api/managed_agents.rs new file mode 100644 index 0000000000..f18eadcf35 --- /dev/null +++ b/crates/buzz-relay/src/api/managed_agents.rs @@ -0,0 +1,155 @@ +//! Dedicated NIP-98 submission boundary for NIP-PMA aggregates. + +use std::sync::Arc; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use serde::Deserialize; +use serde_json::Value; + +use super::{api_error, internal_error}; +use crate::state::AppState; + +/// Maximum aggregate request body. The encrypted payload and two signed +/// projections fit below this cap while preventing unbounded JSON allocation. +const MAX_AGGREGATE_BODY_BYTES: usize = 256 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AggregateBody { + private_event: nostr::Event, + #[serde(default)] + definition_event: Option, + #[serde(default)] + instance_event: Option, + #[serde(default)] + expected_definition_revision: Option, +} + +/// Atomically install an encrypted PMA head and exact public projections. +pub async fn submit_aggregate( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + if body.len() > MAX_AGGREGATE_BODY_BYTES { + return Err(api_error( + StatusCode::PAYLOAD_TOO_LARGE, + "aggregate body too large", + )); + } + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + let url = super::bridge::nip98_expected_url( + &state.config.relay_url, + &tenant, + "/api/managed-agents/aggregate", + ); + let (owner, replay_id) = super::bridge::verify_bridge_auth_with_options( + &headers, + "POST", + &url, + Some(&body), + state.config.require_auth_token, + true, + )?; + super::bridge::enforce_http_admission(&state, &tenant, &owner).await?; + super::bridge::check_nip98_replay(&state, &tenant, replay_id).await?; + super::relay_members::enforce_relay_membership( + &state, + tenant.community(), + &owner.to_bytes(), + None, + ) + .await?; + let parsed: AggregateBody = serde_json::from_slice(&body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid aggregate JSON"))?; + if parsed.private_event.pubkey != owner { + return Err(api_error( + StatusCode::FORBIDDEN, + "aggregate author must be authenticated owner", + )); + } + let request = buzz_db::managed_agent::AggregateRequest { + private_event: parsed.private_event, + definition_event: parsed.definition_event, + instance_event: parsed.instance_event, + expected_definition_revision: parsed.expected_definition_revision, + }; + let commit = state + .db + .commit_managed_agent_aggregate(tenant.community(), &request) + .await + .map_err(|e| match e { + buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message), + buzz_db::DbError::ManagedAgentConflict(message) => { + api_error(StatusCode::CONFLICT, &message) + } + _ => internal_error(&format!("managed-agent aggregate commit failed: {e}")), + })?; + if !commit.head.active { + state + .disconnect_pubkey_clusterwide_awaited( + &tenant, + &commit.head.agent_pubkey, + &hex::encode(&commit.head.event_id), + "blocked: managed agent has been deleted", + ) + .await + .map_err(|e| { + tracing::error!(error = %e, "managed-agent disconnect publication failed after durable tombstone commit"); + api_error( + StatusCode::SERVICE_UNAVAILABLE, + "managed-agent deletion committed; disconnect propagation failed, retry request", + ) + })?; + } + let owner_hex = owner.to_hex(); + let retry_private_event; + let dispatch_events: &[buzz_core::StoredEvent] = if commit.inserted { + &commit.events + } else if !commit.head.active { + // A tombstone may have committed before disconnect propagation failed. + // Its exact retry must fan the durable head out again after repairing + // that propagation; otherwise live Desktop sessions never learn it. + retry_private_event = buzz_core::StoredEvent::new(commit.private_event.clone(), None); + std::slice::from_ref(&retry_private_event) + } else { + &[] + }; + for stored in dispatch_events { + crate::handlers::event::dispatch_persistent_event( + &tenant, + &state, + stored, + buzz_core::kind::event_kind_u32(&stored.event), + &owner_hex, + None, + ) + .await; + } + Ok(Json(serde_json::json!({ + "event_id": hex::encode(&commit.head.event_id), + "generation": commit.head.generation, + "state": if commit.head.active { "active" } else { "deleted" }, + "accepted": true, + "inserted": commit.inserted, + "definition_revision": commit.definition_revision, + "private_event": commit.private_event, + "definition_event": commit.definition_event, + "instance_event": commit.instance_event + }))) +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index d9f829433b..2137888d36 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -5,6 +5,7 @@ pub mod bridge; pub mod events; pub mod git; pub mod invites; +pub mod managed_agents; pub mod media; pub mod mesh_demo; pub mod nip05; @@ -46,14 +47,37 @@ pub mod relay_members { pub enum MembershipDecision { /// Relay membership enforcement is disabled. OpenRelay, - /// Caller is directly present in `relay_members`. - Member, + /// Caller is directly present in `relay_members`. A verified owner is + /// retained for revocation rechecks and durable owner materialization; + /// it did not grant this membership. + Member(Option), /// Caller is admitted through a NIP-OA owner that is a relay member. ViaOwner(nostr::PublicKey), /// Caller is not admitted. Denied, } + fn verified_nip_oa_owner( + pubkey_bytes: &[u8], + auth_tag_header: Option<&str>, + ) -> Option { + let tag_json = auth_tag_header?; + let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).ok()?; + match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { + Ok(owner) => Some(owner), + Err(e) => { + info!(agent = %agent_pubkey.to_hex(), "NIP-OA auth tag invalid: {e}"); + None + } + } + } + + fn open_relay_decision(verified_owner: Option) -> MembershipDecision { + verified_owner + .map(MembershipDecision::ViaOwner) + .unwrap_or(MembershipDecision::OpenRelay) + } + /// Check relay membership without committing to an HTTP response shape. /// /// `community` is the server-resolved tenant of the request; membership is @@ -64,45 +88,54 @@ pub mod relay_members { pubkey_bytes: &[u8], auth_tag_header: Option<&str>, ) -> Result { + let pubkey_hex = hex::encode(pubkey_bytes); + // Verify owner evidence independently of whether NIP-OA may grant + // closed-relay membership. A direct member can still be a managed + // agent, and its proven owner is required to enforce a PMA tombstone. + let verified_owner = verified_nip_oa_owner(pubkey_bytes, auth_tag_header); + let revoked = state + .db + .managed_agent_participation_is_revoked( + community, + pubkey_bytes, + verified_owner + .as_ref() + .map(|owner| owner.as_bytes().as_slice()), + ) + .await + .map_err(|e| format!("managed-agent participation check failed: {e}"))?; + if revoked { + return Ok(MembershipDecision::Denied); + } + if !state.config.require_relay_membership { - return Ok(MembershipDecision::OpenRelay); + return Ok(open_relay_decision(verified_owner)); } - let pubkey_hex = hex::encode(pubkey_bytes); let is_member = state .db .is_relay_member(community, &pubkey_hex) .await .map_err(|e| format!("relay membership check failed: {e}"))?; if is_member { - return Ok(MembershipDecision::Member); + return Ok(MembershipDecision::Member(verified_owner)); } if state.config.allow_nip_oa_auth { - if let Some(tag_json) = auth_tag_header { - let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes) - .map_err(|e| format!("invalid agent pubkey for NIP-OA check: {e}"))?; - - match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { - Ok(owner_pubkey) => { - let owner_hex = owner_pubkey.to_hex(); - let owner_is_member = state - .db - .is_relay_member(community, &owner_hex) - .await - .map_err(|e| format!("relay membership check (owner) failed: {e}"))?; - if owner_is_member { - debug!( - agent = %pubkey_hex, - owner = %owner_hex, - "NIP-OA membership granted via owner" - ); - return Ok(MembershipDecision::ViaOwner(owner_pubkey)); - } - } - Err(e) => { - info!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}"); - } + if let Some(owner_pubkey) = verified_owner { + let owner_hex = owner_pubkey.to_hex(); + let owner_is_member = state + .db + .is_relay_member(community, &owner_hex) + .await + .map_err(|e| format!("relay membership check (owner) failed: {e}"))?; + if owner_is_member { + debug!( + agent = %pubkey_hex, + owner = %owner_hex, + "NIP-OA membership granted via owner" + ); + return Ok(MembershipDecision::ViaOwner(owner_pubkey)); } } } @@ -112,15 +145,15 @@ pub mod relay_members { /// Enforce relay membership for a pubkey, with NIP-OA agent delegation fallback. /// - /// Returns `Ok(Some(owner_pubkey))` when the agent is not a direct member but - /// its NIP-OA owner *is* — access is granted via delegation. + /// Returns `Ok(Some(owner_pubkey))` whenever the caller supplies verified + /// NIP-OA owner evidence, including when the caller is itself a direct + /// member. The feature flag controls only whether that evidence can grant + /// admission to a non-member on a closed relay. /// - /// On open relays (`require_relay_membership = false`), returns `Ok(None)` - /// immediately — no membership check is performed. Callers that need NIP-OA - /// owner extraction on open relays should call [`extract_nip_oa_owner`] directly. + /// On open relays (`require_relay_membership = false`), owner evidence is + /// likewise returned unconditionally for durable backfill. /// - /// Returns `Ok(None)` when the caller is a direct member (closed relay) or when - /// no NIP-OA tag is present/applicable (open relay without auth tag). + /// Returns `Ok(None)` only when no valid NIP-OA tag is present. pub async fn enforce_relay_membership( state: &AppState, community: CommunityId, @@ -128,7 +161,8 @@ pub mod relay_members { auth_tag_header: Option<&str>, ) -> Result, (StatusCode, Json)> { match check_relay_membership(state, community, pubkey_bytes, auth_tag_header).await { - Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None), + Ok(MembershipDecision::OpenRelay) => Ok(None), + Ok(MembershipDecision::Member(owner)) => Ok(owner), Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)), Ok(MembershipDecision::Denied) => Err(( StatusCode::FORBIDDEN, @@ -237,6 +271,31 @@ pub mod relay_members { use buzz_sdk::nip_oa::compute_auth_tag; use nostr::Keys; + #[test] + fn owner_evidence_is_verified_independently_of_membership_grant_policy() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let tag = compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + assert_eq!( + verified_nip_oa_owner(agent.public_key().as_bytes(), Some(&tag)), + Some(owner.public_key()) + ); + assert_eq!( + verified_nip_oa_owner(agent.public_key().as_bytes(), Some("invalid")), + None + ); + } + + #[test] + fn open_relay_extracts_owner_regardless_of_closed_relay_feature_flag() { + let owner = Keys::generate().public_key(); + assert_eq!( + open_relay_decision(Some(owner)), + MembershipDecision::ViaOwner(owner) + ); + assert_eq!(open_relay_decision(None), MembershipDecision::OpenRelay); + } + /// Valid NIP-OA auth tag → returns Some(owner_pubkey). #[test] fn valid_nip_oa_returns_owner() { diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 72a7eb9126..2a02f47023 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -616,23 +616,66 @@ fn request_rejection_message(sub_id: Option<&str>, reason: &str) -> String { } } +fn requires_participation_revalidation(msg: &ClientMessage) -> bool { + matches!( + msg, + ClientMessage::Req { .. } | ClientMessage::Count { .. } | ClientMessage::Event(_) + ) +} + async fn enforce_ws_admission( msg: &ClientMessage, conn: &ConnectionState, state: &AppState, ) -> bool { let is_event = matches!(msg, ClientMessage::Event(_)); - if !is_event && !matches!(msg, ClientMessage::Req { .. } | ClientMessage::Count { .. }) { + if !requires_participation_revalidation(msg) { return true; } - let (pubkey, is_agent) = { + let (pubkey, owner) = { let auth = conn.auth_state.read().await; match &*auth { - AuthState::Authenticated(ctx) => (ctx.pubkey, ctx.agent_owner_pubkey.is_some()), + AuthState::Authenticated(ctx) => (ctx.pubkey, ctx.agent_owner_pubkey), _ => return true, } }; + let is_agent = owner.is_some(); + + match state + .db + .managed_agent_participation_is_revoked( + conn.tenant.community(), + pubkey.as_bytes(), + owner.as_ref().map(|owner| owner.as_bytes().as_slice()), + ) + .await + { + Ok(false) => {} + Ok(true) => { + conn.send(request_rejection_message( + match msg { + ClientMessage::Req { sub_id, .. } => Some(sub_id.as_str()), + _ => None, + }, + "blocked: managed agent has been deleted", + )); + conn.cancel.cancel(); + return false; + } + Err(error) => { + warn!(conn_id = %conn.conn_id, %error, "managed-agent participation revalidation failed closed"); + conn.send(request_rejection_message( + match msg { + ClientMessage::Req { sub_id, .. } => Some(sub_id.as_str()), + _ => None, + }, + "error: managed-agent participation unavailable", + )); + conn.cancel.cancel(); + return false; + } + } let limits = &state.auth.config().rate_limits; let (ws_window_secs, ws_limit) = @@ -797,6 +840,35 @@ mod tests { .collect() } + #[test] + fn managed_agent_revocation_revalidates_req_and_all_event_storage_classes() { + use nostr::{EventBuilder, Filter, Keys, Kind}; + + let signed = |kind| { + EventBuilder::new(kind, "") + .sign_with_keys(&Keys::generate()) + .expect("sign event") + }; + let cases = [ + ClientMessage::Req { + sub_id: "history".into(), + filters: vec![Filter::new()], + }, + ClientMessage::Event(signed(Kind::TextNote)), + ClientMessage::Event(signed(Kind::Custom(20_001))), + ]; + + for case in &cases { + assert!( + requires_participation_revalidation(case), + "REQ, persistent EVENT, and ephemeral EVENT must all revalidate durable revocation" + ); + } + assert!(!requires_participation_revalidation(&ClientMessage::Close( + "history".into() + ))); + } + #[test] fn req_rejections_are_subscription_scoped() { let reason = "rate-limited: too many concurrent requests"; diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e..6e2dfced44 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -1,10 +1,11 @@ //! NIP-42 AUTH handler — verify challenge response, transition auth state. //! //! Relay membership enforcement uses the shared -//! [`crate::api::relay_members::enforce_relay_membership`] helper, which supports -//! NIP-OA owner-delegation fallback on closed relays. On open relays, the auth -//! handler calls [`crate::api::relay_members::extract_nip_oa_owner`] directly to -//! extract the owner pubkey for agent→owner backfill (observer frame auth). +//! [`crate::api::relay_members::enforce_relay_membership`] helper. NIP-OA +//! owner evidence is returned by the shared gate whenever it verifies, while +//! the feature flag controls only whether that evidence may grant closed-relay +//! membership. The relationship is then durably materialized for later +//! transport-neutral revocation checks. //! //! For WebSocket auth, the NIP-OA `auth` tag is extracted from the signed AUTH //! event itself (the tag is integrity-protected by the event signature). @@ -237,21 +238,10 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }; - // Open relay NIP-OA backfill: extract owner for agent→owner DB mapping - // (needed for observer frame auth). Only runs on open relays — on closed - // relays, enforce_relay_membership already handles NIP-OA delegation. - // No feature flag needed: NIP-OA is cryptographically self-proving. - let nip_oa_owner = nip_oa_owner.or_else(|| { - if !state.config.require_relay_membership && auth_tag_json.is_some() { - crate::api::relay_members::extract_nip_oa_owner( - pubkey.as_bytes(), - auth_tag_json.as_deref(), - ) - } else { - None - } - }); - + // A verified owner is returned for both direct members and + // delegated admissions. The feature flag controls only whether the + // latter can grant access; owner evidence remains revocation + // authority in either case. // Stash NIP-OA owner on the auth context only after the shared // backfill confirms the first-write-wins relationship. if let Some(owner) = nip_oa_owner { @@ -271,6 +261,13 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: nip_oa_owner = %owner.to_hex(), "NIP-OA owner could not be materialized" ); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "error: authentication state could not be persisted", + )); + return; } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a67797385b..abd7ef9937 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -631,13 +631,14 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc ( conn.conn_id, ctx.pubkey.to_bytes().to_vec(), ctx.pubkey, + ctx.agent_owner_pubkey, ctx.scopes.clone(), ctx.channel_ids.clone(), ), @@ -720,6 +721,7 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, /// Permission scopes granted to this connection. scopes: Vec, /// Token-level channel restriction, if the WebSocket auth used an API token. @@ -125,6 +127,8 @@ pub enum IngestAuth { Http { /// The authenticated Nostr public key. pubkey: nostr::PublicKey, + /// Cryptographically proven and materialized NIP-OA owner, if any. + agent_owner_pubkey: Option, /// Permission scopes granted to this request. scopes: Vec, /// How the HTTP request was authenticated. @@ -145,6 +149,18 @@ impl IngestAuth { self.pubkey().to_bytes().to_vec() } + /// Cryptographically proven and materialized NIP-OA owner, if any. + pub fn agent_owner_pubkey(&self) -> Option<&nostr::PublicKey> { + match self { + Self::Nip42 { + agent_owner_pubkey, .. + } + | Self::Http { + agent_owner_pubkey, .. + } => agent_owner_pubkey.as_ref(), + } + } + /// Permission scopes for this auth context. pub fn scopes(&self) -> &[Scope] { match self { @@ -1957,6 +1973,32 @@ async fn ingest_event_inner( ))); } + // Participation revocation is durable authority, not a pub/sub side effect. + // Recheck every write so a socket that missed the tombstone disconnect + // cannot continue mutating relay state. + match state + .db + .managed_agent_participation_is_revoked( + tenant.community(), + auth.pubkey().as_bytes(), + auth.agent_owner_pubkey() + .map(|owner| owner.as_bytes().as_slice()), + ) + .await + { + Ok(true) => { + return Err(IngestError::AuthFailed( + "blocked: managed agent has been deleted".to_string(), + )); + } + Ok(false) => {} + Err(e) => { + return Err(IngestError::Internal(format!( + "error: internal error checking managed-agent participation: {e}" + ))); + } + } + // Command kinds are routed AFTER signature verification, timestamp check, // pubkey/auth match, and scope validation — never before. if buzz_core::kind::is_command_kind(kind_u32) { @@ -2802,6 +2844,20 @@ async fn ingest_event_inner( }); } + if matches!(kind_u32, KIND_PERSONA | KIND_MANAGED_AGENT) { + let d_tag = buzz_db::event::extract_d_tag(&event).unwrap_or_default(); + if state + .db + .managed_agent_projection_is_authoritative(tenant.community(), &event, &d_tag) + .await + .map_err(|e| IngestError::Internal(format!("error: PMA authority check failed: {e}")))? + { + return Err(IngestError::Rejected( + "restricted: projection is controlled by a private managed-agent aggregate".into(), + )); + } + } + let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. @@ -3117,6 +3173,7 @@ mod tests { .expect("sign feedback"); let auth = IngestAuth::Http { pubkey: keys.public_key(), + agent_owner_pubkey: None, scopes: vec![Scope::MessagesWrite], auth_method: HttpAuthMethod::Nip98, }; @@ -3531,6 +3588,7 @@ mod tests { let envelope_signer = nostr::Keys::generate(); let auth = IngestAuth::Nip42 { pubkey: principal.public_key(), + agent_owner_pubkey: None, scopes: vec![], channel_ids: None, conn_id: Uuid::new_v4(), @@ -3549,6 +3607,7 @@ mod tests { let keys = nostr::Keys::generate(); let http_auth = IngestAuth::Http { pubkey: keys.public_key(), + agent_owner_pubkey: None, scopes: vec![], auth_method: HttpAuthMethod::Nip98, }; @@ -3564,6 +3623,7 @@ mod tests { let keys = nostr::Keys::generate(); let ws_auth = IngestAuth::Nip42 { pubkey: keys.public_key(), + agent_owner_pubkey: None, scopes: vec![], channel_ids: None, conn_id: uuid::Uuid::new_v4(), diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index fd7deadf51..9d46c63b90 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -264,7 +264,7 @@ pub async fn handle_req( let mut total_sent: usize = 0; // Phase 1 — pure query construction, in filter order. - let filter_queries: Vec<(usize, Option, EventQuery)> = filters + let filter_queries: Vec<(usize, Option, EventQuery, bool)> = filters .iter() .enumerate() .map(|(idx, filter)| { @@ -289,13 +289,24 @@ pub async fn handle_req( let mut params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community()); apply_access_scope_to_query(&mut params, per_filter_channel, &accessible_channels); + // Private visibility pushdown must happen before ORDER/LIMIT. This + // applies even to mixed-kind and kindless filters; per-event checks + // remain defense-in-depth for id lookups and future call paths. + if filter_can_match_author_only_kinds(filter) { + params.author_only_reader = Some(pubkey_bytes.clone()); + } // Shared-gated visibility pushdown: set reader bytes so query_events // appends the SQL visibility clause before ORDER/LIMIT, preventing // newer private events from starving older shared ones off the page. if filter_can_match_shared_gated_kinds(filter) { params.shared_gated_reader = Some(pubkey_bytes.clone()); } - (idx, per_filter_channel, params) + ( + idx, + per_filter_channel, + params, + filter_requires_writer(filter), + ) }) .collect(); @@ -306,10 +317,14 @@ pub async fn handle_req( use futures_util::stream::{self, StreamExt}; let db = state.db.clone(); let mut results = stream::iter(filter_queries.into_iter().map( - |(idx, per_filter_channel, params)| { + |(idx, per_filter_channel, params, requires_writer)| { let db = db.clone(); async move { - let filter_events = db.query_events_routed("req_historical", ¶ms).await; + let filter_events = if requires_writer { + db.query_events(¶ms).await + } else { + db.query_events_routed("req_historical", ¶ms).await + }; (idx, per_filter_channel, filter_events) } }, @@ -752,12 +767,16 @@ async fn handle_search_req( /// Resolves accessible channels for the given pubkey and builds the query. pub async fn build_event_query_from_filter( filter: &Filter, - _pubkey_bytes: &[u8], + pubkey_bytes: &[u8], _state: &AppState, community: buzz_core::tenant::CommunityId, ) -> EventQuery { let channel_id = extract_channel_id_from_filter(filter); - filter_to_query_params(filter, channel_id, community) + let mut query = filter_to_query_params(filter, channel_id, community); + if filter_can_match_author_only_kinds(filter) { + query.author_only_reader = Some(pubkey_bytes.to_vec()); + } + query } /// Maximum SQL candidate rows a non-pushable COUNT filter may inspect before @@ -1150,6 +1169,13 @@ pub(crate) fn engram_filters_authorized(filters: &[Filter], authed_pubkey_hex: & /// Used by the COUNT handler to force the fallback path (per-event filtering) /// instead of the fast `count_events()` which cannot exclude other authors' /// author-only events from the aggregate count. +fn filter_requires_writer(filter: &Filter) -> bool { + filter + .kinds + .as_ref() + .is_some_and(|kinds| !kinds.is_empty() && kinds.iter().all(|kind| kind.as_u16() == 30179)) +} + pub(crate) fn filter_can_match_author_only_kinds(filter: &Filter) -> bool { filter.kinds.as_ref().is_none_or(|ks| { ks.iter() @@ -1846,6 +1872,23 @@ mod tests { assert!(filter_can_match_author_only_kinds(&own)); } + #[test] + fn private_managed_agent_filters_always_receive_author_only_pushdown() { + let explicit = Filter::new().kind(nostr::Kind::Custom(30_179)); + let mixed = Filter::new().kinds([nostr::Kind::TextNote, nostr::Kind::Custom(30_179)]); + let kindless = Filter::new().id(nostr::EventId::from_hex(&"11".repeat(32)).unwrap()); + let public_only = Filter::new().kind(nostr::Kind::TextNote); + + assert!(filter_can_match_author_only_kinds(&explicit)); + assert!(filter_can_match_author_only_kinds(&mixed)); + assert!(filter_can_match_author_only_kinds(&kindless)); + assert!(!filter_can_match_author_only_kinds(&public_only)); + assert!(filter_requires_writer(&explicit)); + assert!(!filter_requires_writer(&mixed)); + assert!(!filter_requires_writer(&kindless)); + assert!(!filter_requires_writer(&public_only)); + } + #[test] fn mixed_filter_omits_another_authors_push_lease() { let owner_keys = nostr::Keys::generate(); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 98f8a9aa84..4bb1f7ccc7 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2165,6 +2165,31 @@ async fn handle_a_tag_deletion( )); } }; + // PMA-bound projections may only be retired by advancing the + // private aggregate generation. A legacy kind:5 is retained as an + // offline compatibility signal but cannot mutate canonical state. + if k == buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT + || (matches!( + k, + buzz_core::kind::KIND_PERSONA | buzz_core::kind::KIND_MANAGED_AGENT + ) && state + .db + .managed_agent_projection_coordinate_is_authoritative( + tenant.community(), + k, + &pubkey_bytes, + d_tag, + ) + .await + .map_err(|e| anyhow::anyhow!("PMA deletion fence failed: {e}"))?) + { + tracing::debug!( + kind = k, + d_tag, + "NIP-09 deletion ignored for PMA projection" + ); + return Ok(()); + } // Safe cast: NIP-33 kinds are 30000–39999, well within i32. let kind_i32 = k as i32; // NIP-09 scopes an a-tag deletion to versions at or before the @@ -2233,13 +2258,45 @@ async fn handle_standard_deletion_event( Some(target) => target, None => continue, }; - if u32::from(target_event.event.kind.as_u16()) == super::push_lease::KIND_PUSH_LEASE { + let target_kind = u32::from(target_event.event.kind.as_u16()); + if target_kind == super::push_lease::KIND_PUSH_LEASE { tracing::debug!( target_id = %hex::encode(&target_id), "NIP-09 deletion ignored for push lease" ); continue; } + if matches!( + target_kind, + buzz_core::kind::KIND_PERSONA | buzz_core::kind::KIND_MANAGED_AGENT + ) { + let d_tag = buzz_db::event::extract_d_tag(&target_event.event).unwrap_or_default(); + if state + .db + .managed_agent_projection_coordinate_is_authoritative( + tenant.community(), + target_kind, + &target_event.event.pubkey.to_bytes(), + &d_tag, + ) + .await? + { + tracing::debug!( + target_id = %hex::encode(&target_id), + kind = target_kind, + d_tag, + "NIP-09 deletion ignored for PMA projection" + ); + continue; + } + } + if target_kind == buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT { + tracing::debug!( + target_id = %hex::encode(&target_id), + "NIP-09 deletion ignored for private managed-agent head" + ); + continue; + } let meta = state .db diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 2575ddd7ba..0d6531045e 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -162,7 +162,10 @@ impl RelayInfo { pubkey: None, contact: None, supported_nips, - supported_extensions: Some(vec!["nip-er".to_string()]), + supported_extensions: Some(vec![ + "nip-er".to_string(), + "nip-pma-aggregate-v1".to_string(), + ]), push: None, software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), @@ -389,6 +392,17 @@ mod tests { ); } + #[test] + fn build_advertises_private_managed_agent_aggregate_v1() { + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + assert!(info + .supported_extensions + .as_ref() + .is_some_and(|extensions| extensions + .iter() + .any(|extension| extension == "nip-pma-aggregate-v1"))); + } + #[test] fn build_advertises_buzz_repository_url() { let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe3..da4ef19977 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -70,6 +70,10 @@ pub fn build_router(state: Arc) -> Router { .route("/_readiness", get(readiness_handler)) // Nostr HTTP bridge (NIP-98 auth) .route("/events", post(api::bridge::submit_event)) + .route( + "/api/managed-agents/aggregate", + post(api::managed_agents::submit_aggregate), + ) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) .route( diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 14a50df7b7..df10ea3ae9 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1142,6 +1142,33 @@ impl AppState { closed } + /// Disconnect a pubkey locally and await publication to every relay pod. + /// + /// PMA tombstone APIs use this stricter variant so a committed deletion can + /// return retryably when propagation fails; an exact retry republishes. + pub async fn disconnect_pubkey_clusterwide_awaited( + &self, + tenant: &TenantContext, + pubkey: &[u8], + event_id: &str, + reason: &str, + ) -> Result { + let closed = + self.conn_manager + .disconnect_pubkey(tenant.community(), pubkey, event_id, reason); + self.pubsub + .publish_conn_control( + tenant, + &ConnControl::DisconnectPubkey { + pubkey: pubkey.to_vec(), + event_id: event_id.to_string(), + reason: reason.to_string(), + }, + ) + .await?; + Ok(closed) + } + /// Disconnect a community locally and publish the command to every relay pod. /// /// Publication is awaited so the archive API can distinguish durable state diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 675d15db8b..911036ef29 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -28,6 +28,8 @@ const MIGRATION_0007_SQL: &str = include_str!("../../../migrations/0007_nip_rs_r const MIGRATION_0008_SQL: &str = include_str!("../../../migrations/0008_fresh_install_search_allowlist.sql"); const MIGRATION_0014_SQL: &str = include_str!("../../../migrations/0014_push_lease_fts.sql"); +const MIGRATION_0028_SQL: &str = + include_str!("../../../migrations/0029_private_managed_agent_foundation.sql"); async fn setup() -> (PgPool, String) { let url = std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); @@ -81,6 +83,9 @@ async fn setup() -> (PgPool, String) { pool.execute(MIGRATION_0014_SQL) .await .expect("apply 0014 migration"); + pool.execute(MIGRATION_0028_SQL) + .await + .expect("apply 0028 migration"); (pool, schema) } @@ -1229,6 +1234,7 @@ async fn excluded_kinds_are_storage_level_unsearchable() { // Negative (load-bearing): each excluded kind MUST NOT surface. for forbidden in [ 1059, + 30179, 30300, 30622, KIND_MEMBER_ADDED_NOTIFICATION as i32, diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 5519153578..f530b525e8 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -116,6 +116,7 @@ fn agent_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), agent_command_override: None, persona_source_version: None, provider: None, diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 4704582372..7e3780b194 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -19,9 +19,17 @@ use crate::{ managed_agents::{ build_managed_agent_summary, current_instance_id, discovery_env_with_baked_floor, find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, - load_personas, managed_agent_avatar_url, missing_command_message, normalize_agent_args, - resolve_command, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, - AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, + load_personas, managed_agent_avatar_url, + migration::activation::{ + confirm_authoritative_edit, enqueue_authoritative_edit, submit_authoritative_edit, + }, + missing_command_message, normalize_agent_args, resolve_command, + retention::{ + active_retention_scope, get_retained_managed_agent_aggregate, open_retention_db, + retire_managed_agent_aggregate, + }, + save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, + AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, @@ -737,7 +745,7 @@ pub async fn update_managed_agent( state: State<'_, AppState>, ) -> Result { // Phase 1: local save (synchronous, under lock) - let (summary, sync_params, rollback) = { + let (summary, sync_params, rollback, authoritative_edit) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -854,6 +862,11 @@ pub async fn update_managed_agent( record.updated_at = now_iso(); + let authoritative_edit = record.relay_authority.is_relay_authoritative(); + if authoritative_edit { + enqueue_authoritative_edit(&app, &state, record)?; + } + save_managed_agents(&app, &records)?; let record = records @@ -901,12 +914,77 @@ pub async fn update_managed_agent( &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), )? }; - let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, record)); - (summary, sync_params, rollback) + let rollback = (name_changed || authoritative_edit) + .then(|| AgentUpdateRollback::new(previous_record, record)); + (summary, sync_params, rollback, authoritative_edit) }; // lock dropped here try_regenerate_nest(&app); + if authoritative_edit { + let scope = active_retention_scope(&app, &state)?; + let relay_api = crate::relay::relay_http_base_url(&scope.relay_url); + let verified = match submit_authoritative_edit( + &state.http_client, + &relay_api, + &scope.owner_keys, + &scope.db_path, + &summary.pubkey, + ) + .await + { + Ok(verified) => verified, + Err(sync_error) => { + if sync_error.starts_with("conflict:") { + let pending = get_retained_managed_agent_aggregate( + &open_retention_db(&scope.db_path)?, + &scope.owner_keys.public_key().to_hex(), + &summary.pubkey, + )? + .ok_or_else(|| "conflicted edit lost its retained attempt".to_string())?; + let conn = open_retention_db(&scope.db_path)?; + if !retire_managed_agent_aggregate( + &conn, + &pending.owner_pubkey, + &pending.agent_pubkey, + pending.generation, + &pending.private_event_id, + )? { + return Err( + "conflicted edit could not retire its retained attempt".to_string() + ); + } + let rollback = rollback.as_ref().ok_or_else(|| { + "missing local rollback state after authoritative conflict".to_string() + })?; + rollback_failed_agent_update(&app, &state, &summary.pubkey, rollback.clone())?; + return Err(format!( + "Agent edit conflicted with a newer relay head. No local changes were kept: {sync_error}" + )); + } + return Err(format!( + "Agent edit is retained for relay retry; local changes remain pending confirmation: {sync_error}" + )); + } + }; + { + let _guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let record = records + .iter_mut() + .find(|record| record.pubkey == summary.pubkey) + .ok_or_else(|| format!("agent {} not found after relay edit", summary.pubkey))?; + record.relay_authority = crate::managed_agents::RelayAuthority::relay_authoritative( + verified.evidence.clone(), + ); + save_managed_agents(&app, &records)?; + } + confirm_authoritative_edit(&scope.db_path, &verified)?; + } + // Phase 2: relay profile sync (async, outside lock). A rename is committed // only when this succeeds; otherwise restore the complete pre-edit record // so Desktop and the relay keep one authoritative name. @@ -937,87 +1015,8 @@ pub async fn update_managed_agent( }) } -// ── Model normalization ─────────────────────────────────────────────────────── - -/// Normalize raw `buzz-acp models --json` output into a typed DTO for the frontend. -/// -/// Merges models from both ACP paths (stable configOptions + unstable SessionModelState), -/// deduplicates by ID (stable takes precedence), and returns a unified list. -pub(super) fn normalize_agent_models( - raw: &serde_json::Value, - persisted_model: Option, -) -> AgentModelsResponse { - let agent_name = raw["agent"]["name"] - .as_str() - .unwrap_or("unknown") - .to_string(); - let agent_version = raw["agent"]["version"] - .as_str() - .unwrap_or("unknown") - .to_string(); - - let mut models: Vec = Vec::new(); - let mut seen_ids: HashSet = HashSet::new(); - - // 1. Stable configOptions (preferred). Only entries with category "model" - // are model options — the CLI pre-filters, but we're defensive here. - if let Some(config_options) = raw["stable"]["configOptions"].as_array() { - for opt in config_options { - if opt.get("category").and_then(|c| c.as_str()) != Some("model") { - continue; - } - if let Some(options) = opt.get("options").and_then(|v| v.as_array()) { - for o in options { - if let Some(value) = o.get("value").and_then(|v| v.as_str()) { - if seen_ids.insert(value.to_string()) { - models.push(AgentModelInfo { - id: value.to_string(), - name: o - .get("displayName") - .and_then(|v| v.as_str()) - .map(str::to_string), - description: None, - }); - } - } - } - } - } - } - - // 2. Unstable availableModels (fallback — skip duplicates from stable). - let mut agent_default_model: Option = None; - if let Some(unstable) = raw.get("unstable") { - agent_default_model = unstable["currentModelId"].as_str().map(str::to_string); - if let Some(available) = unstable["availableModels"].as_array() { - for m in available { - if let Some(id) = m.get("modelId").and_then(|v| v.as_str()) { - if seen_ids.insert(id.to_string()) { - models.push(AgentModelInfo { - id: id.to_string(), - name: m.get("name").and_then(|v| v.as_str()).map(str::to_string), - description: m - .get("description") - .and_then(|v| v.as_str()) - .map(str::to_string), - }); - } - } - } - } - } - - let supports_switching = !models.is_empty(); - - AgentModelsResponse { - agent_name, - agent_version, - models, - agent_default_model, - selected_model: persisted_model, - supports_switching, - } -} +mod normalization; +pub(crate) use normalization::normalize_agent_models; #[cfg(test)] #[path = "agent_models_tests.rs"] diff --git a/desktop/src-tauri/src/commands/agent_models/normalization.rs b/desktop/src-tauri/src/commands/agent_models/normalization.rs new file mode 100644 index 0000000000..c77b54c7b7 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models/normalization.rs @@ -0,0 +1,85 @@ +use std::collections::HashSet; + +use crate::managed_agents::{AgentModelInfo, AgentModelsResponse}; + +// ── Model normalization ─────────────────────────────────────────────────────── + +/// Normalize raw `buzz-acp models --json` output into a typed DTO for the frontend. +/// +/// Merges models from both ACP paths (stable configOptions + unstable SessionModelState), +/// deduplicates by ID (stable takes precedence), and returns a unified list. +pub(crate) fn normalize_agent_models( + raw: &serde_json::Value, + persisted_model: Option, +) -> AgentModelsResponse { + let agent_name = raw["agent"]["name"] + .as_str() + .unwrap_or("unknown") + .to_string(); + let agent_version = raw["agent"]["version"] + .as_str() + .unwrap_or("unknown") + .to_string(); + + let mut models: Vec = Vec::new(); + let mut seen_ids: HashSet = HashSet::new(); + + // 1. Stable configOptions (preferred). Only entries with category "model" + // are model options — the CLI pre-filters, but we're defensive here. + if let Some(config_options) = raw["stable"]["configOptions"].as_array() { + for opt in config_options { + if opt.get("category").and_then(|c| c.as_str()) != Some("model") { + continue; + } + if let Some(options) = opt.get("options").and_then(|v| v.as_array()) { + for o in options { + if let Some(value) = o.get("value").and_then(|v| v.as_str()) { + if seen_ids.insert(value.to_string()) { + models.push(AgentModelInfo { + id: value.to_string(), + name: o + .get("displayName") + .and_then(|v| v.as_str()) + .map(str::to_string), + description: None, + }); + } + } + } + } + } + } + + // 2. Unstable availableModels (fallback — skip duplicates from stable). + let mut agent_default_model: Option = None; + if let Some(unstable) = raw.get("unstable") { + agent_default_model = unstable["currentModelId"].as_str().map(str::to_string); + if let Some(available) = unstable["availableModels"].as_array() { + for m in available { + if let Some(id) = m.get("modelId").and_then(|v| v.as_str()) { + if seen_ids.insert(id.to_string()) { + models.push(AgentModelInfo { + id: id.to_string(), + name: m.get("name").and_then(|v| v.as_str()).map(str::to_string), + description: m + .get("description") + .and_then(|v| v.as_str()) + .map(str::to_string), + }); + } + } + } + } + } + + let supports_switching = !models.is_empty(); + + AgentModelsResponse { + agent_name, + agent_version, + models, + agent_default_model, + selected_model: persisted_model, + supports_switching, + } +} diff --git a/desktop/src-tauri/src/commands/agent_update_rollback.rs b/desktop/src-tauri/src/commands/agent_update_rollback.rs index 2745b3cd22..8fd6ea125a 100644 --- a/desktop/src-tauri/src/commands/agent_update_rollback.rs +++ b/desktop/src-tauri/src/commands/agent_update_rollback.rs @@ -7,7 +7,7 @@ use crate::{ }, }; -#[derive(Debug)] +#[derive(Debug, Clone)] pub(super) struct AgentUpdateRollback { attempted_record: ManagedAgentRecord, previous_record: ManagedAgentRecord, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index e17a90cac3..b7cd51296a 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -44,6 +44,12 @@ pub(super) fn retain_managed_agent_pending( state: &AppState, record: &ManagedAgentRecord, ) { + // Once kind:30179 is canonical, ordinary kind:30177 writes are fenced by + // the relay. Authoritative mutations must use the aggregate CAS path; do + // not feed a permanently failing legacy retry rail. + if record.relay_authority.is_relay_authoritative() { + return; + } use crate::managed_agents::{reconcile::retain_agent_record, retention::open_retention_db}; let result = (|| -> Result<(), String> { @@ -59,61 +65,10 @@ pub(super) fn retain_managed_agent_pending( } } -/// Purge a deleted agent's pending row and enqueue a NIP-09 tombstone, both -/// inside the `managed_agents_store_lock`-held delete body and NEVER across an -/// `.await`. -/// -/// Mirrors `commands::personas::tombstone_persona_pending`: the agent row at -/// `(30177, owner, agent_pubkey)` is purged first so an unpublished edit can -/// never resurrect it after the tombstone publishes, then the kind:5 tombstone -/// is retained at its own `(5, owner, agent_pubkey)` coordinate with -/// `pending_sync = 1`. The `d_tag` is the agent's pubkey. Best-effort: a -/// failure is logged and swallowed so a retention hiccup never blocks the -/// disk-authoritative delete. -pub(super) fn tombstone_managed_agent_pending( - app: &AppHandle, - state: &AppState, - agent_pubkey: &str, -) { - use crate::managed_agents::{ - agent_events::build_agent_delete, - retention::{ - delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, - RetainedEvent, - }, - }; - use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; - - const KIND_DELETE: u32 = 5; - - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let owner_pubkey = scope.owner_keys.public_key().to_hex(); - let event = build_agent_delete(agent_pubkey, &owner_pubkey)? - .sign_with_keys(&scope.owner_keys) - .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; - let conn = open_retention_db(&scope.db_path)?; - 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, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: agent-tombstone: {e}"); - } -} +mod tombstone; +#[cfg(test)] +use tombstone::{classify_tombstone_retry, TombstoneRetry}; +pub(super) use tombstone::{tombstone_managed_agent_pending, TombstoneDisposition}; /// Build and sign the NIP-IA `kind:9035` archive request enqueued when an /// agent is deleted. Pure given the keys — unit-testable without an @@ -173,7 +128,7 @@ pub(super) fn build_agent_archive_request( /// `managed_agents_store_lock`-held delete body, never across an `.await`, /// best-effort — a failure is logged and swallowed so it never blocks the /// disk-authoritative delete. -pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubkey: &str) { +pub(crate) 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; @@ -913,6 +868,7 @@ pub async fn create_managed_agent( } else { relay_mesh.clone() }, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), }; records.push(record); @@ -1321,27 +1277,54 @@ pub async fn delete_managed_agent( } } + let relay_authority = records + .iter() + .find(|record| record.pubkey == pubkey) + .map(|record| record.relay_authority.clone()) + .ok_or_else(|| format!("agent {pubkey} not found"))?; if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { stop_managed_agent_process(&app, record, &mut runtimes)?; } state.clear_agent_session_caches(&pubkey); - let initial_len = records.len(); - records.retain(|record| record.pubkey != pubkey); - if records.len() == initial_len { - return Err(format!("agent {pubkey} not found")); + + // Enqueue-before-erase: durably retain the authoritative tombstone + // BEFORE any local destruction. For a relay-canonical agent a failed + // enqueue propagates here, leaving the record fully intact and the + // delete retryable — never erased against a missing retry. + match tombstone_managed_agent_pending(&app, &state, &pubkey, &relay_authority)? { + TombstoneDisposition::DeferErase { evidence } => { + // Relay-canonical: keep the record on disk in Deleting state. + // The deletion flush erases record/key and archives the + // identity only after verified relay confirmation, so a crash + // between enqueue and erase retries the byte-identical + // tombstone; boot reconcile treats Deleting like + // authoritative and never republishes the projection. + let record = records + .iter_mut() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + record.relay_authority = + crate::managed_agents::RelayAuthority::deleting(evidence); + save_managed_agents(&app, &records)?; + } + TombstoneDisposition::EraseNow => { + // Legacy kind:5: the tombstone is durably enqueued and there + // is no async relay confirmation, so erase in this same lock. + let initial_len = records.len(); + records.retain(|record| record.pubkey != pubkey); + if records.len() == initial_len { + return Err(format!("agent {pubkey} not found")); + } + save_managed_agents(&app, &records)?; + // Remove the agent's nsec from the keyring after the record + // is gone. + crate::managed_agents::delete_agent_key(&pubkey); + // NIP-IA: archive the deleted agent's identity on the relay so + // it stops appearing in member pickers and autocomplete. Same + // best-effort, inside-the-lock contract as the tombstone above. + archive_managed_agent_pending(&app, &state, &pubkey); + } } - save_managed_agents(&app, &records)?; - // Remove the agent's nsec from the keyring after the record is gone. - crate::managed_agents::delete_agent_key(&pubkey); - // Tombstone-after-validation: only reached past the deployed-remote - // guard above and a confirmed removal — never orphan a live remote - // deployment's relay record. Inside the lock, before the block closes - // (no .await here). Every agent published, so every delete tombstones. - tombstone_managed_agent_pending(&app, &state, &pubkey); - // NIP-IA: archive the deleted agent's identity on the relay so it - // stops appearing in member pickers and autocomplete. Same - // best-effort, inside-the-lock contract as the tombstone above. - archive_managed_agent_pending(&app, &state, &pubkey); } try_regenerate_nest(&app); Ok(()) diff --git a/desktop/src-tauri/src/commands/agents/tombstone.rs b/desktop/src-tauri/src/commands/agents/tombstone.rs new file mode 100644 index 0000000000..3f8fd0bc6b --- /dev/null +++ b/desktop/src-tauri/src/commands/agents/tombstone.rs @@ -0,0 +1,275 @@ +use super::*; + +/// Decision for a delete retry that finds (or does not find) a pending +/// tombstone already retained at the agent's coordinate. Keeps the immutable +/// tombstone from ever being rebuilt at a fresh event id/timestamp. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum TombstoneRetry { + /// The exact pending tombstone for THIS deletion already exists; resume it + /// (return `DeferErase`) without touching retention. + Resume, + /// No pending tombstone exists and the record is still authoritative — mint + /// the tombstone now (the normal first-delete path, and the cascade + /// enqueue-before-flip path). + Build, + /// Refuse to proceed: either a mid-deletion (`Deleting`) record has no + /// pending tombstone to resume (inconsistent — its tombstone should exist), + /// or a pending row exists but does not match this agent's verified head + /// (foreign/drifted). Rebuilding would violate immutable retention. + FailClosed, +} + +/// Pure retry classifier for [`tombstone_managed_agent_pending`]. +/// +/// * `pending_row_present` — a `state="deleted"`, `pending_sync` row exists at +/// the coordinate. +/// * `matches_this_deletion` — that row's generation is `evidence.gen + 1` and +/// its decrypted predecessor is the record's verified head. +/// * `is_deleting` — the record's authority is `Deleting` (vs +/// `RelayAuthoritative`). +pub(crate) fn classify_tombstone_retry( + pending_row_present: bool, + matches_this_deletion: bool, + is_deleting: bool, +) -> TombstoneRetry { + match (pending_row_present, matches_this_deletion, is_deleting) { + // Exact pending tombstone for this deletion: idempotent resume. + (true, true, _) => TombstoneRetry::Resume, + // A row exists but is foreign/drifted: never rebuild over it. + (true, false, _) => TombstoneRetry::FailClosed, + // No row, still authoritative: first delete (or cascade pre-flip) builds. + (false, _, false) => TombstoneRetry::Build, + // No row, but already Deleting: inconsistent — its tombstone should exist. + (false, _, true) => TombstoneRetry::FailClosed, + } +} + +/// What the caller must do to the local record after +/// [`tombstone_managed_agent_pending`] has durably enqueued the tombstone. +/// +/// The variant enforces enqueue-before-erase: a `RelayAuthoritative` agent's +/// authoritative tombstone must be verified at the relay before the local +/// record/key are destroyed, so its record stays on disk in `Deleting` state +/// and erase is deferred to the boot deletion flush. A `LegacyOnly` agent has +/// no async relay confirmation, so once its kind:5 tombstone is durably +/// enqueued the caller erases in the same lock. +pub(crate) enum TombstoneDisposition { + /// Legacy kind:5 path: the tombstone is durably enqueued; erase the record + /// and key now, in the same lock. + EraseNow, + /// Relay-canonical path: the authoritative deleted aggregate is durably + /// retained (`pending_sync = 1`). The caller MUST NOT erase yet — flip the + /// record to [`RelayAuthority::Deleting`] with this evidence and keep it on + /// disk. The deletion flush erases only after verified relay confirmation. + DeferErase { + evidence: crate::managed_agents::RelayAuthorityEvidence, + }, +} + +/// Durably enqueue a managed agent's authoritative tombstone, returning what +/// the caller must do to the local record — WITHOUT erasing anything itself. +/// +/// This is the crash-safe enqueue-before-erase seam. It runs inside the +/// `managed_agents_store_lock`-held delete body and NEVER across an `.await`. +/// +/// * **RelayAuthoritative**: builds the next-generation deleted kind:30179 +/// aggregate from the record's verified head evidence and retains it with +/// `pending_sync = 1`. This durable row is the retry input; it MUST land +/// before the record/key are destroyed, so a crash between enqueue and the +/// (deferred, verified) erase retries cleanly and the evidence needed to +/// re-sign the tombstone survives. Returns [`TombstoneDisposition::DeferErase`] +/// — the caller keeps the record on disk in [`RelayAuthority::Deleting`]. +/// A failure here is FATAL and propagates: the record is left fully intact +/// ([`RelayAuthority::RelayAuthoritative`]) and the delete is retryable, never +/// erased against a missing durable tombstone. +/// * **LegacyOnly**: retains the kind:5 NIP-09 tombstone at its own coordinate. +/// Returns [`TombstoneDisposition::EraseNow`]. A retention hiccup here is +/// logged and swallowed (legacy has no live authoritative aggregate to leak +/// and no async confirmation), so a disk-authoritative delete is never blocked. +/// +/// Mirrors `commands::personas::tombstone_persona_pending`: the agent row at +/// `(30177, owner, agent_pubkey)` is purged first so an unpublished edit can +/// never resurrect it after the tombstone publishes. +pub(crate) fn tombstone_managed_agent_pending( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, + relay_authority: &crate::managed_agents::RelayAuthority, +) -> Result { + use crate::managed_agents::{ + agent_events::build_agent_delete, + migration::build_tombstone_event, + retention::{ + delete_retained_event, open_retention_db, retain_event, retain_managed_agent_aggregate, + tombstone_retention_d_tag, RetainedEvent, RetainedManagedAgentAggregate, + }, + }; + use buzz_core_pkg::kind::KIND_MANAGED_AGENT; + use nostr::JsonUtil; + + const KIND_DELETE: u32 = 5; + + // Relay-canonical agents durably enqueue the authoritative tombstone before + // ANY local destruction. A failure propagates so the caller never erases + // against a missing retry — the record stays RelayAuthoritative and the + // delete is retryable. + if let Some(evidence) = relay_authority.evidence() { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + + // Idempotent retry recognition. A repeat delete on this coordinate may + // find its immutable tombstone ALREADY enqueued — either because the + // record is now `Deleting` (its authority flip/save landed) or because it + // is still `RelayAuthoritative` (a cascade crash after enqueue N but + // before the batch authority save, per the self-heal contract). In BOTH + // cases rebuilding would mint a new event id / timestamp at the same + // coordinate and hit immutable-retention drift. So recognize the exact + // pending tombstone bound to THIS verified head and return DeferErase + // without touching retention; the deletion flush self-heals the authority + // state. If a pending tombstone exists but does NOT match its predecessor, + // fail closed — never rebuild over a foreign row. + { + let conn = open_retention_db(&scope.db_path)?; + let pending_deleted = + crate::managed_agents::retention::get_retained_managed_agent_aggregate( + &conn, + &owner_pubkey, + agent_pubkey, + )? + .filter(|retained| retained.state == "deleted" && retained.pending_sync); + + // Decrypt the pending row (if any) and reduce it to a single fact: + // does it name THIS exact deletion (generation = evidence.gen+1 and + // decrypted predecessor = the record's verified head)? Decryption + // stays here; the branch decision is a pure classifier below. + let matches_this_deletion = match &pending_deleted { + Some(retained) => { + let generation_ok = evidence + .generation + .checked_add(1) + .is_some_and(|next| retained.generation == next); + generation_ok && { + let parsed: serde_json::Value = + serde_json::from_str(&retained.request_json).map_err(|e| { + format!("retained deleted request json invalid: {e}") + })?; + let private_event: nostr::Event = serde_json::from_value( + parsed.get("private_event").cloned().ok_or_else(|| { + "retained deleted request missing private_event".to_string() + })?, + ) + .map_err(|e| format!("retained deleted private_event invalid: {e}"))?; + let (_, payload) = + buzz_core_pkg::private_managed_agent::validate_and_decrypt( + &private_event, + &scope.owner_keys, + ) + .map_err(|e| format!("retained deletion head does not decrypt: {e}"))?; + payload.state == buzz_core_pkg::private_managed_agent::State::Deleted + && payload.owner_pubkey == owner_pubkey + && payload.agent_pubkey == agent_pubkey + && payload.previous_event_id.as_deref() + == Some(&evidence.private_event_id) + } + } + None => false, + }; + + match classify_tombstone_retry( + pending_deleted.is_some(), + matches_this_deletion, + relay_authority.is_deleting(), + ) { + TombstoneRetry::Resume => { + return Ok(TombstoneDisposition::DeferErase { + evidence: evidence.clone(), + }) + } + TombstoneRetry::FailClosed => { + return Err( + "delete retry cannot rebuild an immutable tombstone: a pending row is \ + missing or does not match this agent's verified head (fail closed)" + .to_string(), + ) + } + TombstoneRetry::Build => {} + } + } + + let mut conn = open_retention_db(&scope.db_path)?; + // Build path: no pending tombstone yet exists for this coordinate. + // Purge the agent's 30177 row first so an unpublished edit can never + // resurrect it after the tombstone publishes. + delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; + + let timestamp = crate::util::now_iso(); + let event = build_tombstone_event( + &scope.owner_keys, + agent_pubkey, + evidence.generation, + &evidence.private_event_id, + ×tamp, + nostr::Timestamp::now().as_secs(), + ) + .map_err(|error| format!("failed to build managed-agent aggregate tombstone: {error:?}"))?; + let generation = evidence + .generation + .checked_add(1) + .ok_or_else(|| "managed-agent aggregate generation overflow".to_string())?; + let request_json = serde_json::to_string(&serde_json::json!({ + "private_event": event, + "definition_event": null, + "instance_event": null, + "expected_definition_revision": null + })) + .map_err(|error| format!("failed to serialize managed-agent tombstone: {error}"))?; + retain_managed_agent_aggregate( + &mut conn, + &RetainedManagedAgentAggregate { + owner_pubkey, + agent_pubkey: agent_pubkey.to_string(), + generation, + private_event_id: event.id.to_hex(), + state: "deleted".to_string(), + request_json, + pending_sync: true, + last_error: None, + local_authority_applied: false, + }, + )?; + return Ok(TombstoneDisposition::DeferErase { + evidence: evidence.clone(), + }); + } + + // LegacyOnly: enqueue the kind:5 tombstone before erase, but best-effort — + // a retention hiccup never blocks the disk-authoritative delete. + let result = (|| -> Result<(), String> { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let conn = open_retention_db(&scope.db_path)?; + delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; + + let event = build_agent_delete(agent_pubkey, &owner_pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; + 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, + }, + ) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: agent-tombstone: {e}"); + } + Ok(TombstoneDisposition::EraseNow) +} diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 20061debe7..98432978a9 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -58,6 +58,7 @@ fn bare_agent_record( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], @@ -507,3 +508,63 @@ fn tauri_platform_configs_bundle_kubernetes_only_on_supported_hosts() { ); } } + +// ── Delete-retry tombstone classifier ─────────────────────────────────────── +// +// Guards the immutable-retention contract: a repeat delete must NEVER rebuild +// a tombstone at a fresh event id/timestamp. These cover the cascade +// enqueue-N/save-fail self-heal and the Deleting retry Carl flagged. + +#[test] +fn retry_resumes_when_exact_pending_tombstone_exists_deleting() { + // Record already flipped to Deleting, its tombstone pending: idempotent + // resume, no rebuild. + assert_eq!( + classify_tombstone_retry(true, true, true), + TombstoneRetry::Resume + ); +} + +#[test] +fn retry_resumes_authoritative_after_cascade_enqueue_save_fail() { + // Cascade enqueued tombstone N, then the batch authority save failed, so the + // record is still RelayAuthoritative but a matching pending tombstone exists. + // Retry must resume it, not rebuild (which would drift). + assert_eq!( + classify_tombstone_retry(true, true, false), + TombstoneRetry::Resume + ); +} + +#[test] +fn retry_builds_on_first_delete_of_authoritative_record() { + // No pending tombstone, record authoritative: the normal first-delete path. + assert_eq!( + classify_tombstone_retry(false, false, false), + TombstoneRetry::Build + ); +} + +#[test] +fn retry_fails_closed_for_deleting_record_without_pending_row() { + // A Deleting record whose tombstone row is gone is inconsistent — never mint + // a fresh one. + assert_eq!( + classify_tombstone_retry(false, false, true), + TombstoneRetry::FailClosed + ); +} + +#[test] +fn retry_fails_closed_for_foreign_pending_row() { + // A pending row exists but does not match this agent's verified head + // (foreign/drifted): refuse to rebuild over it, regardless of authority. + assert_eq!( + classify_tombstone_retry(true, false, false), + TombstoneRetry::FailClosed + ); + assert_eq!( + classify_tombstone_retry(true, false, true), + TombstoneRetry::FailClosed + ); +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 237bc06e8d..a6da60c7c1 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -9,7 +9,7 @@ mod agent_models_env; mod agent_providers; mod agent_settings; mod agent_update_rollback; -mod agents; +pub(crate) mod agents; mod canvas; mod channel_templates; mod channel_window; diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9b..369d817ebd 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -66,6 +66,7 @@ fn make_agent( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d..fd5e04285f 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -77,7 +77,9 @@ fn reconcile_inbound_persona_event_blocking( save_managed_agents, save_teams, team_events::team_content_from_event, }; - use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; + use buzz_core_pkg::kind::{ + KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRIVATE_MANAGED_AGENT, KIND_TEAM, + }; use nostr::JsonUtil; let state = app.state::(); @@ -96,10 +98,17 @@ fn reconcile_inbound_persona_event_blocking( return reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state); } - if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { + if !matches!( + kind, + KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT + ) { return Ok(()); } + if kind == KIND_PRIVATE_MANAGED_AGENT { + return reconcile_inbound_private_managed_agent(&event, &arrival_relay_url, &app, &state); + } + // The d-tag identifies the record within its kind. Persona derives it from // the parsed record (`persona_d_tag`); team/agent carry it as the event's // d-tag directly. The persona is parsed once here and reused in the apply @@ -182,6 +191,302 @@ fn reconcile_inbound_persona_event_blocking( Ok(()) } +fn reconcile_inbound_private_managed_agent( + event: &nostr::Event, + arrival_relay_url: &str, + app: &AppHandle, + state: &AppState, +) -> Result<(), String> { + use buzz_core_pkg::private_managed_agent::{self as pma, State}; + + let owner_keys = state.signing_keys()?; + if event.pubkey != owner_keys.public_key() { + return Ok(()); + } + if crate::managed_agents::retention::arrival_retention_scope(app, state, arrival_relay_url)? + .is_none() + { + return Ok(()); + } + + let (_, payload) = pma::validate_and_decrypt(event, &owner_keys) + .map_err(|error| format!("invalid private managed-agent aggregate: {error}"))?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut agents = crate::managed_agents::load_managed_agents(app)?; + let existing_index = agents + .iter() + .position(|record| record.pubkey == payload.agent_pubkey); + + if let Some(index) = existing_index { + let authority = agents[index].relay_authority.evidence(); + if authority.is_some_and(|evidence| { + payload.generation <= evidence.generation + || payload.previous_event_id.as_deref() != Some(&evidence.private_event_id) + }) { + return Ok(()); + } + // A local legacy record is still authoritative until its own migration + // persists verified evidence. Never replace it from an unsolicited head. + if authority.is_none() { + return Ok(()); + } + } + + // Open the retention store once for both branches. It carries the DURABLE + // generation floor per coordinate — the deleted branch persists a tombstone + // row here even when no local record exists, and the active branch gates its + // no-match insert on it. The in-memory `relay_authority` guard above only + // fires for a record that is still present; a delayed stale active head that + // arrives after a tombstone erased (or preceded) the record finds no record, + // skips that guard, and would otherwise resurrect the deleted agent — nsec + // included. The persisted floor closes that window. + let scope = + crate::managed_agents::retention::arrival_retention_scope(app, state, arrival_relay_url)? + .ok_or_else(|| "private aggregate arrival scope changed".to_string())?; + let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?; + let durable_floor = crate::managed_agents::retention::get_retained_managed_agent_aggregate( + &conn, + &payload.owner_pubkey, + &payload.agent_pubkey, + )?; + // A retained row (active OR deleted) at this generation or newer means this + // head is stale relative to durable local authority — drop it. A legitimate + // re-creation advances to a strictly higher generation and passes. + if durable_floor + .as_ref() + .is_some_and(|floor| payload.generation <= floor.generation) + { + return Ok(()); + } + + match payload.state { + State::Deleted => { + // Persist the tombstone as a durable floor FIRST, so even with no + // local record a later stale active head at <= this generation is + // rejected by the floor gate above. Seeded confirmed (already on the + // relay); `local_authority_applied` reflects the record/key erase + // this branch performs (trivially true when no record exists). + let request_json = serde_json::to_string(&serde_json::json!({ + "private_event": event, + "definition_event": Option::<&nostr::Event>::None, + "instance_event": Option::<&nostr::Event>::None, + "expected_definition_revision": Option::::None, + })) + .map_err(|error| format!("failed to retain inbound private tombstone: {error}"))?; + crate::managed_agents::retention::seed_confirmed_managed_agent_tombstone( + &conn, + &crate::managed_agents::retention::RetainedManagedAgentAggregate { + owner_pubkey: payload.owner_pubkey.clone(), + agent_pubkey: payload.agent_pubkey.clone(), + generation: payload.generation, + private_event_id: event.id.to_hex(), + state: "deleted".to_string(), + request_json, + pending_sync: false, + last_error: None, + local_authority_applied: true, + }, + )?; + if let Some(index) = existing_index { + agents.remove(index); + crate::managed_agents::delete_agent_key(&payload.agent_pubkey); + state.clear_agent_session_caches(&payload.agent_pubkey); + crate::managed_agents::save_managed_agents(app, &agents)?; + } + } + State::Active => { + let reconstructed = reconstruct_managed_agent_from_payload(&payload, event)?; + let request_json = serde_json::to_string(&serde_json::json!({ + "private_event": event, + "definition_event": payload.active.as_ref().map(|active| &active.definition.recovery.signed_event), + "instance_event": payload.active.as_ref().map(|active| &active.instance_projection.recovery.signed_event), + "expected_definition_revision": payload.active.as_ref().map(|active| active.definition.revision), + })) + .map_err(|error| format!("failed to retain inbound private aggregate: {error}"))?; + crate::managed_agents::retention::seed_confirmed_managed_agent_aggregate( + &conn, + &crate::managed_agents::retention::RetainedManagedAgentAggregate { + owner_pubkey: payload.owner_pubkey.clone(), + agent_pubkey: payload.agent_pubkey.clone(), + generation: payload.generation, + private_event_id: event.id.to_hex(), + state: "active".to_string(), + request_json, + pending_sync: false, + last_error: None, + local_authority_applied: false, + }, + )?; + match existing_index { + Some(index) => merge_reconstructed_managed_agent(&mut agents[index], reconstructed), + None => agents.push(reconstructed), + } + crate::managed_agents::save_managed_agents(app, &agents)?; + } + } + + try_regenerate_nest(app); + let _ = app.emit("agents-data-changed", ()); + Ok(()) +} + +fn merge_reconstructed_managed_agent(local: &mut ManagedAgentRecord, inbound: ManagedAgentRecord) { + // Deliberately exhaustive: adding a record field forces this authority + // boundary to classify it as relay-portable or device-local at compile time. + let ManagedAgentRecord { + pubkey, + name, + persona_id, + team_id, + private_key_nsec, + auth_tag, + relay_url, + avatar_url, + acp_command: _, + agent_command: _, + agent_command_override, + agent_args, + mcp_command: _, + turn_timeout_seconds: _, + idle_timeout_seconds, + max_turn_duration_seconds, + parallelism, + system_prompt, + model, + provider, + persona_source_version: _, + env_vars, + start_on_app_launch: _, + auto_restart_on_config_change: _, + runtime_pid: _, + backend, + backend_agent_id, + provider_binary_path: _, + persona_team_dir: _, + persona_name_in_team, + created_at: _, + updated_at, + last_started_at: _, + last_stopped_at: _, + last_exit_code: _, + last_error: _, + last_error_code: _, + respond_to, + respond_to_allowlist, + display_name, + slug, + runtime, + name_pool, + is_builtin, + is_active, + shared: _, + source_team, + source_team_persona_slug, + catalog_source, + definition_respond_to, + definition_respond_to_allowlist, + definition_parallelism, + relay_mesh, + relay_authority, + } = inbound; + + local.pubkey = pubkey; + local.name = name; + local.persona_id = persona_id; + local.team_id = team_id; + local.private_key_nsec = private_key_nsec; + local.auth_tag = auth_tag; + local.relay_url = relay_url; + local.avatar_url = avatar_url; + local.agent_command_override = agent_command_override; + local.agent_args = agent_args; + local.idle_timeout_seconds = idle_timeout_seconds; + local.max_turn_duration_seconds = max_turn_duration_seconds; + local.parallelism = parallelism; + local.system_prompt = system_prompt; + local.model = model; + local.provider = provider; + local.env_vars = env_vars; + local.backend = backend; + local.backend_agent_id = backend_agent_id; + local.persona_name_in_team = persona_name_in_team; + local.updated_at = updated_at; + local.respond_to = respond_to; + local.respond_to_allowlist = respond_to_allowlist; + local.display_name = display_name; + local.slug = slug; + local.runtime = runtime; + local.name_pool = name_pool; + local.is_builtin = is_builtin; + local.is_active = is_active; + local.source_team = source_team; + local.source_team_persona_slug = source_team_persona_slug; + local.catalog_source = catalog_source; + local.definition_respond_to = definition_respond_to; + local.definition_respond_to_allowlist = definition_respond_to_allowlist; + local.definition_parallelism = definition_parallelism; + local.relay_mesh = relay_mesh; + local.relay_authority = relay_authority; +} + +pub(crate) fn reconstruct_managed_agent_from_payload( + payload: &buzz_core_pkg::private_managed_agent::Payload, + private_event: &nostr::Event, +) -> Result { + use crate::managed_agents::{ + agent_events::managed_agent_content_from_event, persona_events::persona_from_event, + RelayAuthority, RelayAuthorityEvidence, RelayMeshConfig, VersionedBackend, + }; + + let active = payload + .active + .as_ref() + .ok_or_else(|| "active private aggregate is missing active payload".to_string())?; + let definition_event = &active.definition.recovery.signed_event; + let instance_event = &active.instance_projection.recovery.signed_event; + let definition = persona_from_event(definition_event)?; + let instance = managed_agent_content_from_event(instance_event)?; + let backend: VersionedBackend = serde_json::from_value(active.config.backend.clone()) + .map_err(|error| format!("invalid private backend envelope: {error}"))?; + let relay_mesh = active + .config + .relay_mesh + .clone() + .map(serde_json::from_value::) + .transpose() + .map_err(|error| format!("invalid private relay mesh config: {error}"))?; + + let mut record = definition.into_agent_record(); + record.pubkey = payload.agent_pubkey.clone(); + record.name = instance.name; + record.persona_id = instance.persona_id; + record.private_key_nsec = active.identity.private_key_nsec.clone(); + record.auth_tag = active.identity.auth_tag.clone(); + record.relay_url = active.config.relay_url.clone(); + record.agent_command_override = active.config.agent_command_override.clone(); + record.agent_args = active.config.agent_args.clone(); + record.idle_timeout_seconds = active.config.idle_timeout_seconds; + record.max_turn_duration_seconds = active.config.max_turn_duration_seconds; + record.env_vars = active.config.env_vars.clone().into_iter().collect(); + record.backend = backend.backend; + record.backend_agent_id = active.config.backend_agent_id.clone(); + record.team_id = active.config.team_id.clone(); + record.persona_name_in_team = active.config.persona_name_in_team.clone(); + record.relay_mesh = relay_mesh; + record.parallelism = instance.parallelism; + record.respond_to = instance.respond_to; + record.respond_to_allowlist = instance.respond_to_allowlist; + record.updated_at = payload.updated_at.clone(); + record.relay_authority = RelayAuthority::relay_authoritative(RelayAuthorityEvidence { + generation: payload.generation, + private_event_id: private_event.id.to_hex(), + }); + Ok(record) +} + /// Parse an inbound wire event and enforce the signature gate. Everything /// downstream trusts `event.pubkey` (ownership routing, tombstone scoping, /// behavioral-quad application), so a forged pubkey must die here — the diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432..963829914f 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -215,6 +215,7 @@ fn local_agent() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } @@ -256,6 +257,64 @@ fn foreign_agent_event_with_secrets(d_tag: &str) -> nostr::Event { /// a foreign event crammed with secrets and assert NONE land on the local /// record, and that every projected field IS updated. The projection type is /// the structural guard — the injected keys cannot even be represented. +#[test] +fn private_aggregate_reconstructs_portable_agent_and_authority() { + use crate::managed_agents::migration::{build_migration_candidate, CasMetadata}; + use nostr::{Keys, ToBech32}; + + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut source = local_agent(); + source.pubkey = agent.public_key().to_hex(); + source.private_key_nsec = agent.secret_key().to_bech32().unwrap(); + source.persona_id = None; + source.slug = Some("recovered-agent".to_string()); + source.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "mesh/recovered".to_string(), + }); + let mut definition_source = source.clone(); + definition_source.pubkey.clear(); + let definition = definition_source.to_definition_view().unwrap(); + let definition_event = crate::managed_agents::persona_events::build_persona_event(&definition) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + let instance_event = crate::managed_agents::agent_events::build_agent_event(&source) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + let candidate = build_migration_candidate( + &source, + &owner, + &agent, + definition_event, + instance_event, + &CasMetadata { + generation: 1, + previous_event_id: None, + definition_revision: 1, + }, + ["nip-pma-aggregate-v1"], + 1_700_000_000, + ) + .unwrap(); + + let reconstructed = + reconstruct_managed_agent_from_payload(&candidate.payload, &candidate.signed_event) + .unwrap(); + assert_eq!(reconstructed.pubkey, source.pubkey); + assert_eq!(reconstructed.private_key_nsec, source.private_key_nsec); + assert_eq!(reconstructed.backend, source.backend); + assert_eq!(reconstructed.env_vars, source.env_vars); + assert_eq!(reconstructed.relay_mesh, source.relay_mesh); + let evidence = reconstructed.relay_authority.evidence().unwrap(); + assert_eq!(evidence.generation, 1); + assert_eq!( + evidence.private_event_id, + candidate.signed_event.id.to_hex() + ); +} + #[test] fn inbound_managed_agent_drops_injected_secrets_and_harness() { let event = foreign_agent_event_with_secrets(AGENT_PUBKEY); diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 0cd7ad0324..023bc29741 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -181,6 +181,11 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { remote_deployed.join(", ") )); } + let cascade_authorities: std::collections::HashMap<_, _> = agents + .iter() + .filter(|agent| cascade.contains(&agent.pubkey)) + .map(|agent| (agent.pubkey.clone(), agent.relay_authority.clone())) + .collect(); // ── Phase 2: Stop ─────────────────────────────────────────────── // @@ -208,18 +213,59 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { // ── Phase 3: Commit ───────────────────────────────────────────── // + // Enqueue-before-erase per cascade agent, mirroring + // `delete_managed_agent`. For each agent the authoritative tombstone + // is durably retained BEFORE any local destruction: + // * relay-canonical → stays on disk flipped to Deleting; the + // deletion flush erases + archives after verified confirmation. + // * legacy → erased in this lock once its kind:5 tombstone is + // enqueued. + // A relay-canonical enqueue failure propagates before anything is + // destroyed, so the full cascade retries cleanly. The persona's own + // tombstone (kind:30175) is independent, so a persona may leave disk + // while its relay-canonical agents are still confirming. + let mut erase_pubkeys: Vec = Vec::new(); + let mut deleting_updates: std::collections::HashMap< + String, + crate::managed_agents::RelayAuthority, + > = std::collections::HashMap::new(); + for pk in &cascade { + let authority = cascade_authorities + .get(pk) + .ok_or_else(|| format!("agent {pk} authority missing"))?; + match super::agents::tombstone_managed_agent_pending(&app, &state, pk, authority)? { + super::agents::TombstoneDisposition::DeferErase { evidence } => { + deleting_updates.insert( + pk.clone(), + crate::managed_agents::RelayAuthority::deleting(evidence), + ); + } + super::agents::TombstoneDisposition::EraseNow => { + erase_pubkeys.push(pk.clone()); + } + } + } + // Disk-authoritative writes first, side effects strictly after. - // commit_cascade_agents is an injectable seam so unit tests can - // verify retry-safety: a failing save propagates before any keyring - // deletion or tombstone occurs. + // Relay-canonical cascade agents are kept in Deleting state; only + // legacy agents are removed from the list. commit_cascade_agents is + // an injectable seam so unit tests can verify retry-safety: a failing + // save propagates before any keyring deletion. // // Failure semantics: - // agent save fails → nothing destroyed; full cascade retries cleanly - // persona save fails → cascade agents gone, persona survives; a retry - // finds an empty cascade and proceeds cleanly - // Keys and tombstones are enqueued only after their records leave disk. + // agent save fails → nothing erased; full cascade retries cleanly + // persona save fails → cascade agents already erased/flipped, persona + // survives; a retry finds an empty cascade and + // proceeds cleanly + let erase_set: std::collections::HashSet = + erase_pubkeys.iter().cloned().collect(); if !cascade.is_empty() { - commit_cascade_agents(&mut agents, &cascade, |recs| { + for (pk, authority) in &deleting_updates { + if let Some(agent) = agents.iter_mut().find(|a| &a.pubkey == pk) { + agent.relay_authority = authority.clone(); + } + } + commit_cascade_agents(&mut agents, &erase_set, |recs| { save_managed_agents(&app, recs) })?; } @@ -231,14 +277,22 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { } save_personas(&app, &personas)?; - // Side effects — strictly after records leave disk. - for pk in &cascade { + // Side effects — strictly after records leave disk. Only the legacy + // agents that actually left disk are erased/archived here; the + // relay-canonical agents' keys/caches/archive are handled by the + // deletion flush after verified confirmation. + for pk in &erase_pubkeys { state.clear_agent_session_caches(pk); // Remove nsec from keyring after the record is gone. delete_agent_key(pk); - super::agents::tombstone_managed_agent_pending(&app, &state, pk); super::agents::archive_managed_agent_pending(&app, &state, pk); } + // Relay-canonical cascade agents are stopped now (their process must + // not outlive the deletion); their session caches are cleared so a + // stale handle cannot resurrect work while the tombstone confirms. + for pk in deleting_updates.keys() { + state.clear_agent_session_caches(pk); + } tombstone_persona_pending(&app, &state, &d_tag); // _store_guard drops here, before try_regenerate_nest. diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index cab5fababc..00253a8d3d 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -41,6 +41,16 @@ pub(in crate::commands) fn retain_persona_pending( state: &AppState, persona: &AgentDefinition, ) { + if crate::managed_agents::load_managed_agents(app) + .unwrap_or_default() + .iter() + .any(|record| { + record.persona_id.as_deref() == Some(persona.id.as_str()) + && record.relay_authority.is_relay_authoritative() + }) + { + return; + } if let Err(e) = prepare_persona_publication(app, state, persona, None) { eprintln!("buzz-desktop: persona-retain: {e}"); } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index b769d74d7b..85552a0aa7 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -64,6 +64,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304..24333becd9 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -652,6 +652,7 @@ pub async fn confirm_agent_snapshot_import( definition_respond_to_allowlist: minted.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), runtime: snapshot.definition.runtime.clone(), name_pool: snapshot.definition.name_pool.clone(), }; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9d..fab7821ab5 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -73,6 +73,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index c60215ae4d..5160b10a96 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -58,6 +58,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..83d9369478 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -609,6 +609,7 @@ pub async fn confirm_team_snapshot_import( definition_respond_to_allowlist: definition.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), runtime: member.definition.runtime.clone(), name_pool: member.definition.name_pool.clone(), }; diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c9a6d8812a..ceaf30108e 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -229,6 +229,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), runtime: None, name_pool: vec![], }; diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 731a99d9d9..9f4672cdd5 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -224,9 +224,83 @@ pub async fn apply_workspace( migrate_legacy_retention_into(&restore_app, &scope); crate::event_sync::spawn_event_sync( restore_app.clone(), - scope.owner_keys, - scope.db_path, - ) + scope.owner_keys.clone(), + scope.db_path.clone(), + ); + + // PMA activation is scoped to the exact relay+owner snapshot above. + // Capability discovery fails closed; the legacy record remains + // authoritative unless submission, strict read-back verification, + // local authority persistence, and compare-and-clear all succeed. + let migration_app = restore_app.clone(); + let migration_client = state.http_client.clone(); + let migration_relay_api = crate::relay::relay_http_base_url(&scope.relay_url); + tauri::async_runtime::spawn(async move { + // Promotion is a one-way rollout and defaults off. Existing + // authoritative retries (including deletions) are still driven + // below so disabling NEW promotion never strands prior state. + if crate::managed_agents::migration::activation::automatic_migration_enabled() { + let extensions = match crate::managed_agents::migration::activation::discover_relay_extensions( + &migration_client, + &migration_relay_api, + ) + .await + { + Ok(extensions) => extensions, + Err(error) => { + eprintln!("buzz-desktop: PMA capability discovery failed: {error}"); + Vec::new() + } + }; + if !extensions.is_empty() { + let app = migration_app.clone(); + let keys = scope.owner_keys.clone(); + let db_path = scope.db_path.clone(); + match tauri::async_runtime::spawn_blocking(move || { + crate::managed_agents::migration::activation::enqueue_initial_migrations( + &app, + &keys, + &db_path, + &extensions, + ) + }) + .await + { + Ok(Ok(_)) => {} + Ok(Err(error)) => { + eprintln!("buzz-desktop: PMA migration enqueue failed: {error}"); + } + Err(error) => { + eprintln!("buzz-desktop: PMA migration enqueue task failed: {error}"); + } + } + } + } + if let Err(error) = + crate::managed_agents::migration::activation::flush_pending_migrations( + &migration_app, + &migration_client, + &migration_relay_api, + &scope.owner_keys, + &scope.db_path, + ) + .await + { + eprintln!("buzz-desktop: PMA migration flush failed: {error}"); + } + if let Err(error) = + crate::managed_agents::migration::activation::flush_pending_deletions( + &migration_app, + &migration_client, + &migration_relay_api, + &scope.owner_keys, + &scope.db_path, + ) + .await + { + eprintln!("buzz-desktop: PMA deletion flush failed: {error}"); + } + }); } Err(error) => { eprintln!("buzz-desktop: scoped event-sync unavailable after workspace apply: {error}"); diff --git a/desktop/src-tauri/src/egress_guard.rs b/desktop/src-tauri/src/egress_guard.rs index db58ddafa0..6e8693bad4 100644 --- a/desktop/src-tauri/src/egress_guard.rs +++ b/desktop/src-tauri/src/egress_guard.rs @@ -14,6 +14,7 @@ //! | 6 | `submit_engram_event` (team snapshot) | `commands/team_snapshot.rs` | //! | 7 | `submit_engram_event` (persona import) | `commands/personas/snapshot/import.rs` | //! | 8 | native websocket send loop (all webview relay WS) | `native_websocket.rs` | +//! | 9 | PMA aggregate submit/retry driver | `managed_agents/migration/driver/mod.rs` | //! //! The inventory-completeness test in `egress_guard_tests.rs` asserts that //! every `/events` URL-construction site in the tree calls this guard, so a diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index f487c8ce16..70081601d8 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -246,6 +246,9 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ ("src/commands/team_snapshot.rs", 1, 1), // boundary 6 ("src/commands/personas/snapshot/import.rs", 2, 1), // boundary 7 + its in-file injection-test fixture URL ("src/native_websocket.rs", 0, 2), // boundary 8 (WS frames; no events URL) + // boundary 9: PMA aggregate submit funnel (POSTs to /api/managed-agents/ + // aggregate, not /events, so 0 events-URL sites + 1 guard call). + ("src/managed_agents/migration/driver/mod.rs", 0, 1), // Test-only fixtures — no production egress, no guard: ("src/relay_admission.rs", 1, 0), ("src/archive/mod_tests.rs", 1, 0), @@ -418,6 +421,7 @@ fn ncryptsec_handling_is_confined_to_allowlisted_files() { "src/commands/team_snapshot/tests.rs", "src/commands/personas/snapshot/import.rs", "src/native_websocket.rs", + "src/managed_agents/migration/driver/tests.rs", // boundary 9 injection test ]; let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079d..d266e10a4b 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -216,6 +216,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8508c27073..86631891df 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -416,6 +416,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), agent_command_override: None, persona_source_version: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e5..f480b5e02e 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -72,6 +72,7 @@ fn minimal_record() -> ManagedAgentRecord { definition_respond_to_allowlist: vec!["abc123def".to_string()], definition_parallelism: Some(4), relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/managed_agents/authority.rs b/desktop/src-tauri/src/managed_agents/authority.rs new file mode 100644 index 0000000000..a5132a7264 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/authority.rs @@ -0,0 +1,466 @@ +//! Per-agent relay-authority evidence and the exhaustive field-classification +//! table that governs which [`ManagedAgentRecord`](super::types::ManagedAgentRecord) +//! fields cross to the relay as canonical private managed-agent (kind:30179) +//! state. +//! +//! This module is the **safety foundation** for the relay-canonical managed +//! agent migration. It defines *state and classification only*: it neither +//! enables migration nor performs any destructive local mutation. The default +//! for every existing record is [`RelayAuthority::LegacyOnly`], so an +//! upgrade that predates any migration deserializes unchanged and keeps local +//! JSON + keyring authoritative. + +use serde::{Deserialize, Serialize}; + +/// Per-agent relay-canonical authority: where an agent's canonical runnable +/// configuration currently lives, plus (when authoritative) the verified head +/// evidence. +/// +/// Represented as a single internally-tagged enum so **impossible states are +/// unrepresentable**: `RelayAuthoritative` structurally carries its evidence +/// and `LegacyOnly` structurally cannot. There is no way to persist +/// "authoritative but no evidence" or "legacy but with a stale head", which +/// would otherwise let corrupt evidence silently disable boot reconcile. +/// +/// Serialized inline on [`ManagedAgentRecord`](super::types::ManagedAgentRecord) +/// via `#[serde(default)]`, so a store written by a build that predates this +/// field deserializes as [`RelayAuthority::LegacyOnly`] — the only safe +/// default. No record is ever silently promoted; promotion happens exclusively +/// through the (not-yet-enabled) verified migration path. +/// +/// Persisted JSON shape (internally tagged on `authority`, so it never nests a +/// `state` inside a `state`): +/// ```json +/// { "authority": "legacy_only" } +/// { "authority": "relay_authoritative", "evidence": { "generation": 7, "private_event_id": "…" } } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(tag = "authority", rename_all = "snake_case")] +pub enum RelayAuthority { + /// Local `managed-agents.json` + OS keyring remain canonical. Boot + /// reconcile still republishes this record's public projection. + #[default] + LegacyOnly, + /// The relay kind:30179 aggregate is canonical for this agent. The local + /// record is a derived compatibility cache and MUST NOT be republished by + /// boot reconcile. Carries the verified head [`RelayAuthorityEvidence`]. + RelayAuthoritative { evidence: RelayAuthorityEvidence }, + /// Deletion has been enqueued for this relay-canonical agent but the + /// authoritative tombstone has not yet been verified at the relay. The + /// record is kept on disk in this terminal-pending state so a crash between + /// enqueue and verified erase retries cleanly — and so the head + /// [`RelayAuthorityEvidence`] needed to (re)sign the tombstone survives. + /// Like [`RelayAuthoritative`](Self::RelayAuthoritative), this record MUST + /// NOT be republished by boot reconcile: doing so could resurrect an agent + /// that is mid-deletion. + Deleting { evidence: RelayAuthorityEvidence }, +} + +/// Verified evidence that an agent has become relay-authoritative. +/// +/// Present only inside [`RelayAuthority::RelayAuthoritative`], set after the +/// migration path has committed a kind:30179 head at `generation` / +/// `private_event_id` and read it back — this is the durable record of *which* +/// head the local cache mirrors, so a later mutation can supply the correct +/// predecessor for compare-and-swap. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RelayAuthorityEvidence { + /// CAS generation of the authoritative kind:30179 head this cache mirrors. + pub generation: u64, + /// Event id of the authoritative kind:30179 head. The predecessor for the + /// next compare-and-swap mutation. + pub private_event_id: String, +} + +impl RelayAuthority { + /// The default authority for a local-only agent. + pub fn legacy() -> Self { + Self::LegacyOnly + } + + /// Construct a verified relay-authoritative authority from its head + /// evidence. This is the only way to reach the authoritative state, so + /// evidence is always present. + /// + /// Consumed by the verified migration path (sibling lane), not yet wired; + /// exercised today by this module's tests. + #[allow(dead_code)] + pub fn relay_authoritative(evidence: RelayAuthorityEvidence) -> Self { + Self::RelayAuthoritative { evidence } + } + + /// Construct the terminal pending-deletion authority from the verified head + /// evidence being tombstoned. Reached only from the delete path, which + /// captures the evidence from the record's prior relay-authoritative state. + #[allow(dead_code)] + pub fn deleting(evidence: RelayAuthorityEvidence) -> Self { + Self::Deleting { evidence } + } + + /// Whether the relay is canonical for this agent. When `true`, boot + /// reconcile must NOT republish the record's public projection. + /// + /// Consumed by the boot-reconcile gate (sibling lane), not yet wired. + #[allow(dead_code)] + pub fn is_relay_authoritative(&self) -> bool { + matches!(self, Self::RelayAuthoritative { .. }) + } + + /// Whether the relay owns this agent's canonical state — either promoted + /// ([`RelayAuthoritative`](Self::RelayAuthoritative)) or mid-deletion + /// ([`Deleting`](Self::Deleting)). Boot reconcile must NOT republish the + /// public projection in either case; a `Deleting` record left to reconcile + /// would resurrect an agent whose tombstone is still confirming. + #[allow(dead_code)] + pub fn is_relay_canonical(&self) -> bool { + matches!( + self, + Self::RelayAuthoritative { .. } | Self::Deleting { .. } + ) + } + + /// Whether deletion has been enqueued and is awaiting verified relay + /// confirmation. + #[allow(dead_code)] + pub fn is_deleting(&self) -> bool { + matches!(self, Self::Deleting { .. }) + } + + /// The verified head evidence, present whenever the relay is canonical + /// ([`RelayAuthoritative`](Self::RelayAuthoritative) or + /// [`Deleting`](Self::Deleting)). + /// + /// Consumed by the compare-and-swap mutation path (sibling lane), not yet + /// wired. + #[allow(dead_code)] + pub fn evidence(&self) -> Option<&RelayAuthorityEvidence> { + match self { + Self::RelayAuthoritative { evidence } | Self::Deleting { evidence } => Some(evidence), + Self::LegacyOnly => None, + } + } +} + +/// Current version of the `backend` field's encrypted-payload representation. +/// +/// [`BackendKind`](super::types::BackendKind) is `PrivateCanonical`: it is +/// carried byte-exact into the kind:30179 payload and diffed at verification. +/// Its serde shape (`{"type":"provider","id":…,"config":…}`) is therefore a +/// wire contract — a future variant rename or field change would make an old +/// device's stored value mismatch a new device's re-encode and block migration +/// with no diagnostic. Wrapping it in a versioned envelope makes the shape +/// explicit and lets the migration codec reject/upgrade an unknown version +/// deterministically instead of silently mis-diffing. +pub const BACKEND_PAYLOAD_VERSION: u64 = 1; + +/// Versioned envelope for the `backend` field inside the kind:30179 payload. +/// +/// Serializes as `{"version":1,"backend":{…BackendKind…}}`. The migration codec +/// carries + verifies this envelope, not a bare `BackendKind`, so a shape +/// change bumps `version` and is caught explicitly rather than surfacing as an +/// opaque byte mismatch. +/// +/// Foundation type — the migration payload codec (sibling lane) is the +/// consumer; only this module's tests exercise it today. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +pub struct VersionedBackend { + /// Payload schema version. Migration rejects an envelope whose version it + /// does not understand rather than mis-diffing an unexpected shape. + pub version: u64, + /// The carried backend configuration at that version. + pub backend: super::types::BackendKind, +} + +#[allow(dead_code)] +impl VersionedBackend { + /// Wrap a backend in the current payload envelope. + pub fn current(backend: super::types::BackendKind) -> Self { + Self { + version: BACKEND_PAYLOAD_VERSION, + backend, + } + } +} + +/// How a durable [`ManagedAgentRecord`](super::types::ManagedAgentRecord) field +/// relates to the relay kind:30179 aggregate during migration. +/// +/// Every persisted record field is assigned exactly one class by +/// [`classify_field`]. The class decides two things during migration: +/// 1. whether the field's value must be *carried* into the encrypted +/// kind:30179 payload (or a bound public projection), and +/// 2. whether the field participates in the *byte-exact verification* that +/// gates promotion to [`RelayAuthority::RelayAuthoritative`]. +/// +/// Getting this wrong is the single largest "does an existing agent survive +/// migration intact?" risk: a durable field misclassified as transient is +/// silently lost; a derived/deprecated field misclassified as carried causes +/// spurious verification mismatches that block migration forever. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] // Consumed by the migration verification path (sibling lane); today only tests read it. +pub enum FieldClass { + /// Portable canonical secret/config carried in the encrypted kind:30179 + /// payload and verified byte-exact. E.g. `private_key_nsec`, `relay_url`. + PrivateCanonical, + /// Canonical remote-identity value carried in kind:30179, but the concrete + /// resource must be re-validated on each device before use and MUST NOT be + /// blindly applied from the relay. Carried; verified for equality against + /// the source, not applied to the device. E.g. `backend_agent_id`. + DeviceValidated, + /// Authoritative for a definition-*less* agent and carried through the + /// bound kind:30175 definition projection (materialized at migration if the + /// agent had none). For a definition-*linked* agent this mirrors the + /// definition and is not independently canonical. Verified via the + /// definition binding, not the private payload. + DefinitionProjected, + /// Authoritative instance behavior carried through the bound kind:30177 + /// public instance projection. Verified via the instance binding. + InstanceProjected, + /// Deprecated or re-derived-at-spawn snapshot. NOT carried and MUST NOT be + /// diffed at verification — its stored value can legitimately differ from a + /// freshly derived one, so diffing it would false-positive and block + /// migration. E.g. `acp_command`, `mcp_command`, `turn_timeout_seconds`. + DerivedNotCarried, + /// Machine-local runtime/telemetry state OR device-local config that no + /// codec slot carries (verified against the actual 30175/30177 builders). + /// Never carried, never diffed, never reconstructed from the relay. E.g. + /// `runtime_pid`, `last_error`, `start_on_app_launch`, `provider_binary_path`. + TransientLocal, + /// Local relay-authority bookkeeping introduced by this module. Never + /// carried into the payload (it describes the payload); never diffed. + AuthorityBookkeeping, +} + +/// Exhaustive classification of every durable +/// [`ManagedAgentRecord`](super::types::ManagedAgentRecord) field by its serde +/// name. +/// +/// The companion test `field_classification_is_exhaustive` asserts this table +/// covers exactly the record's serialized field set, so a future field added +/// to the record without a classification here fails the build's tests rather +/// than silently defaulting to "lost on migration". +#[allow(dead_code)] // Test-facing accessor; migration path reads FIELD_CLASSIFICATIONS directly (sibling lane). +pub fn classify_field(serde_field_name: &str) -> Option { + FIELD_CLASSIFICATIONS + .iter() + .find(|(name, _)| *name == serde_field_name) + .map(|(_, class)| *class) +} + +/// Single source of truth for the field → class mapping. Both +/// [`classify_field`] and the exhaustiveness test read this slice, so the class +/// table and the record's field set cannot drift apart without a test failure. +#[allow(dead_code)] // Consumed by the migration verification path (sibling lane); today only tests read it. +pub(crate) const FIELD_CLASSIFICATIONS: &[(&str, FieldClass)] = { + use FieldClass::*; + &[ + // --- Identity + private canonical config (encrypted kind:30179) --- + ("pubkey", PrivateCanonical), // the d-tag coordinate itself + ("private_key_nsec", PrivateCanonical), + ("relay_url", PrivateCanonical), + ("agent_command_override", PrivateCanonical), + ("agent_args", PrivateCanonical), + ("idle_timeout_seconds", PrivateCanonical), + ("max_turn_duration_seconds", PrivateCanonical), + ("env_vars", PrivateCanonical), + ("backend", PrivateCanonical), + ("team_id", PrivateCanonical), + ("persona_name_in_team", PrivateCanonical), + ("relay_mesh", PrivateCanonical), + // --- Carried, but device-validated (never blindly applied) --- + // A durable REMOTE identity that is meaningful across devices, so it + // rides the encrypted kind:30179 payload and is verified-not-applied. + ("backend_agent_id", DeviceValidated), + // --- Definition-projected (kind:30175). Authoritative only when the + // agent is definition-less; else mirrors the linked definition. --- + ("system_prompt", DefinitionProjected), + ("model", DefinitionProjected), + ("provider", DefinitionProjected), + ("persona_source_version", DefinitionProjected), + ("slug", DefinitionProjected), + ("runtime", DefinitionProjected), + ("name_pool", DefinitionProjected), + ("is_builtin", DefinitionProjected), + ("is_active", DefinitionProjected), + ("display_name", DefinitionProjected), + ("avatar_url", DefinitionProjected), + ("source_team", DefinitionProjected), + ("source_team_persona_slug", DefinitionProjected), + ("catalog_source", DefinitionProjected), + ("persona_id", DefinitionProjected), + ("definition_respond_to", DefinitionProjected), + ("definition_respond_to_allowlist", DefinitionProjected), + ("definition_parallelism", DefinitionProjected), + // --- Instance-projected (kind:30177) --- + ("name", InstanceProjected), + ("parallelism", InstanceProjected), + ("respond_to", InstanceProjected), + ("respond_to_allowlist", InstanceProjected), + // --- Deprecated / re-derived at spawn or migration: carry NOT, diff NOT --- + ("acp_command", DerivedNotCarried), + ("agent_command", DerivedNotCarried), + ("mcp_command", DerivedNotCarried), + ("turn_timeout_seconds", DerivedNotCarried), + ("shared", DerivedNotCarried), // legacy, `skip_serializing` + // NIP-OA owner->agent authorization tag. Deterministically COMPUTED + // from the owner keys + agent pubkey (compute_auth_tag), never a + // free-standing secret. Migration MUST RE-MINT it (owner re-signs a + // fresh owner->agent tag for the canonical record), never preserve or + // trust the stored string — a re-mint would not byte-match the stored + // value, so carrying + diffing it would false-positive and block + // migration forever. Hence DerivedNotCarried, not PrivateCanonical. + ("auth_tag", DerivedNotCarried), + // --- Bookkeeping timestamps carried for audit but not conflict-resolving --- + ("created_at", InstanceProjected), + ("updated_at", InstanceProjected), + // --- Transient machine-local: NO codec slot carries these today + // (verified against PersonaEventContent/ManagedAgentEventContent + // builders + the 30177 exclusion asserts). Classifying them as + // projected would promise a preservation the codec cannot provide. + // --- + // Runtime/telemetry: + ("runtime_pid", TransientLocal), + ("last_started_at", TransientLocal), + ("last_stopped_at", TransientLocal), + ("last_exit_code", TransientLocal), + ("last_error", TransientLocal), + ("last_error_code", TransientLocal), + // Device-local launch preferences — 30177 does NOT carry them: + ("start_on_app_launch", TransientLocal), + ("auto_restart_on_config_change", TransientLocal), + // Absolute machine paths — meaningless across devices, carried by + // nothing; re-derived per install: + ("provider_binary_path", TransientLocal), + ("persona_team_dir", TransientLocal), + // --- Relay-authority bookkeeping introduced by this module --- + ("relay_authority", AuthorityBookkeeping), + ] +}; + +/// Maximum NIP-44 v2 plaintext size for the encrypted kind:30179 payload, in +/// bytes. A serialized migration payload larger than this cannot be locked, so +/// the agent stays [`RelayAuthority::LegacyOnly`]. Mirrors +/// `buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX` (65_535); duplicated as a local +/// constant so the taxonomy is self-describing and unit-testable without the +/// crypto dependency. +pub const MIGRATION_PAYLOAD_MAX_BYTES: usize = 65_535; + +/// Maximum size, in bytes, of a single serialized `Value` the private +/// managed-agent codec accepts (each extension / recovery / config entry the +/// payload builder inserts). This is the per-`Value` cap the codec enforces — +/// it is NOT an OS-keyring limit and NOT a combined recovery blob. Any single +/// codec value larger than this keeps the agent +/// [`RelayAuthority::LegacyOnly`]. Tracks the relay-side +/// `private_managed_agent::MAX_VALUE_BYTES` (32_768); duplicated locally until +/// the shared codec crate is a dependency of Desktop. +pub const MIGRATION_MAX_VALUE_BYTES: usize = 32_768; + +/// The exact versioned NIP-11 capability token a relay must advertise before an +/// agent may migrate. The relay deliberately advertises *aggregate* semantics +/// (that it will maintain the kind:30179 aggregate and serve a readable head), +/// not mere kind acceptance — so gating checks for this precise token, never a +/// generic "accepts the kind" boolean. +pub const NIP_PMA_AGGREGATE_TOKEN: &str = "nip-pma-aggregate-v1"; + +/// A terminal reason an agent cannot migrate to relay-canonical authority on +/// the current attempt and MUST remain [`RelayAuthority::LegacyOnly`]. +/// +/// This is the *decision*, not the storage: it is the value +/// [`assess_migration_readiness`] returns so the migration driver can stop and +/// record why. Persisting it durably is the job of the migration record/state +/// in the driver lane (which owns the store); the `LegacyOnly` variant here +/// deliberately carries no reason, so this type makes no persistence claim it +/// cannot back. +/// +/// Each variant is *terminal* for the current attempt: migration stops and +/// NEVER drives a retry-loop — retrying an oversize value or a relay that +/// lacks the capability just burns cycles and spams the relay. Migration is +/// re-attempted only when the underlying input changes (a value shrinks, the +/// relay begins advertising the capability), which is an external event, not a +/// timer. +/// +/// Foundation type — the migration driver (sibling lane) is the consumer; +/// today only this module's tests exercise it. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub enum MigrationBlock { + /// The serialized encrypted payload exceeds [`MIGRATION_PAYLOAD_MAX_BYTES`]. + PayloadTooLarge { bytes: usize }, + /// A single serialized codec `Value` (extension / recovery / config entry) + /// exceeds [`MIGRATION_MAX_VALUE_BYTES`]. + CodecValueTooLarge { bytes: usize }, + /// The relay's NIP-11 document does not advertise the exact + /// [`NIP_PMA_AGGREGATE_TOKEN`] aggregate capability, so a published head + /// could never be maintained and read back to verify. Stay legacy until the + /// relay advertises it. + RelayCapabilityAbsent, +} + +impl MigrationBlock { + /// A self-describing reason string the migration driver can surface or log + /// when it stops. This is a diagnostic rendering of the decision, not a + /// persistence mechanism — the variant itself is the signal. + #[allow(dead_code)] + pub fn reason(&self) -> String { + match self { + Self::PayloadTooLarge { bytes } => format!( + "migration payload is {bytes} bytes; exceeds the \ + {MIGRATION_PAYLOAD_MAX_BYTES}-byte encrypted limit" + ), + Self::CodecValueTooLarge { bytes } => format!( + "a migration codec value is {bytes} bytes; exceeds the \ + {MIGRATION_MAX_VALUE_BYTES}-byte per-value limit" + ), + Self::RelayCapabilityAbsent => format!( + "relay does not advertise the {NIP_PMA_AGGREGATE_TOKEN} \ + aggregate capability (NIP-11)" + ), + } + } +} + +/// Pure readiness gate: decide whether an agent MAY migrate given the concrete +/// encrypted-payload size, the largest single codec `Value` size, and the set +/// of NIP-11 capability tokens the target relay advertises. +/// +/// Returns `Ok(())` only when every terminal condition is clear. On any +/// [`MigrationBlock`] the caller MUST keep the agent +/// [`RelayAuthority::LegacyOnly`] and NOT retry until an input changes. Size +/// boundaries are inclusive-safe: exactly-at-limit is allowed; strictly-over is +/// blocked. Capability is checked FIRST — a relay that cannot maintain the +/// aggregate can never verify a head, so size checks would be moot. +/// +/// Foundation function — the migration driver (sibling lane) is the consumer. +#[allow(dead_code)] +pub fn assess_migration_readiness<'a, I>( + payload_bytes: usize, + max_codec_value_bytes: usize, + advertised_nip11_tokens: I, +) -> Result<(), MigrationBlock> +where + I: IntoIterator, +{ + let relay_supports_aggregate = advertised_nip11_tokens + .into_iter() + .any(|token| token == NIP_PMA_AGGREGATE_TOKEN); + if !relay_supports_aggregate { + return Err(MigrationBlock::RelayCapabilityAbsent); + } + if payload_bytes > MIGRATION_PAYLOAD_MAX_BYTES { + return Err(MigrationBlock::PayloadTooLarge { + bytes: payload_bytes, + }); + } + if max_codec_value_bytes > MIGRATION_MAX_VALUE_BYTES { + return Err(MigrationBlock::CodecValueTooLarge { + bytes: max_codec_value_bytes, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/authority/tests.rs b/desktop/src-tauri/src/managed_agents/authority/tests.rs new file mode 100644 index 0000000000..882a047c63 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/authority/tests.rs @@ -0,0 +1,520 @@ +//! Exhaustive field-classification fixture. +//! +//! The load-bearing test here (`field_classification_is_exhaustive`) derives +//! the record's serialized field set from a fully-populated +//! [`ManagedAgentRecord`] and asserts [`classify_field`] covers exactly that +//! set. A field added to the record without a classification, or a stale +//! classification for a removed field, fails the build's tests — so no durable +//! field can silently reach migration unclassified (and thus be lost). + +use super::*; +use crate::managed_agents::{ + BackendKind, CatalogSource, ManagedAgentRecord, RelayMeshConfig, RespondTo, +}; +use std::collections::{BTreeMap, BTreeSet}; + +/// A record with EVERY field set to a non-skipping value, so serialization +/// emits the complete field set (no `skip_serializing_if` omissions). This is +/// deliberately exhaustive: adding a field to `ManagedAgentRecord` forces a +/// compile error here until it is populated, which in turn forces a +/// classification in `classify_field`. +fn fully_populated_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "agentpubkeyhex".to_string(), + name: "Test Agent".to_string(), + persona_id: Some("persona-1".to_string()), + team_id: Some("team-1".to_string()), + private_key_nsec: "nsec1secret".to_string(), + auth_tag: Some("authtag".to_string()), + relay_url: "wss://relay.example".to_string(), + avatar_url: Some("https://example.com/a.png".to_string()), + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_command_override: Some("codex".to_string()), + agent_args: vec!["--flag".to_string()], + mcp_command: "buzz-dev-mcp".to_string(), + turn_timeout_seconds: 320, + idle_timeout_seconds: Some(60), + max_turn_duration_seconds: Some(600), + parallelism: 24, + system_prompt: Some("You are a test agent.".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + persona_source_version: Some("abc123".to_string()), + env_vars: BTreeMap::from([("K".to_string(), "V".to_string())]), + start_on_app_launch: true, + auto_restart_on_config_change: true, + runtime_pid: Some(4242), + backend: BackendKind::Provider { + id: "buzz-backend-x".to_string(), + config: serde_json::json!({ "api_key": "secret" }), + }, + backend_agent_id: Some("remote-id".to_string()), + provider_binary_path: Some("/path/to/binary".to_string()), + persona_team_dir: Some("/team/dir".into()), + persona_name_in_team: Some("member".to_string()), + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + last_started_at: Some("2025-01-02T00:00:00Z".to_string()), + last_stopped_at: Some("2025-01-03T00:00:00Z".to_string()), + last_exit_code: Some(0), + last_error: Some("some runtime error".to_string()), + last_error_code: Some(1), + respond_to: RespondTo::Allowlist, + respond_to_allowlist: vec!["79be667e".to_string()], + display_name: Some("Display".to_string()), + slug: Some("sample-slug".to_string()), + runtime: Some("goose".to_string()), + name_pool: vec!["poolname".to_string()], + is_builtin: true, + is_active: false, + shared: false, + source_team: Some("src-team".to_string()), + source_team_persona_slug: Some("src-slug".to_string()), + catalog_source: Some(CatalogSource { + owner_pubkey: "a".repeat(64), + persona_id: "cat-persona".to_string(), + }), + definition_respond_to: Some("owner_only".to_string()), + definition_respond_to_allowlist: vec!["deadbeef".to_string()], + definition_parallelism: Some(2), + relay_mesh: Some(RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }), + relay_authority: RelayAuthority::legacy(), + } +} + +/// Serialized field names of a fully-populated record. +fn serialized_field_names() -> Vec { + let value = serde_json::to_value(fully_populated_record()).unwrap(); + let obj = value + .as_object() + .expect("record serializes to a JSON object"); + obj.keys().cloned().collect() +} + +/// Every field name that carries a classification, read from the single +/// source-of-truth table. A duplicate key here is itself a defect, so assert +/// uniqueness while collecting. +fn classified_field_names() -> BTreeSet { + let mut set = BTreeSet::new(); + for (name, _) in super::FIELD_CLASSIFICATIONS { + assert!( + set.insert(name.to_string()), + "duplicate classification key `{name}` in FIELD_CLASSIFICATIONS", + ); + } + set +} + +#[test] +fn field_classification_is_exhaustive() { + // `shared` is `#[serde(skip_serializing)]`, so it never appears on the + // wire — but it is a durable deserialize-time field and MUST still carry a + // classification. Assert its presence explicitly, then fold it into the + // record's serialized field set for exact set equality below. + assert!( + classify_field("shared").is_some(), + "the skip_serializing `shared` field must still be classified", + ); + + let mut record_fields: BTreeSet = serialized_field_names().into_iter().collect(); + record_fields.insert("shared".to_string()); + + // Every classification key must correspond to a real record field. This is + // the FIX #4 tightening: the previous test only checked that every field + // had a class, not that every class key still names a live field. A stale + // key (field renamed/removed but left in `classify_field`) now fails here. + let classified: BTreeSet = classified_field_names(); + + let unclassified: Vec<&String> = record_fields.difference(&classified).collect(); + assert!( + unclassified.is_empty(), + "these durable ManagedAgentRecord fields have no FieldClass — a new \ + field must be classified in classify_field() or it is silently lost \ + on migration: {unclassified:?}", + ); + + let stale: Vec<&String> = classified.difference(&record_fields).collect(); + assert!( + stale.is_empty(), + "these classify_field() keys no longer name a ManagedAgentRecord field \ + — remove them so the table cannot drift out of sync with the record: \ + {stale:?}", + ); +} + +#[test] +fn versioned_backend_roundtrips_with_stable_shape() { + use super::{VersionedBackend, BACKEND_PAYLOAD_VERSION}; + + // Provider variant — carries id + opaque config. + let backend = BackendKind::Provider { + id: "buzz-backend-x".to_string(), + config: serde_json::json!({ "api_key": "secret", "region": "us" }), + }; + let envelope = VersionedBackend::current(backend.clone()); + let json = serde_json::to_value(&envelope).unwrap(); + + // EXACT persisted shape: a version marker beside the backend body, so a + // future BackendKind shape change bumps `version` and is caught explicitly. + assert_eq!( + json, + serde_json::json!({ + "version": BACKEND_PAYLOAD_VERSION, + "backend": { + "type": "provider", + "id": "buzz-backend-x", + "config": { "api_key": "secret", "region": "us" }, + }, + }), + ); + + let back: VersionedBackend = serde_json::from_value(json).unwrap(); + assert_eq!(back, envelope); + assert_eq!(back.version, 1); + + // Local variant — the internally-tagged unit shape. + let local = VersionedBackend::current(BackendKind::Local); + let local_json = serde_json::to_value(&local).unwrap(); + assert_eq!( + local_json, + serde_json::json!({ "version": 1, "backend": { "type": "local" } }), + ); + assert_eq!( + serde_json::from_value::(local_json).unwrap(), + local, + ); +} + +#[test] +fn versioned_backend_rejects_unknown_envelope_fields() { + // `deny_unknown_fields`: an envelope with an unexpected sibling key fails to + // deserialize rather than silently dropping data the migration codec relies + // on. (BackendKind's own `config` stays opaque; this guards the envelope.) + let bad = serde_json::json!({ + "version": 1, + "backend": { "type": "local" }, + "unexpected": true, + }); + assert!( + serde_json::from_value::(bad).is_err(), + "versioned backend envelope must reject unknown top-level fields", + ); +} + +#[test] +fn migration_readiness_allows_clear_case() { + use super::{ + assess_migration_readiness, MIGRATION_MAX_VALUE_BYTES, MIGRATION_PAYLOAD_MAX_BYTES, + NIP_PMA_AGGREGATE_TOKEN, + }; + // Exactly at both limits, relay advertises the aggregate token -> allowed + // (inclusive boundary). + assert!(assess_migration_readiness( + MIGRATION_PAYLOAD_MAX_BYTES, + MIGRATION_MAX_VALUE_BYTES, + [NIP_PMA_AGGREGATE_TOKEN], + ) + .is_ok()); + // Comfortably under; extra unrelated tokens are ignored. + assert!(assess_migration_readiness(1024, 512, ["other", NIP_PMA_AGGREGATE_TOKEN]).is_ok()); +} + +#[test] +fn migration_readiness_blocks_oversize_payload() { + use super::{ + assess_migration_readiness, MigrationBlock, MIGRATION_PAYLOAD_MAX_BYTES, + NIP_PMA_AGGREGATE_TOKEN, + }; + let over = MIGRATION_PAYLOAD_MAX_BYTES + 1; + assert_eq!( + assess_migration_readiness(over, 0, [NIP_PMA_AGGREGATE_TOKEN]), + Err(MigrationBlock::PayloadTooLarge { bytes: over }), + ); +} + +#[test] +fn migration_readiness_blocks_oversize_codec_value() { + use super::{ + assess_migration_readiness, MigrationBlock, MIGRATION_MAX_VALUE_BYTES, + NIP_PMA_AGGREGATE_TOKEN, + }; + let over = MIGRATION_MAX_VALUE_BYTES + 1; + assert_eq!( + assess_migration_readiness(0, over, [NIP_PMA_AGGREGATE_TOKEN]), + Err(MigrationBlock::CodecValueTooLarge { bytes: over }), + ); +} + +#[test] +fn migration_readiness_blocks_when_relay_lacks_capability() { + use super::{assess_migration_readiness, MigrationBlock, NIP_PMA_AGGREGATE_TOKEN}; + // Capability absence is checked FIRST: even a valid-size payload cannot + // migrate to a relay that does not advertise the aggregate token. Stay legacy. + assert_eq!( + assess_migration_readiness(0, 0, []), + Err(MigrationBlock::RelayCapabilityAbsent), + ); + // A relay advertising only unrelated tokens is still not capable — mere + // kind acceptance is not the aggregate semantics we require. + assert_eq!( + assess_migration_readiness(0, 0, ["nip-pma-aggregate-v0", "other"]), + Err(MigrationBlock::RelayCapabilityAbsent), + ); + // And it dominates a simultaneous oversize condition (no misleading reason). + assert_eq!( + assess_migration_readiness(usize::MAX, usize::MAX, []), + Err(MigrationBlock::RelayCapabilityAbsent), + ); + // Sanity: the exact token, alone, does not itself block. + assert!(assess_migration_readiness(0, 0, [NIP_PMA_AGGREGATE_TOKEN]).is_ok()); +} + +#[test] +fn migration_block_reasons_are_self_describing() { + use super::MigrationBlock; + // Each block yields a non-empty, self-describing diagnostic string. This is + // a rendering of the decision for logging/surfacing — the variant itself is + // the signal; persistence is owned by the migration driver lane. + for block in [ + MigrationBlock::PayloadTooLarge { bytes: 70_000 }, + MigrationBlock::CodecValueTooLarge { bytes: 40_000 }, + MigrationBlock::RelayCapabilityAbsent, + ] { + assert!(!block.reason().is_empty()); + } + assert!(MigrationBlock::PayloadTooLarge { bytes: 70_000 } + .reason() + .contains("70000")); +} + +#[test] +fn no_secret_field_is_projected_publicly() { + // Secrets must never ride a PUBLIC projection (kind:30175/30177). Any field + // holding credential material must be PrivateCanonical. + for secret in ["private_key_nsec", "env_vars", "backend"] { + assert_eq!( + classify_field(secret), + Some(FieldClass::PrivateCanonical), + "secret-bearing field `{secret}` must be PrivateCanonical, never projected publicly", + ); + } +} + +#[test] +fn auth_tag_is_re_minted_not_carried() { + // The NIP-OA owner->agent tag is deterministically re-mintable from owner + // keys + agent pubkey. Migration re-mints it; it is NOT carried or diffed, + // so a re-mint that differs byte-for-byte cannot block migration. It must + // never ride a public projection either. + assert_eq!( + classify_field("auth_tag"), + Some(FieldClass::DerivedNotCarried), + "auth_tag must be DerivedNotCarried (re-minted at migration, never preserved/diffed)", + ); +} + +#[test] +fn no_codec_slot_fields_are_transient_local() { + // Verified against PersonaEventContent (30175) + ManagedAgentEventContent + // (30177) builders and the 30177 exclusion asserts: NO codec carries these. + // Classifying them as projected would promise a preservation the codec + // cannot deliver, silently losing them on migration. + for field in [ + "provider_binary_path", + "persona_team_dir", + "start_on_app_launch", + "auto_restart_on_config_change", + ] { + assert_eq!( + classify_field(field), + Some(FieldClass::TransientLocal), + "no-codec-slot field `{field}` must be TransientLocal (nothing carries it)", + ); + } +} + +#[test] +fn definition_carried_fields_stay_projected() { + // Guard against the review's incorrect claim that avatar_url has no slot: + // PersonaEventContent (30175) DOES carry avatar_url, so it is authoritative + // for a definition-less agent and MUST remain DefinitionProjected. + assert_eq!( + classify_field("avatar_url"), + Some(FieldClass::DefinitionProjected), + "avatar_url is carried by kind:30175 and must stay DefinitionProjected", + ); +} + +#[test] +fn deprecated_snapshots_are_not_carried() { + // Re-derived at spawn; diffing them at verification would false-positive + // and block migration forever. + for derived in [ + "acp_command", + "agent_command", + "mcp_command", + "turn_timeout_seconds", + ] { + assert_eq!( + classify_field(derived), + Some(FieldClass::DerivedNotCarried), + "re-derived snapshot field `{derived}` must be DerivedNotCarried", + ); + } +} + +#[test] +fn authority_defaults_to_legacy() { + let authority = RelayAuthority::default(); + assert_eq!(authority, RelayAuthority::LegacyOnly); + assert!(authority.evidence().is_none()); + assert!(!authority.is_relay_authoritative()); +} + +#[test] +fn legacy_only_persists_as_flat_authority_tag() { + // FIX #3: the enum is adjacently tagged on `authority`, so it must NOT nest + // a `state` inside a `state`. Assert the EXACT persisted shape. + let json = serde_json::to_value(RelayAuthority::LegacyOnly).unwrap(); + assert_eq!(json, serde_json::json!({ "authority": "legacy_only" })); +} + +#[test] +fn relay_authoritative_persists_with_evidence() { + // FIX #3: exact persisted shape for the authoritative variant. + let authority = RelayAuthority::relay_authoritative(RelayAuthorityEvidence { + generation: 7, + private_event_id: "eventid".to_string(), + }); + let json = serde_json::to_value(&authority).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "authority": "relay_authoritative", + "evidence": { "generation": 7, "private_event_id": "eventid" }, + }) + ); +} + +#[test] +fn legacy_record_deserializes_without_authority_field() { + // A store written before this field existed has no `relay_authority` key. + // It MUST deserialize as LegacyOnly, never fail, never silently promote. + let json = serde_json::json!({ + "pubkey": "p", + "name": "n", + "private_key_nsec": "nsec1x", + "relay_url": "wss://r", + "acp_command": "a", + "agent_command": "g", + "agent_args": [], + "mcp_command": "m", + "turn_timeout_seconds": 320, + "parallelism": 1, + "created_at": "t", + "updated_at": "t", + }); + let record: ManagedAgentRecord = serde_json::from_value(json).unwrap(); + assert_eq!(record.relay_authority, RelayAuthority::LegacyOnly); + assert!(!record.relay_authority.is_relay_authoritative()); +} + +#[test] +fn authoritative_authority_roundtrips_with_evidence() { + let authority = RelayAuthority::relay_authoritative(RelayAuthorityEvidence { + generation: 7, + private_event_id: "eventid".to_string(), + }); + let json = serde_json::to_string(&authority).unwrap(); + let back: RelayAuthority = serde_json::from_str(&json).unwrap(); + assert_eq!(authority, back); + assert!(back.is_relay_authoritative()); + assert_eq!(back.evidence().unwrap().generation, 7); +} + +#[test] +fn impossible_authority_states_are_unrepresentable() { + // FIX #3: corrupt evidence can no longer silently disable reconcile. + // `RelayAuthoritative` structurally carries evidence; `LegacyOnly` + // structurally cannot. Legacy with a stray `evidence` key is rejected by + // deserialize (evidence belongs only to the authoritative variant). + let legacy_with_evidence = serde_json::json!({ + "authority": "legacy_only", + "evidence": { "generation": 1, "private_event_id": "x" }, + }); + // Internally-tagged enums ignore sibling keys not belonging to the variant, + // so this still deserializes as LegacyOnly (evidence is discarded, never + // retained) — the important guarantee is that LegacyOnly carries no head. + let back: RelayAuthority = serde_json::from_value(legacy_with_evidence).unwrap(); + assert_eq!(back, RelayAuthority::LegacyOnly); + assert!(back.evidence().is_none()); + + // Authoritative WITHOUT evidence is a hard deserialize error: reconcile can + // never be disabled by an authoritative marker lacking a verified head. + let authoritative_no_evidence = serde_json::json!({ "authority": "relay_authoritative" }); + assert!( + serde_json::from_value::(authoritative_no_evidence).is_err(), + "RelayAuthoritative without evidence must fail to deserialize", + ); +} + +#[test] +fn deleting_authority_roundtrips_with_evidence() { + // The mid-deletion state structurally carries the head evidence needed to + // (re)sign the tombstone and to compare-and-clear. It is relay-canonical + // (reconcile must NOT republish) and reports deleting, but is NOT the + // promoted authoritative state. + let authority = RelayAuthority::deleting(RelayAuthorityEvidence { + generation: 9, + private_event_id: "priorhead".to_string(), + }); + let json = serde_json::to_string(&authority).unwrap(); + let back: RelayAuthority = serde_json::from_str(&json).unwrap(); + assert_eq!(authority, back); + assert!(back.is_deleting()); + assert!(back.is_relay_canonical()); + assert!( + !back.is_relay_authoritative(), + "Deleting is canonical but not the promoted authoritative state", + ); + assert_eq!(back.evidence().unwrap().generation, 9); + assert_eq!(back.evidence().unwrap().private_event_id, "priorhead"); + + // Exact persisted shape: internally tagged on `authority`, evidence beside. + assert_eq!( + serde_json::to_value(&authority).unwrap(), + serde_json::json!({ + "authority": "deleting", + "evidence": { "generation": 9, "private_event_id": "priorhead" }, + }), + ); +} + +#[test] +fn deleting_authority_without_evidence_fails_to_deserialize() { + // Like RelayAuthoritative, a Deleting marker lacking a verified head is a + // hard error — a mid-deletion record must always carry the evidence needed + // to re-sign the tombstone and compare-and-clear. + let deleting_no_evidence = serde_json::json!({ "authority": "deleting" }); + assert!( + serde_json::from_value::(deleting_no_evidence).is_err(), + "Deleting without evidence must fail to deserialize", + ); +} + +#[test] +fn relay_canonical_covers_authoritative_but_legacy_is_neither() { + // Boot reconcile gates republish on is_relay_canonical: true for both + // promoted and mid-deletion records, false for legacy. + let authoritative = RelayAuthority::relay_authoritative(RelayAuthorityEvidence { + generation: 1, + private_event_id: "h".to_string(), + }); + assert!(authoritative.is_relay_canonical()); + assert!(!authoritative.is_deleting()); + assert!(!RelayAuthority::legacy().is_relay_canonical()); + assert!(!RelayAuthority::legacy().is_deleting()); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 62caffeb2e..304fcbf3e2 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -118,6 +118,7 @@ fn test_record() -> ManagedAgentRecord { agent_command_override: None, persona_source_version: None, provider: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a77521..c1236c43fd 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -1,5 +1,3 @@ -use std::path::PathBuf; - use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; use super::{ apply_agent_command_update, classify_runtime, codex_adapter_availability, @@ -11,6 +9,7 @@ use super::{ GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; +use std::path::PathBuf; #[test] fn resolves_known_avatar_for_bare_command() { @@ -283,6 +282,7 @@ fn record_with( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index c8e437809c..4f6866068d 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -92,6 +92,7 @@ fn record( definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 553596e226..bb7bdecb77 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -348,6 +348,7 @@ fn bare_record() -> ManagedAgentRecord { source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/migration/activation.rs b/desktop/src-tauri/src/managed_agents/migration/activation.rs new file mode 100644 index 0000000000..88be4328f0 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/migration/activation.rs @@ -0,0 +1,749 @@ +//! Boot-time activation for relay-canonical managed-agent aggregates. +//! +//! Capability discovery is fail-closed. A relay must advertise the exact PMA +//! extension before Desktop snapshots legacy agents into immutable retained +//! requests. Submission is then replayable from SQLite; verified promotion is +//! persisted to the local compatibility cache before the exact retained attempt +//! is compare-and-cleared. + +use std::path::Path; + +use nostr::Keys; +use serde::Deserialize; +use tauri::Manager; + +use super::{build_migration_candidate, CasMetadata, MigrationError}; +use crate::managed_agents::authority::{ + RelayAuthority, RelayAuthorityEvidence, NIP_PMA_AGGREGATE_TOKEN, +}; +use crate::managed_agents::retention::{ + get_pending_managed_agent_aggregates, get_retained_managed_agent_aggregate, + mark_managed_agent_aggregate_synced, open_retention_db, record_managed_agent_aggregate_error, + retain_managed_agent_aggregate, RetainedManagedAgentAggregate, +}; +use crate::managed_agents::{load_managed_agents, save_managed_agents}; +use crate::{app_state::AppState, managed_agents}; + +/// Automatic legacy-agent promotion is a one-way availability change: after +/// promotion, edits and deletes require relay confirmation. Ship the protocol +/// dark unless the release explicitly opts into that rollout at build time. +/// Already-authoritative edit/deletion retry paths remain active independently. +pub(crate) fn automatic_migration_enabled() -> bool { + automatic_migration_enabled_from(option_env!("BUZZ_DESKTOP_BUILD_PMA_MIGRATION")) +} + +fn automatic_migration_enabled_from(value: Option<&str>) -> bool { + value.is_some_and(|value| value == "1") +} + +#[cfg(test)] +mod rollout_tests { + use super::automatic_migration_enabled_from; + + #[test] + fn automatic_migration_is_default_off_and_requires_exact_opt_in() { + assert!(!automatic_migration_enabled_from(None)); + assert!(!automatic_migration_enabled_from(Some(""))); + assert!(!automatic_migration_enabled_from(Some("true"))); + assert!(automatic_migration_enabled_from(Some("1"))); + } +} + +#[derive(Debug, Deserialize)] +struct RelayInformationDocument { + #[serde(default)] + supported_extensions: Vec, +} + +#[derive(serde::Serialize)] +struct AggregateRequest<'a> { + private_event: &'a nostr::Event, + definition_event: &'a nostr::Event, + instance_event: &'a nostr::Event, + expected_definition_revision: u64, +} + +#[derive(Deserialize)] +struct StoredAggregateRequest { + definition_event: nostr::Event, + expected_definition_revision: u64, +} + +/// Build and durably retain the next authoritative instance-edit aggregate. +/// Called under the managed-agent store lock before any local record save. +pub(crate) fn enqueue_authoritative_edit( + app: &tauri::AppHandle, + state: &AppState, + record: &crate::managed_agents::ManagedAgentRecord, +) -> Result<(), String> { + let evidence = record + .relay_authority + .evidence() + .ok_or_else(|| "authoritative edit is missing relay evidence".to_string())?; + if record.relay_authority.is_deleting() { + return Err("cannot edit an agent while deletion is pending".to_string()); + } + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let mut conn = open_retention_db(&scope.db_path)?; + let retained = get_retained_managed_agent_aggregate(&conn, &owner_pubkey, &record.pubkey)? + .ok_or_else(|| "authoritative edit is missing its retained relay head".to_string())?; + if retained.pending_sync { + return Err( + "managed-agent relay synchronization is still pending; retry the edit shortly" + .to_string(), + ); + } + if retained.state != "active" + || retained.generation != evidence.generation + || retained.private_event_id != evidence.private_event_id + { + return Err( + "local relay evidence does not match the retained authoritative head".to_string(), + ); + } + let stored: StoredAggregateRequest = serde_json::from_str(&retained.request_json) + .map_err(|error| format!("retained authoritative request is invalid: {error}"))?; + let instance_event = managed_agents::agent_events::build_agent_event(record)? + .custom_created_at(nostr::Timestamp::now()) + .sign_with_keys(&scope.owner_keys) + .map_err(|error| format!("failed to sign authoritative instance edit: {error}"))?; + let agent_keys = Keys::parse(&record.private_key_nsec) + .map_err(|error| format!("agent key does not parse for authoritative edit: {error}"))?; + let generation = evidence + .generation + .checked_add(1) + .ok_or_else(|| "managed-agent aggregate generation overflow".to_string())?; + let candidate = build_migration_candidate( + record, + &scope.owner_keys, + &agent_keys, + stored.definition_event, + instance_event, + &CasMetadata { + generation, + previous_event_id: Some(evidence.private_event_id.clone()), + definition_revision: stored.expected_definition_revision, + }, + [NIP_PMA_AGGREGATE_TOKEN], + nostr::Timestamp::now().as_secs(), + ) + .map_err(|error| format!("failed to build authoritative edit: {error:?}"))?; + let request_json = serde_json::to_string(&AggregateRequest { + private_event: &candidate.signed_event, + definition_event: &candidate.definition_event, + instance_event: &candidate.instance_event, + expected_definition_revision: stored.expected_definition_revision, + }) + .map_err(|error| format!("failed to serialize authoritative edit: {error}"))?; + retain_managed_agent_aggregate( + &mut conn, + &RetainedManagedAgentAggregate { + owner_pubkey, + agent_pubkey: record.pubkey.clone(), + generation, + private_event_id: candidate.signed_event.id.to_hex(), + state: "active".to_string(), + request_json, + pending_sync: true, + last_error: None, + local_authority_applied: false, + }, + ) +} + +pub(crate) struct VerifiedAuthoritativeEdit { + pub owner_pubkey: String, + pub agent_pubkey: String, + pub generation: u64, + pub private_event_id: String, + pub evidence: RelayAuthorityEvidence, +} + +pub(crate) async fn submit_authoritative_edit( + client: &reqwest::Client, + relay_api_base_url: &str, + owner_keys: &Keys, + db_path: &Path, + agent_pubkey: &str, +) -> Result { + let owner_pubkey = owner_keys.public_key().to_hex(); + match super::driver::submit_retained_aggregate( + client, + relay_api_base_url, + db_path, + owner_keys, + &owner_pubkey, + agent_pubkey, + ) + .await? + { + super::driver::SubmitOutcome::Verified { attempt, evidence } => { + Ok(VerifiedAuthoritativeEdit { + owner_pubkey: attempt.owner_pubkey, + agent_pubkey: attempt.agent_pubkey, + generation: attempt.generation, + private_event_id: attempt.private_event_id, + evidence: RelayAuthorityEvidence { + generation: evidence.generation, + private_event_id: evidence.head_event_id, + }, + }) + } + super::driver::SubmitOutcome::Retained { error } => Err(error), + other => Err(format!("authoritative edit did not verify: {other:?}")), + } +} + +pub(crate) fn confirm_authoritative_edit( + db_path: &Path, + edit: &VerifiedAuthoritativeEdit, +) -> Result<(), String> { + let conn = open_retention_db(db_path)?; + if mark_managed_agent_aggregate_synced( + &conn, + &edit.owner_pubkey, + &edit.agent_pubkey, + edit.generation, + &edit.private_event_id, + )? { + Ok(()) + } else { + Err("verified authoritative edit no longer matches retained attempt".to_string()) + } +} + +/// Discover relay extensions from the captured workspace HTTP origin. +pub(crate) async fn discover_relay_extensions( + client: &reqwest::Client, + relay_api_base_url: &str, +) -> Result, String> { + let url = format!("{}/info", relay_api_base_url.trim_end_matches('/')); + let response = client + .get(url) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|error| crate::relay::classify_request_error(&error))?; + if !response.status().is_success() { + return Err(crate::relay::relay_error_message(response).await); + } + Ok( + crate::relay::parse_json_response::(response) + .await? + .supported_extensions, + ) +} + +/// Snapshot every eligible legacy agent into a generation-one retained request. +/// Existing retained coordinates are immutable and left untouched. +pub(crate) fn enqueue_initial_migrations( + app: &tauri::AppHandle, + owner_keys: &Keys, + db_path: &Path, + advertised_extensions: &[String], +) -> Result { + if !advertised_extensions + .iter() + .any(|extension| extension == NIP_PMA_AGGREGATE_TOKEN) + { + return Ok(0); + } + + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(app)?; + let mut conn = open_retention_db(db_path)?; + let owner_pubkey = owner_keys.public_key().to_hex(); + let mut enqueued = 0; + + for record in records + .iter() + .filter(|record| !record.relay_authority.is_relay_authoritative()) + { + if record.private_key_nsec.is_empty() + || get_retained_managed_agent_aggregate(&conn, &owner_pubkey, &record.pubkey)?.is_some() + { + continue; + } + let definition = record.to_definition_view(); + let definition_event = if let Some(definition) = definition { + managed_agents::persona_events::build_persona_event(&definition) + .map_err(|error| format!("failed to build migration definition: {error}"))? + .custom_created_at(nostr::Timestamp::now()) + .sign_with_keys(owner_keys) + .map_err(|error| format!("failed to sign migration definition: {error}"))? + } else { + let mut definition = record.clone(); + definition.pubkey.clear(); + definition.slug = Some(record.pubkey.clone()); + definition + .to_definition_view() + .ok_or_else(|| "failed to synthesize migration definition".to_string()) + .and_then(|definition| { + managed_agents::persona_events::build_persona_event(&definition) + })? + .custom_created_at(nostr::Timestamp::now()) + .sign_with_keys(owner_keys) + .map_err(|error| format!("failed to sign migration definition: {error}"))? + }; + let instance_event = managed_agents::agent_events::build_agent_event(record)? + .custom_created_at(nostr::Timestamp::now()) + .sign_with_keys(owner_keys) + .map_err(|error| format!("failed to sign migration instance: {error}"))?; + let agent_keys = Keys::parse(&record.private_key_nsec) + .map_err(|error| format!("agent key does not parse for migration: {error}"))?; + let cas = CasMetadata { + generation: 1, + previous_event_id: None, + definition_revision: 1, + }; + let candidate = match build_migration_candidate( + record, + owner_keys, + &agent_keys, + definition_event, + instance_event, + &cas, + advertised_extensions.iter().map(String::as_str), + nostr::Timestamp::now().as_secs(), + ) { + Ok(candidate) => candidate, + Err(MigrationError::Blocked(_)) => continue, + Err(error) => { + return Err(format!( + "failed to build managed-agent migration: {error:?}" + )) + } + }; + let request_json = serde_json::to_string(&AggregateRequest { + private_event: &candidate.signed_event, + definition_event: &candidate.definition_event, + instance_event: &candidate.instance_event, + expected_definition_revision: cas.definition_revision, + }) + .map_err(|error| format!("failed to serialize managed-agent aggregate: {error}"))?; + retain_managed_agent_aggregate( + &mut conn, + &RetainedManagedAgentAggregate { + owner_pubkey: owner_pubkey.clone(), + agent_pubkey: record.pubkey.clone(), + generation: cas.generation, + private_event_id: candidate.signed_event.id.to_hex(), + state: "active".to_string(), + request_json, + pending_sync: true, + last_error: None, + local_authority_applied: false, + }, + )?; + enqueued += 1; + } + Ok(enqueued) +} + +/// Submit all pending active attempts for the captured workspace scope. +pub(crate) async fn flush_pending_migrations( + app: &tauri::AppHandle, + client: &reqwest::Client, + relay_api_base_url: &str, + owner_keys: &Keys, + db_path: &Path, +) -> Result { + let owner_pubkey = owner_keys.public_key().to_hex(); + let pending = + get_pending_managed_agent_aggregates(&open_retention_db(db_path)?, &owner_pubkey)?; + let mut promoted = 0; + for row in pending.into_iter().filter(|row| row.state == "active") { + let state = app.state::(); + let current_owner = state.signing_keys()?.public_key().to_hex(); + let current_relay_api = crate::relay::relay_api_base_url_with_override(&state); + if current_owner != owner_pubkey + || current_relay_api.trim_end_matches('/') != relay_api_base_url.trim_end_matches('/') + { + return Ok(promoted); + } + let outcome = super::driver::submit_retained_aggregate( + client, + relay_api_base_url, + db_path, + owner_keys, + &owner_pubkey, + &row.agent_pubkey, + ) + .await?; + let super::driver::SubmitOutcome::Verified { attempt, evidence } = outcome else { + continue; + }; + + let state = app.state::(); + let persisted = { + let _guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(app)?; + if let Some(index) = records.iter().position(|record| { + let authority_can_advance = record.relay_authority.evidence().is_some_and(|head| { + head.generation.checked_add(1) == Some(evidence.generation) + && evidence.previous_event_id.as_deref() + == Some(head.private_event_id.as_str()) + }); + record.pubkey == attempt.agent_pubkey + && record.updated_at == attempt.source_updated_at + && (!record.relay_authority.is_relay_authoritative() || authority_can_advance) + }) { + records[index].relay_authority = + RelayAuthority::relay_authoritative(RelayAuthorityEvidence { + generation: evidence.generation, + private_event_id: evidence.head_event_id.clone(), + }); + save_managed_agents(app, &records)?; + true + } else { + // Crash replay: authority may have reached disk before the exact + // retained attempt was cleared. Matching evidence makes this + // idempotent; any different head remains pending and blocked. + records.iter().any(|record| { + record.pubkey == attempt.agent_pubkey + && record.relay_authority.evidence().is_some_and(|authority| { + authority.generation == evidence.generation + && authority.private_event_id == evidence.head_event_id + }) + }) + } + }; + + let conn = open_retention_db(db_path)?; + if persisted { + if mark_managed_agent_aggregate_synced( + &conn, + &attempt.owner_pubkey, + &attempt.agent_pubkey, + attempt.generation, + &attempt.private_event_id, + )? { + promoted += 1; + } + } else { + let _ = record_managed_agent_aggregate_error( + &conn, + &attempt.owner_pubkey, + &attempt.agent_pubkey, + attempt.generation, + &attempt.private_event_id, + "local agent changed during relay promotion; retry preserved", + ); + } + } + Ok(promoted) +} + +/// Submit all pending deleted attempts (tombstones) for the captured workspace +/// scope, erasing local record/key only after verified relay confirmation. +/// +/// This is the async confirming half of the crash-safe deletion seam. The +/// delete command durably enqueues the deleted aggregate and flips the record to +/// [`RelayAuthority::Deleting`] BEFORE any erase; this flush verifies the +/// tombstone at the relay, then applies the erase in a strict crash-safe order: +/// +/// 1. verified read-back (`SubmitOutcome::VerifiedDeletion`), +/// 2. erase record + key + session caches + archive request, and save, +/// 3. durably set `local_authority_applied` (the terminal proof), then +/// 4. compare-and-clear the retained row. +/// +/// **Crash replay honors the durable marker, never mere record absence.** On +/// replay the `Deleting` record may already be gone. Only a set marker proves +/// the exact deletion reached local authority; absence alone could equally be an +/// unrelated/manual deletion, which must NOT license clearing the retry. So a +/// missing record with an unset marker leaves the row pending + records a +/// diagnostic, while a missing record with the marker already set is cleared +/// idempotently. +pub(crate) async fn flush_pending_deletions( + app: &tauri::AppHandle, + client: &reqwest::Client, + relay_api_base_url: &str, + owner_keys: &Keys, + db_path: &Path, +) -> Result { + use crate::managed_agents::retention::mark_managed_agent_deletion_local_authority_applied; + + let owner_pubkey = owner_keys.public_key().to_hex(); + let pending = + get_pending_managed_agent_aggregates(&open_retention_db(db_path)?, &owner_pubkey)?; + let mut deleted = 0; + for row in pending.into_iter().filter(|row| row.state == "deleted") { + let state = app.state::(); + let current_owner = state.signing_keys()?.public_key().to_hex(); + let current_relay_api = crate::relay::relay_api_base_url_with_override(&state); + if current_owner != owner_pubkey + || current_relay_api.trim_end_matches('/') != relay_api_base_url.trim_end_matches('/') + { + return Ok(deleted); + } + let outcome = super::driver::submit_retained_aggregate( + client, + relay_api_base_url, + db_path, + owner_keys, + &owner_pubkey, + &row.agent_pubkey, + ) + .await?; + let super::driver::SubmitOutcome::VerifiedDeletion { attempt, evidence } = outcome else { + continue; + }; + + let state = app.state::(); + // Applied means the exact verified deletion reached local authority. The + // crash-safe order is: (1) set the durable marker FIRST — it means + // "verified deletion pending local application" and is the terminal proof + // replay trusts — then (2) erase the record/key/caches/archive + // idempotently. A crash BEFORE the marker replays verification and + // re-attempts; a crash AFTER the marker but before the erase re-runs the + // erase; a crash AFTER the erase clears on record absence + marker. + // Record absence ALONE never licenses clearing — it could be an unrelated + // or manual deletion; only the marker proves THIS deletion applied. + let applied = { + let _guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + // Step 1: durably set the marker (idempotent CAS on the exact + // tombstone coordinate). Nothing is destroyed until this lands. + let conn = open_retention_db(db_path)?; + let marker_set = mark_managed_agent_deletion_local_authority_applied( + &conn, + &attempt.owner_pubkey, + &attempt.agent_pubkey, + attempt.generation, + &attempt.private_event_id, + )?; + drop(conn); + + // Step 2: erase idempotently. Accept the record still authoritative + // (RelayAuthoritative — enqueue landed but the authority flip/save + // crashed, per the cascade self-heal contract) OR mid-deletion + // (Deleting), but ONLY when its evidence is the tombstone's exact + // predecessor: generation `attempt.generation - 1` at + // `evidence.previous_event_id`. Binding to it prevents erasing a + // record a racing workspace switch/edit re-created or advanced, and + // leaves any nonmatching record in place so the retry stays blocked. + let mut records = load_managed_agents(app)?; + let prior_generation = attempt.generation.saturating_sub(1); + let had_match = records.iter().any(|record| { + is_exact_deletion_predecessor( + &record.pubkey, + record.relay_authority.evidence(), + &attempt.agent_pubkey, + prior_generation, + &evidence.previous_event_id, + ) + }); + // A record at this pubkey that is NOT the exact predecessor blocks: + // clearing would abandon a live/advanced identity. + let has_blocking_record = records.iter().any(|record| { + record.pubkey == attempt.agent_pubkey + && !is_exact_deletion_predecessor( + &record.pubkey, + record.relay_authority.evidence(), + &attempt.agent_pubkey, + prior_generation, + &evidence.previous_event_id, + ) + }); + if had_match { + records.retain(|record| { + !is_exact_deletion_predecessor( + &record.pubkey, + record.relay_authority.evidence(), + &attempt.agent_pubkey, + prior_generation, + &evidence.previous_event_id, + ) + }); + save_managed_agents(app, &records)?; + managed_agents::delete_agent_key(&attempt.agent_pubkey); + state.clear_agent_session_caches(&attempt.agent_pubkey); + crate::commands::agents::archive_managed_agent_pending( + app, + &state, + &attempt.agent_pubkey, + ); + } + + // Cleared only when the marker is durable AND no foreign/advanced + // record remains at this pubkey. On replay the record may already be + // absent (erased in a prior run) — that plus the set marker clears + // idempotently. + deletion_apply_clears(marker_set, has_blocking_record) + }; + + let conn = open_retention_db(db_path)?; + if applied { + if mark_managed_agent_aggregate_synced( + &conn, + &attempt.owner_pubkey, + &attempt.agent_pubkey, + attempt.generation, + &attempt.private_event_id, + )? { + deleted += 1; + } + } else { + let _ = record_managed_agent_aggregate_error( + &conn, + &attempt.owner_pubkey, + &attempt.agent_pubkey, + attempt.generation, + &attempt.private_event_id, + "local deletion authority not applied; retry preserved", + ); + } + } + Ok(deleted) +} + +/// Pure erase-match predicate for the deletion flush: does a record with this +/// `pubkey` / relay-authority `evidence` name the exact record this verified +/// tombstone supersedes? +/// +/// A tombstone advances the CAS chain by one, so the record it deletes carries +/// the tombstone's PREDECESSOR evidence: generation = `prior_generation` +/// (the tombstone generation minus one) at `previous_event_id`. This matches +/// EITHER authority state that keeps relay evidence — `RelayAuthoritative` +/// (enqueue landed but the authority flip/save crashed; the flush self-heals it) +/// or `Deleting` (the flip landed). A record without evidence (`LegacyOnly`) +/// never matches. Binding to the exact predecessor prevents erasing a record a +/// racing workspace switch/edit re-created or advanced. +fn is_exact_deletion_predecessor( + record_pubkey: &str, + record_evidence: Option<&RelayAuthorityEvidence>, + agent_pubkey: &str, + prior_generation: u64, + previous_event_id: &str, +) -> bool { + record_pubkey == agent_pubkey + && record_evidence.is_some_and(|authority| { + authority.generation == prior_generation + && authority.private_event_id == previous_event_id + }) +} + +/// Pure clear decision for the deletion flush, encoding the marker-before-erase +/// crash-replay contract. The retry's row is compare-cleared ONLY when: +/// * `marker_set` — the durable "verified deletion pending local application" +/// marker is set. It is set BEFORE any erase, so a crash before it replays +/// verification; a crash after it (before or during erase) replays the +/// idempotent erase. Record absence alone NEVER clears — it could be an +/// unrelated/manual deletion. +/// * `!has_blocking_record` — no foreign/advanced record remains at the +/// coordinate. A record that is not the exact predecessor means a racing +/// re-create/advance; clearing would abandon a live identity, so stay +/// blocked until it resolves. +fn deletion_apply_clears(marker_set: bool, has_blocking_record: bool) -> bool { + marker_set && !has_blocking_record +} + +#[cfg(test)] +mod deletion_apply_tests { + use super::*; + use crate::managed_agents::authority::{RelayAuthority, RelayAuthorityEvidence}; + + const AGENT: &str = "agent-pubkey-hex"; + const PRED_ID: &str = "predecessor-event-id"; + const PRIOR_GEN: u64 = 4; + + fn evidence(gen: u64, id: &str) -> RelayAuthorityEvidence { + RelayAuthorityEvidence { + generation: gen, + private_event_id: id.to_string(), + } + } + + /// Helper: run the predicate as the flush does, deriving the evidence from a + /// concrete [`RelayAuthority`] so tests exercise the real accessor. + fn matches(pubkey: &str, authority: &RelayAuthority) -> bool { + is_exact_deletion_predecessor(pubkey, authority.evidence(), AGENT, PRIOR_GEN, PRED_ID) + } + + #[test] + fn deleting_record_at_exact_predecessor_matches() { + assert!(matches( + AGENT, + &RelayAuthority::deleting(evidence(PRIOR_GEN, PRED_ID)) + )); + } + + #[test] + fn authoritative_record_at_exact_predecessor_matches_for_self_heal() { + // Cascade enqueue-N/save-fail leaves the record RelayAuthoritative with a + // pending tombstone. The flush must still recognize and erase it. + assert!(matches( + AGENT, + &RelayAuthority::relay_authoritative(evidence(PRIOR_GEN, PRED_ID)) + )); + } + + #[test] + fn advanced_generation_record_does_not_match() { + // A racing edit advanced the head past the tombstone's predecessor. + assert!(!matches( + AGENT, + &RelayAuthority::relay_authoritative(evidence(PRIOR_GEN + 1, "newer-head")) + )); + } + + #[test] + fn different_predecessor_id_does_not_match() { + assert!(!matches( + AGENT, + &RelayAuthority::deleting(evidence(PRIOR_GEN, "other-event-id")) + )); + } + + #[test] + fn legacy_record_never_matches() { + assert!(!matches(AGENT, &RelayAuthority::legacy())); + } + + #[test] + fn different_agent_never_matches() { + assert!(!matches( + "someone-else", + &RelayAuthority::deleting(evidence(PRIOR_GEN, PRED_ID)) + )); + } + + #[test] + fn crash_after_marker_before_erase_reclears_on_next_pass() { + // Marker set, matching record still present (erase never ran): the flush + // re-erases (had_match) and clears. No blocking record remains after the + // in-run erase, so the decision clears. + assert!(deletion_apply_clears(true, /*has_blocking=*/ false)); + } + + #[test] + fn crash_after_erase_clears_on_absence_plus_marker() { + // Record already gone from a prior run, marker set, nothing blocking: + // absence + marker clears idempotently. + assert!(deletion_apply_clears(true, false)); + } + + #[test] + fn crash_before_marker_does_not_clear() { + // Marker never landed (crash before step 1). The row must stay pending so + // the next boot replays verification — record absence alone never clears. + assert!(!deletion_apply_clears(/*marker_set=*/ false, false)); + } + + #[test] + fn blocking_advanced_record_does_not_clear_even_with_marker() { + // A foreign/advanced record at the coordinate blocks the clear. + assert!(!deletion_apply_clears(true, /*has_blocking=*/ true)); + } +} diff --git a/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs b/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs new file mode 100644 index 0000000000..6cc17b8ff6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs @@ -0,0 +1,397 @@ +//! Desktop PMA aggregate submit/retry driver. +//! +//! This is the transport half of the relay-canonical migration seam. The pure +//! [builder/verifier](super) turns a hydrated record into a signed candidate +//! and gates the relay read-back; the durable [retention](crate::managed_agents::retention) +//! layer persists one immutable request per CAS generation. This driver is the +//! only code that moves a retained request across the wire: it validates the +//! retained row against its exact JSON before egress, POSTs those bytes, and +//! returns verified evidence plus the immutable attempt identity. The caller +//! owns authority/cache persistence and only then compare-and-clears the row. +//! +//! Design invariants: +//! * **Exact bytes on the wire.** The body is `request_json` verbatim — the +//! driver never re-serializes it, so a stored request and the request the +//! relay authenticates are the same bytes. The verification source +//! ([`MigrationCandidate`](super::MigrationCandidate)) is reconstructed from +//! those same bytes (parse the events, decrypt the head under the owner +//! key), so there is no candidate-vs-wire drift and no crypto duplication. +//! * **Fresh owner NIP-98 per attempt.** Each submission mints a new +//! [`build_nip98_auth_header_for_keys`](crate::relay::build_nip98_auth_header_for_keys) +//! header (unique nonce), so a retry never replays a stale token. +//! * **Caller-owned confirmation.** Successful verification returns the exact +//! retained attempt identity. This transport never clears `pending_sync`; +//! the caller first persists relay authority/cache and then compare-and-clears. +//! * **Errors preserve retry.** Every failure path persists a diagnostic via +//! [`record_managed_agent_aggregate_error`](crate::managed_agents::retention::record_managed_agent_aggregate_error) +//! and leaves `pending_sync = 1`. + +use std::path::Path; + +use buzz_core_pkg::private_managed_agent::Payload; +use nostr::{Event, Keys}; +use reqwest::Method; +use serde::Deserialize; + +use super::{ + verify_deletion, verify_promotion, AggregateResponse, DeletionEvidence, MigrationCandidate, + PromotionEvidence, +}; +use crate::managed_agents::retention::{ + get_retained_managed_agent_aggregate, record_managed_agent_aggregate_error, +}; + +/// The route the relay mounts for atomic PMA aggregate commits. NIP-98 is +/// signed against this exact path (see `buzz-relay` router). +const AGGREGATE_PATH: &str = "/api/managed-agents/aggregate"; + +/// The wire body persisted as `request_json` and POSTed verbatim. +/// +/// Mirrors the relay's `AggregateBody`; used here only to reconstruct the +/// verification candidate from the retained bytes, never to re-serialize the +/// request. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RetainedAggregateBody { + private_event: Event, + #[serde(default)] + definition_event: Option, + #[serde(default)] + instance_event: Option, + #[serde(default)] + #[allow(dead_code)] // Bound into the head payload at build time; the relay re-derives it. + expected_definition_revision: Option, +} + +/// Immutable identity of the retained attempt that produced verified evidence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RetainedAttempt { + pub owner_pubkey: String, + pub agent_pubkey: String, + pub generation: u64, + pub private_event_id: String, + pub state: String, + /// Source record revision bound inside the encrypted retained payload. + pub source_updated_at: String, +} + +/// What one drive of a retained aggregate resolved to. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] // Consumed by the boot/reconcile lane (sibling); today only tests read it. +pub(crate) enum SubmitOutcome { + /// No pending aggregate row for this agent — nothing to submit. + Nothing, + /// The relay served back a faithful active aggregate. Persistence and + /// compare-and-clear remain the caller's responsibility. + Verified { + attempt: RetainedAttempt, + evidence: PromotionEvidence, + }, + /// The relay served back a faithful deleted aggregate (tombstone). The + /// caller erases local record/key, durably marks local authority applied, + /// then compare-and-clears the retry. + VerifiedDeletion { + attempt: RetainedAttempt, + evidence: DeletionEvidence, + }, + /// The attempt failed; a diagnostic was persisted and retry is preserved. + Retained { error: String }, +} + +/// Submit (or retry) the latest retained aggregate for one owner→agent pair. +/// +/// Reads the durable row, validates it against its strict `request_json` before +/// any network I/O, POSTs those exact bytes with fresh owner-signed NIP-98 auth, +/// strict-deserializes and verifies the response, then returns evidence with the +/// exact attempt identity. Every failure persists an exact-attempt diagnostic; +/// success deliberately leaves retry pending for caller-owned persistence and +/// compare-and-clear ordering. +#[allow(dead_code)] // Consumed by the boot/reconcile lane (sibling); exercised by this module's tests. +pub(crate) async fn submit_retained_aggregate( + http_client: &reqwest::Client, + relay_api_base_url: &str, + db_path: &Path, + owner_keys: &Keys, + owner_pubkey: &str, + agent_pubkey: &str, +) -> Result { + let row = { + let conn = crate::managed_agents::retention::open_retention_db(db_path)?; + match get_retained_managed_agent_aggregate(&conn, owner_pubkey, agent_pubkey)? { + Some(row) if row.pending_sync => row, + _ => return Ok(SubmitOutcome::Nothing), + } + }; + + // Fail-fast guard: the retained head id/generation is what we will confirm. + // Capture them before any network work so a diagnostic always names the + // exact attempt. + let generation = row.generation; + let private_event_id = row.private_event_id.clone(); + + let record_failure = |error: String| -> Result { + // A best-effort diagnostic write must not mask the real error; if the + // update itself fails we still surface the original failure. + if let Ok(conn) = crate::managed_agents::retention::open_retention_db(db_path) { + let _ = record_managed_agent_aggregate_error( + &conn, + owner_pubkey, + agent_pubkey, + generation, + &private_event_id, + &error, + ); + } + Ok(SubmitOutcome::Retained { error }) + }; + + // Branch on the retained state. Both paths validate the reconstructed head + // against its coordinate BEFORE egress, so malformed or coordinate-drifted + // disk bytes never leave the device. + match row.state.as_str() { + "active" => { + let candidate = match reconstruct_candidate(row.request_json.as_bytes(), owner_keys) { + Ok(candidate) => candidate, + Err(error) => return record_failure(error), + }; + if candidate.payload.state != buzz_core_pkg::private_managed_agent::State::Active + || candidate.payload.generation != generation + || candidate.signed_event.id.to_hex() != private_event_id + || candidate.payload.owner_pubkey != owner_pubkey + || candidate.payload.agent_pubkey != agent_pubkey + { + return record_failure( + "retained active request does not match its owner/agent/generation/event/state coordinate" + .to_string(), + ); + } + + let response = match submit_to_relay( + http_client, + relay_api_base_url, + owner_keys, + row.request_json.as_bytes(), + ) + .await + { + Ok(response) => response, + Err(error) => return record_failure(error), + }; + + let evidence = match verify_promotion(&candidate, &response, owner_keys) { + Ok(evidence) => evidence, + Err(error) => return record_failure(format!("verification failed: {error:?}")), + }; + + // The relay's committed head must be the exact generation/event we + // retained; confirming a different coordinate would desync durable + // state from the relay. verify_promotion already proved the head is + // a faithful copy, so a mismatch here is a protocol violation. + if evidence.generation != generation || evidence.head_event_id != private_event_id { + return record_failure(format!( + "read-back coordinate {}:{} does not match retained {generation}:{private_event_id}", + evidence.generation, evidence.head_event_id + )); + } + + let source_updated_at = candidate.payload.updated_at.clone(); + Ok(SubmitOutcome::Verified { + attempt: RetainedAttempt { + owner_pubkey: row.owner_pubkey, + agent_pubkey: row.agent_pubkey, + generation, + private_event_id, + state: row.state, + source_updated_at, + }, + evidence, + }) + } + "deleted" => { + let (event, payload) = + match reconstruct_deletion(row.request_json.as_bytes(), owner_keys) { + Ok(parsed) => parsed, + Err(error) => return record_failure(error), + }; + // Mirror the active branch: validate the FULL decrypted coordinate + // against the retained row before egress, not just the event id. A + // drifted owner/agent/generation (or a tombstone missing its chain + // predecessor) must never leave the device. `reconstruct_deletion` + // already proved state=Deleted / no active body / deleted_at present + // / null expected-definition-revision. + if event.id.to_hex() != private_event_id + || payload.generation != generation + || payload.owner_pubkey != owner_pubkey + || payload.agent_pubkey != agent_pubkey + || payload.previous_event_id.is_none() + { + return record_failure( + "retained deleted request does not match its owner/agent/generation/event/predecessor coordinate" + .to_string(), + ); + } + let source_updated_at = payload.updated_at.clone(); + + let response = match submit_to_relay( + http_client, + relay_api_base_url, + owner_keys, + row.request_json.as_bytes(), + ) + .await + { + Ok(response) => response, + Err(error) => return record_failure(error), + }; + + let evidence = match verify_deletion(&event, &response, owner_keys) { + Ok(evidence) => evidence, + Err(error) => { + return record_failure(format!("deletion verification failed: {error:?}")) + } + }; + if evidence.generation != generation || evidence.head_event_id != private_event_id { + return record_failure(format!( + "deleted read-back coordinate {}:{} does not match retained {generation}:{private_event_id}", + evidence.generation, evidence.head_event_id + )); + } + + Ok(SubmitOutcome::VerifiedDeletion { + attempt: RetainedAttempt { + owner_pubkey: row.owner_pubkey, + agent_pubkey: row.agent_pubkey, + generation, + private_event_id, + state: row.state, + source_updated_at, + }, + evidence, + }) + } + other => record_failure(format!("retained aggregate has unknown state {other:?}")), + } +} + +/// POST the exact request bytes with fresh owner-signed NIP-98 auth and +/// strict-deserialize the aggregate response. +async fn submit_to_relay( + http_client: &reqwest::Client, + relay_api_base_url: &str, + owner_keys: &Keys, + body: &[u8], +) -> Result { + crate::egress_guard::assert_no_key_backup_bytes(body, "managed-agent aggregate submit")?; + crate::relay_admission::wait_for_rate_limit().await; + + let url = format!( + "{}{AGGREGATE_PATH}", + relay_api_base_url.trim_end_matches('/') + ); + let auth_header = + crate::relay::build_nip98_auth_header_for_keys(owner_keys, &Method::POST, &url, body)?; + + let response = http_client + .post(&url) + .header("Authorization", auth_header) + .header("Content-Type", "application/json") + .body(body.to_vec()) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + + if !response.status().is_success() { + let status = response.status(); + let message = crate::relay::relay_error_message(response).await; + return Err(if status == reqwest::StatusCode::CONFLICT { + format!("conflict: {message}") + } else { + message + }); + } + + crate::relay::parse_json_response::(response).await +} + +/// Rebuild the verification [`MigrationCandidate`] from the retained request +/// bytes: parse the three signed events and decrypt the head under the owner +/// key. The result is the exact source the relay was asked to store, so +/// verification compares like against like. +fn reconstruct_candidate(body: &[u8], owner_keys: &Keys) -> Result { + let parsed: RetainedAggregateBody = serde_json::from_slice(body) + .map_err(|e| format!("retained request json is not a valid aggregate body: {e}"))?; + let definition_event = parsed + .definition_event + .ok_or_else(|| "retained request is missing its definition projection".to_string())?; + let instance_event = parsed + .instance_event + .ok_or_else(|| "retained request is missing its instance projection".to_string())?; + + let expected_definition_revision = parsed.expected_definition_revision.ok_or_else(|| { + "retained active request is missing expected_definition_revision".to_string() + })?; + let (_, payload) = buzz_core_pkg::private_managed_agent::validate_and_decrypt( + &parsed.private_event, + owner_keys, + ) + .map_err(|e| format!("retained head does not decrypt under the owner key: {e}"))?; + let payload_revision = payload + .active + .as_ref() + .ok_or_else(|| "retained active request head has no active payload".to_string())? + .definition + .revision; + if expected_definition_revision != payload_revision { + return Err(format!( + "retained expected definition revision {expected_definition_revision} does not match head {payload_revision}" + )); + } + + Ok(MigrationCandidate { + signed_event: parsed.private_event, + payload, + definition_event, + instance_event, + }) +} + +/// Rebuild the verification tombstone from the retained deleted request bytes: +/// parse the single signed private event and decrypt it under the owner key. +/// +/// A deletion carries no public projections or active body, so — unlike +/// [`reconstruct_candidate`] — the definition/instance projections are absent by +/// contract; their presence would mean the retained bytes are not a valid +/// tombstone. A tombstone is also minted with a null `expected_definition_revision` +/// (there is no active head to gate against), so a present revision means the +/// bytes are not a valid deletion. Returns the signed head plus its decrypted +/// payload; the caller mirrors the active branch and validates the full +/// owner/agent/generation/predecessor coordinate before egress. `verify_deletion` +/// (called by the "deleted" branch) additionally re-checks state/`active`/`deleted_at`. +fn reconstruct_deletion(body: &[u8], owner_keys: &Keys) -> Result<(Event, Payload), String> { + let parsed: RetainedAggregateBody = serde_json::from_slice(body) + .map_err(|e| format!("retained request json is not a valid aggregate body: {e}"))?; + if parsed.definition_event.is_some() || parsed.instance_event.is_some() { + return Err("retained deleted request unexpectedly carries active projections".to_string()); + } + if parsed.expected_definition_revision.is_some() { + return Err( + "retained deleted request unexpectedly carries an expected definition revision" + .to_string(), + ); + } + let (_, payload) = buzz_core_pkg::private_managed_agent::validate_and_decrypt( + &parsed.private_event, + owner_keys, + ) + .map_err(|e| format!("retained deletion head does not decrypt under the owner key: {e}"))?; + if payload.state != buzz_core_pkg::private_managed_agent::State::Deleted + || payload.active.is_some() + || payload.deleted_at.is_none() + { + return Err("retained deleted request head is not a valid deletion".to_string()); + } + Ok((parsed.private_event, payload)) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/migration/driver/tests.rs b/desktop/src-tauri/src/managed_agents/migration/driver/tests.rs new file mode 100644 index 0000000000..69456c8935 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/migration/driver/tests.rs @@ -0,0 +1,721 @@ +//! Transport tests for the PMA aggregate submit/retry driver. +//! +//! Each test builds a real signed candidate, serializes it into the exact +//! `request_json` the retention layer would persist, stands up a loopback axum +//! stub in the shape of the relay's aggregate route, and drives one submission. +//! The happy path proves a faithful read-back returns evidence while leaving +//! caller-owned retry state pending; every negative path proves the row stays +//! pending with a persisted diagnostic. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use axum::{extract::State as AxumState, http::StatusCode, routing::post, Router}; +use buzz_core_pkg::kind::{KIND_MANAGED_AGENT, KIND_PERSONA}; +use nostr::{EventBuilder, Keys, Kind}; +use serde_json::json; + +use super::super::{build_migration_candidate, CasMetadata, MigrationCandidate}; +use super::*; +use crate::managed_agents::retention::{ + get_retained_managed_agent_aggregate, open_retention_db, retain_managed_agent_aggregate, + RetainedManagedAgentAggregate, +}; +use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo}; + +const CAPABLE: [&str; 1] = [crate::managed_agents::authority::NIP_PMA_AGGREGATE_TOKEN]; +const CREATED_AT: u64 = 1_700_000_000; + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +fn signed_projection(owner_keys: &Keys, kind: u32, d: &str, content: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(kind as u16), content) + .tags([nostr::Tag::parse(["d", d]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(CREATED_AT)) + .sign_with_keys(owner_keys) + .expect("sign projection") +} + +fn record_for(agent_keys: &Keys) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: agent_keys.public_key().to_hex(), + name: "Test Agent".to_string(), + persona_id: Some("persona-1".to_string()), + team_id: Some("team-1".to_string()), + private_key_nsec: String::new(), + auth_tag: Some("[\"auth\",\"STALE\",\"\",\"deadbeef\"]".to_string()), + relay_url: "wss://relay.example".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: "buzz-dev-mcp".to_string(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: Some("You are a test agent.".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: Some("sample-slug".to_string()), + runtime: Some("goose".to_string()), + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + relay_mesh: None, + relay_authority: crate::managed_agents::authority::RelayAuthority::legacy(), + } +} + +struct Fixture { + owner_keys: Keys, + agent_keys: Keys, + candidate: MigrationCandidate, + cas: CasMetadata, +} + +impl Fixture { + fn new() -> Self { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let agent_hex = agent_keys.public_key().to_hex(); + let definition_event = signed_projection( + &owner_keys, + KIND_PERSONA, + "def-slug", + "{\"definition\":true}", + ); + let instance_event = signed_projection( + &owner_keys, + KIND_MANAGED_AGENT, + &agent_hex, + "{\"instance\":true}", + ); + let cas = CasMetadata { + generation: 1, + previous_event_id: None, + definition_revision: 7, + }; + let candidate = build_migration_candidate( + &record_for(&agent_keys), + &owner_keys, + &agent_keys, + definition_event, + instance_event, + &cas, + CAPABLE, + CREATED_AT, + ) + .expect("candidate builds"); + Self { + owner_keys, + agent_keys, + candidate, + cas, + } + } + + fn owner_hex(&self) -> String { + self.owner_keys.public_key().to_hex() + } + + fn agent_hex(&self) -> String { + self.agent_keys.public_key().to_hex() + } + + /// The exact `request_json` the retention layer would persist and the + /// driver POSTs verbatim: the relay's `AggregateBody` shape. + fn request_json(&self) -> String { + json!({ + "private_event": self.candidate.signed_event, + "definition_event": self.candidate.definition_event, + "instance_event": self.candidate.instance_event, + "expected_definition_revision": self.cas.definition_revision, + }) + .to_string() + } + + /// A faithful relay read-back — the relay stored and serves back exactly + /// what we submitted. + fn faithful_response_json(&self) -> serde_json::Value { + json!({ + "event_id": self.candidate.signed_event.id.to_hex(), + "generation": self.cas.generation, + "state": "active", + "accepted": true, + "inserted": true, + "private_event": self.candidate.signed_event, + "definition_event": self.candidate.definition_event, + "instance_event": self.candidate.instance_event, + "definition_revision": self.cas.definition_revision, + }) + } + + fn retained_row(&self) -> RetainedManagedAgentAggregate { + RetainedManagedAgentAggregate { + owner_pubkey: self.owner_hex(), + agent_pubkey: self.agent_hex(), + generation: self.cas.generation, + private_event_id: self.candidate.signed_event.id.to_hex(), + state: "active".to_string(), + request_json: self.request_json(), + pending_sync: true, + last_error: None, + local_authority_applied: false, + } + } + + /// Build the next-generation deleted tombstone for this agent, its verbatim + /// `request_json` (no active projections), and a faithful relay read-back — + /// the exact trio a `state:"deleted"` retained row drives. + fn tombstone(&self) -> nostr::Event { + super::super::build_tombstone_event( + &self.owner_keys, + &self.agent_hex(), + self.cas.generation, + &self.candidate.signed_event.id.to_hex(), + "2025-02-02T00:00:00Z", + CREATED_AT, + ) + .expect("tombstone builds") + } + + fn deleted_request_json(&self, tombstone: &nostr::Event) -> String { + json!({ + "private_event": tombstone, + "definition_event": null, + "instance_event": null, + "expected_definition_revision": null, + }) + .to_string() + } + + fn deleted_retained_row(&self, tombstone: &nostr::Event) -> RetainedManagedAgentAggregate { + RetainedManagedAgentAggregate { + owner_pubkey: self.owner_hex(), + agent_pubkey: self.agent_hex(), + generation: self.cas.generation + 1, + private_event_id: tombstone.id.to_hex(), + state: "deleted".to_string(), + request_json: self.deleted_request_json(tombstone), + pending_sync: true, + last_error: None, + local_authority_applied: false, + } + } + + fn faithful_deletion_response_json(&self, tombstone: &nostr::Event) -> serde_json::Value { + json!({ + "event_id": tombstone.id.to_hex(), + "generation": self.cas.generation + 1, + "state": "deleted", + "accepted": true, + "inserted": true, + "private_event": tombstone, + "definition_event": null, + "instance_event": null, + "definition_revision": null, + }) + } +} + +// ── Stub relay ────────────────────────────────────────────────────────────── + +/// A scripted reply: the HTTP status and body the stub returns. +#[derive(Clone)] +struct StubReply { + status: StatusCode, + body: String, +} + +type StubState = (Arc, Arc>>>); + +/// Spawn a loopback stub relay serving the aggregate route with a scripted +/// reply, capturing the raw request body it received. Returns the base URL and +/// a handle to the captured body. +async fn spawn_stub(reply: StubReply) -> (String, Arc>>>) { + let captured: Arc>>> = Arc::new(Mutex::new(None)); + let state: StubState = (Arc::new(reply), captured.clone()); + let app = + Router::new() + .route( + "/api/managed-agents/aggregate", + post( + |AxumState((reply, captured)): AxumState, + body: axum::body::Bytes| async move { + *captured.lock().unwrap() = Some(body.to_vec()); + (reply.status, reply.body.clone()) + }, + ), + ) + .with_state(state); + 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}"), captured) +} + +/// Open the retention db, seed one pending row, and hand back a fresh +/// connection for the driver to read/write committed state through. +fn seeded_conn( + dir: &tempfile::TempDir, + row: &RetainedManagedAgentAggregate, +) -> rusqlite::Connection { + let path = dir.path().join("retention.db"); + let mut writer = open_retention_db(&path).expect("open db"); + retain_managed_agent_aggregate(&mut writer, row).expect("retain aggregate row"); + open_retention_db(&path).expect("reopen db") +} + +/// Seed the generation-1 active row (retention requires a contiguous chain +/// starting at 1) followed by the generation-2 deleted tombstone row, so a +/// deletion attempt has a valid predecessor to advance from. +fn seeded_deletion_conn( + dir: &tempfile::TempDir, + fx: &Fixture, + deleted_row: &RetainedManagedAgentAggregate, +) -> rusqlite::Connection { + let path = dir.path().join("retention.db"); + let mut writer = open_retention_db(&path).expect("open db"); + retain_managed_agent_aggregate(&mut writer, &fx.retained_row()).expect("retain active row"); + retain_managed_agent_aggregate(&mut writer, deleted_row).expect("retain deleted row"); + open_retention_db(&path).expect("reopen db") +} + +fn app_state_at(url: String) -> crate::app_state::AppState { + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(url); + state +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn faithful_read_back_returns_evidence_leaves_pending_and_posts_verbatim() { + let fx = Fixture::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let conn = seeded_conn(&dir, &fx.retained_row()); + + let (url, captured) = spawn_stub(StubReply { + status: StatusCode::OK, + body: fx.faithful_response_json().to_string(), + }) + .await; + let state = app_state_at(url); + + let outcome = submit_retained_aggregate( + &state.http_client, + &crate::relay::relay_api_base_url_with_override(&state), + &dir.path().join("retention.db"), + &fx.owner_keys, + &fx.owner_hex(), + &fx.agent_hex(), + ) + .await + .expect("drive succeeds"); + + match outcome { + SubmitOutcome::Verified { attempt, evidence } => { + assert_eq!(attempt.owner_pubkey, fx.owner_hex()); + assert_eq!(attempt.agent_pubkey, fx.agent_hex()); + assert_eq!(attempt.generation, fx.cas.generation); + assert_eq!( + attempt.private_event_id, + fx.candidate.signed_event.id.to_hex() + ); + assert_eq!(attempt.state, "active"); + assert_eq!(attempt.source_updated_at, "2025-01-01T00:00:00Z"); + assert_eq!(evidence.generation, fx.cas.generation); + assert_eq!( + evidence.head_event_id, + fx.candidate.signed_event.id.to_hex() + ); + } + other => panic!("expected verified evidence, got {other:?}"), + } + + // Posted bytes are the retained request verbatim. + let posted = captured.lock().unwrap().clone().expect("body captured"); + assert_eq!(posted, fx.request_json().into_bytes()); + + // Transport success is not permission to clear retry. The caller must first + // persist authority/cache, then compare-and-clear this exact attempt. + let retained = get_retained_managed_agent_aggregate(&conn, &fx.owner_hex(), &fx.agent_hex()) + .unwrap() + .unwrap(); + assert!( + retained.pending_sync, + "transport leaves confirmation pending" + ); + assert_eq!(retained.last_error, None); +} + +#[tokio::test] +async fn conflict_status_preserves_retry_with_diagnostic() { + let fx = Fixture::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let conn = seeded_conn(&dir, &fx.retained_row()); + + let (url, _captured) = spawn_stub(StubReply { + status: StatusCode::CONFLICT, + body: json!({ "error": "stale generation" }).to_string(), + }) + .await; + let state = app_state_at(url); + + let outcome = submit_retained_aggregate( + &state.http_client, + &crate::relay::relay_api_base_url_with_override(&state), + &dir.path().join("retention.db"), + &fx.owner_keys, + &fx.owner_hex(), + &fx.agent_hex(), + ) + .await + .expect("drive resolves"); + + assert!(matches!(outcome, SubmitOutcome::Retained { .. })); + let row = get_retained_managed_agent_aggregate(&conn, &fx.owner_hex(), &fx.agent_hex()) + .unwrap() + .unwrap(); + assert!(row.pending_sync, "rejected attempt stays pending"); + assert!(row.last_error.is_some(), "diagnostic persisted"); +} + +#[tokio::test] +async fn malformed_success_body_preserves_retry() { + let fx = Fixture::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let conn = seeded_conn(&dir, &fx.retained_row()); + + let (url, _captured) = spawn_stub(StubReply { + status: StatusCode::OK, + body: "{ not json".to_string(), + }) + .await; + let state = app_state_at(url); + + let outcome = submit_retained_aggregate( + &state.http_client, + &crate::relay::relay_api_base_url_with_override(&state), + &dir.path().join("retention.db"), + &fx.owner_keys, + &fx.owner_hex(), + &fx.agent_hex(), + ) + .await + .expect("drive resolves"); + + assert!(matches!(outcome, SubmitOutcome::Retained { .. })); + let row = get_retained_managed_agent_aggregate(&conn, &fx.owner_hex(), &fx.agent_hex()) + .unwrap() + .unwrap(); + assert!(row.pending_sync); + assert!(row.last_error.is_some()); +} + +#[tokio::test] +async fn tampered_read_back_fails_verification_and_preserves_retry() { + let fx = Fixture::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let conn = seeded_conn(&dir, &fx.retained_row()); + + // A well-formed response whose head id was swapped for a different value — + // deserializes cleanly but must fail verify_promotion. + let mut tampered = fx.faithful_response_json(); + tampered["event_id"] = json!("0".repeat(64)); + let (url, _captured) = spawn_stub(StubReply { + status: StatusCode::OK, + body: tampered.to_string(), + }) + .await; + let state = app_state_at(url); + + let outcome = submit_retained_aggregate( + &state.http_client, + &crate::relay::relay_api_base_url_with_override(&state), + &dir.path().join("retention.db"), + &fx.owner_keys, + &fx.owner_hex(), + &fx.agent_hex(), + ) + .await + .expect("drive resolves"); + + assert!(matches!(outcome, SubmitOutcome::Retained { .. })); + let row = get_retained_managed_agent_aggregate(&conn, &fx.owner_hex(), &fx.agent_hex()) + .unwrap() + .unwrap(); + assert!(row.pending_sync, "unverified read-back never clears retry"); + assert!(row.last_error.is_some()); +} + +#[tokio::test] +async fn row_coordinate_disagreeing_with_request_is_rejected_before_egress() { + // The durable row's (generation, private_event_id) columns are the pair the + // driver confirms. If they disagree with the request_json the row carries — + // and thus with the verified read-back — the guard must refuse to mark it + // synced even though the relay served a faithful copy of the request bytes. + let fx = Fixture::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut row = fx.retained_row(); + row.private_event_id = "f".repeat(64); // column diverges from request_json head id + let conn = seeded_conn(&dir, &row); + + let (url, captured) = spawn_stub(StubReply { + status: StatusCode::OK, + body: fx.faithful_response_json().to_string(), + }) + .await; + let state = app_state_at(url); + + let outcome = submit_retained_aggregate( + &state.http_client, + &crate::relay::relay_api_base_url_with_override(&state), + &dir.path().join("retention.db"), + &fx.owner_keys, + &fx.owner_hex(), + &fx.agent_hex(), + ) + .await + .expect("drive resolves"); + + assert!(matches!(outcome, SubmitOutcome::Retained { .. })); + assert!( + captured.lock().unwrap().is_none(), + "coordinate drift must be rejected before network submission" + ); + let stored = get_retained_managed_agent_aggregate(&conn, &fx.owner_hex(), &fx.agent_hex()) + .unwrap() + .unwrap(); + assert!(stored.pending_sync, "mismatched row stays pending"); + assert!(stored.last_error.is_some(), "diagnostic persisted"); +} + +#[tokio::test] +async fn no_pending_row_is_a_noop() { + let fx = Fixture::new(); + let dir = tempfile::tempdir().expect("tempdir"); + open_retention_db(&dir.path().join("retention.db")).expect("open db"); + + let (url, captured) = spawn_stub(StubReply { + status: StatusCode::OK, + body: fx.faithful_response_json().to_string(), + }) + .await; + let state = app_state_at(url); + + let outcome = submit_retained_aggregate( + &state.http_client, + &crate::relay::relay_api_base_url_with_override(&state), + &dir.path().join("retention.db"), + &fx.owner_keys, + &fx.owner_hex(), + &fx.agent_hex(), + ) + .await + .expect("drive resolves"); + + assert_eq!(outcome, SubmitOutcome::Nothing); + assert!( + captured.lock().unwrap().is_none(), + "no row means no network submission" + ); +} + +// ── Deletion transport ────────────────────────────────────────────────────── + +#[tokio::test] +async fn faithful_deletion_read_back_returns_evidence_and_posts_verbatim() { + let fx = Fixture::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let tombstone = fx.tombstone(); + let conn = seeded_deletion_conn(&dir, &fx, &fx.deleted_retained_row(&tombstone)); + + let (url, captured) = spawn_stub(StubReply { + status: StatusCode::OK, + body: fx.faithful_deletion_response_json(&tombstone).to_string(), + }) + .await; + let state = app_state_at(url); + + let outcome = submit_retained_aggregate( + &state.http_client, + &crate::relay::relay_api_base_url_with_override(&state), + &dir.path().join("retention.db"), + &fx.owner_keys, + &fx.owner_hex(), + &fx.agent_hex(), + ) + .await + .expect("drive succeeds"); + + match outcome { + SubmitOutcome::VerifiedDeletion { attempt, evidence } => { + assert_eq!(attempt.state, "deleted"); + assert_eq!(attempt.generation, fx.cas.generation + 1); + assert_eq!(attempt.private_event_id, tombstone.id.to_hex()); + assert_eq!(evidence.head_event_id, tombstone.id.to_hex()); + assert_eq!(evidence.generation, fx.cas.generation + 1); + // The tombstone's predecessor is the prior head we tombstoned. + assert_eq!( + evidence.previous_event_id, + fx.candidate.signed_event.id.to_hex() + ); + } + other => panic!("expected verified deletion, got {other:?}"), + } + + // Posted bytes are the retained deleted request verbatim. + let posted = captured.lock().unwrap().clone().expect("body captured"); + assert_eq!( + posted, + fx.deleted_request_json(&tombstone).into_bytes(), + "deletion posts the retained tombstone verbatim" + ); + + // Transport success is not permission to clear retry — the flush erases + // + marks applied first, then compare-and-clears. + let retained = get_retained_managed_agent_aggregate(&conn, &fx.owner_hex(), &fx.agent_hex()) + .unwrap() + .unwrap(); + assert!( + retained.pending_sync, + "verified deletion leaves retry pending" + ); +} + +#[tokio::test] +async fn deleted_row_with_active_payload_is_rejected_before_egress() { + // A `state:"deleted"` row whose request bytes are actually an ACTIVE + // aggregate (definition/instance projections + active head) must never + // leave the device: `reconstruct_deletion` rejects the shape pre-POST. + let fx = Fixture::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut row = fx.retained_row(); // active request_json… + row.state = "deleted".to_string(); // …mislabeled deleted. + let conn = seeded_conn(&dir, &row); + + let (url, captured) = spawn_stub(StubReply { + status: StatusCode::OK, + body: fx.faithful_response_json().to_string(), + }) + .await; + let state = app_state_at(url); + + let outcome = submit_retained_aggregate( + &state.http_client, + &crate::relay::relay_api_base_url_with_override(&state), + &dir.path().join("retention.db"), + &fx.owner_keys, + &fx.owner_hex(), + &fx.agent_hex(), + ) + .await + .expect("drive resolves"); + + assert!(matches!(outcome, SubmitOutcome::Retained { .. })); + assert!( + captured.lock().unwrap().is_none(), + "an invalid deletion must be rejected before network submission" + ); + let stored = get_retained_managed_agent_aggregate(&conn, &fx.owner_hex(), &fx.agent_hex()) + .unwrap() + .unwrap(); + assert!(stored.pending_sync, "rejected deletion stays pending"); + assert!(stored.last_error.is_some(), "diagnostic persisted"); +} + +#[tokio::test] +async fn deleted_read_back_coordinate_mismatch_preserves_retry() { + // The relay accepts and serves a well-formed but DIFFERENT deleted head + // (swapped event id). verify_deletion fails and the retry is preserved — + // never confirming a head the row did not commit. + let fx = Fixture::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let tombstone = fx.tombstone(); + let conn = seeded_deletion_conn(&dir, &fx, &fx.deleted_retained_row(&tombstone)); + + let mut tampered = fx.faithful_deletion_response_json(&tombstone); + tampered["event_id"] = json!("0".repeat(64)); + let (url, _captured) = spawn_stub(StubReply { + status: StatusCode::OK, + body: tampered.to_string(), + }) + .await; + let state = app_state_at(url); + + let outcome = submit_retained_aggregate( + &state.http_client, + &crate::relay::relay_api_base_url_with_override(&state), + &dir.path().join("retention.db"), + &fx.owner_keys, + &fx.owner_hex(), + &fx.agent_hex(), + ) + .await + .expect("drive resolves"); + + assert!(matches!(outcome, SubmitOutcome::Retained { .. })); + let row = get_retained_managed_agent_aggregate(&conn, &fx.owner_hex(), &fx.agent_hex()) + .unwrap() + .unwrap(); + assert!( + row.pending_sync, + "unverified deletion read-back never clears retry" + ); + assert!(row.last_error.is_some(), "diagnostic persisted"); +} + +// ── NIP-49 egress guard: boundary 9 (PMA aggregate submit) ────────────────── + +/// A valid NIP-49 key backup. If this string ever reaches the wire the guard +/// has failed; the test proves it is refused before any network I/O. +const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + +/// An aggregate body carrying an ncryptsec must be rejected by +/// [`submit_to_relay`]'s egress guard BEFORE any network I/O. The target is a +/// discard address, so a *guard* error — not a connection error — proves the +/// abort ordering (the guard runs before `wait_for_rate_limit` and the POST). +#[tokio::test] +async fn aggregate_submit_blocks_ncryptsec_before_network() { + let client = reqwest::Client::new(); + let owner_keys = Keys::generate(); + let body = format!("{{\"private_event\":\"{NCRYPTSEC}\"}}"); + let err = submit_to_relay(&client, "http://127.0.0.1:9", &owner_keys, body.as_bytes()) + .await + .unwrap_err(); + assert!(err.contains("key-backup material"), "{err}"); +} diff --git a/desktop/src-tauri/src/managed_agents/migration/mod.rs b/desktop/src-tauri/src/managed_agents/migration/mod.rs new file mode 100644 index 0000000000..67e48263cf --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -0,0 +1,723 @@ +//! Pure relay-canonical managed-agent migration codec (slice 3). +//! +//! This module turns one hydrated [`ManagedAgentRecord`] plus its owner/agent +//! keys, its exact signed public projections (kind:30175 definition and +//! kind:30177 instance), and the CAS coordinate the relay will assign into a +//! signed, inert `kind:30179` private managed-agent aggregate candidate — and +//! then verifies the relay's read-back response byte-exactly before it will +//! yield promotion evidence. +//! +//! It is deliberately **pure**: no HTTP, no persistence, no authority mutation. +//! The transport/driver lane (sibling) owns submission, retry, durable state, +//! and the actual [`RelayAuthority`](super::authority::RelayAuthority) write. +//! This module is the gate those lanes must pass through, so every check that +//! decides "is the relay head a faithful, owner-signed copy of this agent?" +//! lives here and is exercised by an adversarial fixture matrix. +//! +//! Design invariants (see PLANS/RELAY_ONLY_MANAGED_AGENTS_MIGRATION.md): +//! * `PrivateConfig.backend` carries the *versioned* backend envelope +//! ([`VersionedBackend`]), never a bare `BackendKind`, so a shape change is +//! an explicit version bump rather than a silent byte mismatch. +//! * `auth_tag` is **re-minted** unconditionally from the owner keys and the +//! agent pubkey; the stored record string is never copied or trusted. A +//! re-mint cannot byte-match a stored tag, so it is not carried/diffed. +//! * Readiness gating derives the *actual* largest serialized codec `Value` +//! the payload inserts (the projection recovery events dominate) rather +//! than trusting a caller-supplied size. +//! * Verification compares source-vs-roundtrip **only for carried field +//! classes**; derived/transient/bookkeeping fields are never diffed. + +use buzz_core_pkg::kind::{KIND_MANAGED_AGENT, KIND_PERSONA}; +use buzz_core_pkg::private_managed_agent::{ + self as pma, ActivePayload, DefinitionBinding, InstanceBinding, Payload, PrivateConfig, + PrivateIdentity, ProjectionRecoveryV1, State, +}; +use nostr::{Event, Keys, PublicKey, ToBech32}; +use serde::Deserialize; +use serde_json::Value; + +use super::authority::VersionedBackend; +use super::types::ManagedAgentRecord; + +/// A terminal failure while building or verifying a migration candidate. +/// +/// These are decisions, not transient conditions: the transport lane must keep +/// the agent [`LegacyOnly`](super::authority::RelayAuthority::LegacyOnly) and +/// must not retry until an input changes. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] // Consumed by the transport/driver lane (sibling); today only this module's tests read it. +pub enum MigrationError { + /// A supplied key/projection is internally inconsistent (wrong pubkey, + /// non-verifying signature, coordinate mismatch) before anything is built. + InvalidInput(String), + /// The re-minted owner→agent attestation could not be computed. + AuthTag(String), + /// The candidate payload could not be assembled/encrypted/signed by the + /// shared codec. Carries the codec's own diagnostic. + Codec(String), + /// A pure migration-readiness block (capability/size). The agent stays + /// legacy; the reason is a diagnostic rendering only. + Blocked(super::authority::MigrationBlock), + /// The relay read-back is not a faithful, owner-signed copy of the + /// candidate. Carries which check failed. Promotion is refused. + VerificationFailed(String), +} + +impl From for MigrationError { + fn from(error: pma::Error) -> Self { + MigrationError::Codec(error.to_string()) + } +} + +/// The CAS coordinate the relay is expected to assign to this write. +/// +/// Generation and predecessor are supplied by the driver lane from the relay's +/// current head (or `generation = 1`, `previous_event_id = None` for genesis); +/// this module binds them into the payload and later checks the read-back +/// echoes them exactly. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub struct CasMetadata { + /// Monotonic CAS generation for this write. + pub generation: u64, + /// Exact predecessor event ID; `None` only at generation one. + pub previous_event_id: Option, + /// CAS-managed definition revision pinned by the bound kind:30175. + pub definition_revision: u64, +} + +/// A built, signed, inert migration candidate ready for the driver lane to +/// submit. Holds exactly what verification needs to compare the relay's +/// read-back against. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct MigrationCandidate { + /// The signed kind:30179 event to publish. + pub signed_event: Event, + /// The decrypted payload that was sealed into `signed_event`, retained so + /// verification can compare the read-back against the exact source without + /// re-decrypting the candidate. + pub payload: Payload, + /// The exact signed kind:30175 definition projection this candidate binds. + pub definition_event: Event, + /// The exact signed kind:30177 instance projection this candidate binds. + pub instance_event: Event, +} + +/// The relay's read-back response for a submitted aggregate. +/// +/// This is a **local typed fixture** matching the documented aggregate route +/// JSON, not a buzz-db type: the transport lane deserializes the route response +/// into this shape and hands it here for verification. Keeping it local keeps +/// this slice free of the relay/persistence crates. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +pub struct AggregateResponse { + /// Exact ID of the committed private head. + pub event_id: String, + /// Committed CAS generation. + pub generation: u64, + /// Committed lifecycle state. + pub state: String, + /// Whether the relay accepted the request. + pub accepted: bool, + /// Whether this request inserted a new generation rather than replaying one. + pub inserted: bool, + /// The signed kind:30179 event the relay stored and serves as the head. + pub private_event: Event, + /// The signed kind:30175 definition projection the relay serves. + pub definition_event: Option, + /// The signed kind:30177 instance projection the relay serves. + pub instance_event: Option, + /// The definition revision the relay reports for the served projection. + pub definition_revision: Option, +} + +/// Proof that a submitted migration was stored and served back byte-exactly. +/// +/// Returned *only* after every verification check passes. The driver lane +/// treats this as the sole license to promote the agent to +/// [`RelayAuthoritative`](super::authority::RelayAuthority::RelayAuthoritative); +/// this module never performs that write itself. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub struct PromotionEvidence { + /// The verified head event ID served by the relay. + pub head_event_id: String, + /// The verified CAS generation. + pub generation: u64, + /// The verified predecessor event ID (absent at genesis). + pub previous_event_id: Option, + /// The verified definition projection event ID. + pub definition_event_id: String, + /// The verified instance projection event ID. + pub instance_event_id: String, + /// The verified definition revision. + pub definition_revision: u64, +} + +/// Proof that a deleted aggregate request was committed and read back exactly. +/// +/// Deleted heads do not carry public projections or an active private body, so +/// their verification contract is intentionally smaller than promotion's but +/// just as strict about the submitted event and CAS metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeletionEvidence { + pub head_event_id: String, + pub generation: u64, + pub previous_event_id: String, +} + +/// Build the next minimal deleted aggregate from verified relay-head evidence. +/// +/// A tombstone carries no public projections or private active body. It advances +/// the same CAS chain by exactly one and is signed by the owner, so deletion can +/// use the aggregate route rather than legacy kind:5 mutation. +#[allow(dead_code)] // Consumed by the aggregate deletion driver in the next slice. +pub fn build_tombstone_event( + owner_keys: &Keys, + agent_pubkey: &str, + current_generation: u64, + current_private_event_id: &str, + timestamp: &str, + created_at: u64, +) -> Result { + let agent = parse_pubkey("agent_pubkey", agent_pubkey)?; + let previous = nostr::EventId::from_hex(current_private_event_id) + .map_err(|e| MigrationError::InvalidInput(format!("private event id: {e}")))?; + let generation = current_generation + .checked_add(1) + .ok_or_else(|| MigrationError::InvalidInput("aggregate generation overflow".into()))?; + let payload = Payload { + format: pma::FORMAT.to_string(), + version: pma::VERSION, + agent_pubkey: agent.to_hex(), + owner_pubkey: owner_keys.public_key().to_hex(), + generation, + previous_event_id: Some(previous.to_hex()), + state: State::Deleted, + updated_at: timestamp.to_string(), + active: None, + deleted_at: Some(timestamp.to_string()), + extensions: Default::default(), + }; + pma::build_event(owner_keys, &payload, created_at).map_err(MigrationError::from) +} + +/// Build a signed, inert kind:30179 migration candidate for one agent. +/// +/// Assembles the encrypted aggregate payload from the record + keys + exact +/// signed projections + CAS coordinate, re-minting the owner→agent auth tag and +/// wrapping the backend in its versioned envelope. Gates on migration readiness +/// using the *actual* largest inserted codec value before sealing. Returns a +/// [`MigrationError`] on any inconsistency; never persists or promotes. +#[allow(dead_code)] +#[allow(clippy::too_many_arguments)] +pub fn build_migration_candidate<'a, I>( + record: &ManagedAgentRecord, + owner_keys: &Keys, + agent_keys: &Keys, + definition_event: Event, + instance_event: Event, + cas: &CasMetadata, + advertised_nip11_tokens: I, + created_at: u64, +) -> Result +where + I: IntoIterator, +{ + let owner_pubkey = owner_keys.public_key(); + let agent_pubkey = agent_keys.public_key(); + + // The record's stored pubkey is the aggregate coordinate; the supplied + // agent keys must derive it, or we would seal a payload the codec rejects. + let record_agent = parse_pubkey("record.pubkey", &record.pubkey)?; + if record_agent != agent_pubkey { + return Err(MigrationError::InvalidInput( + "agent keys do not derive record.pubkey".into(), + )); + } + if owner_pubkey == agent_pubkey { + return Err(MigrationError::InvalidInput( + "owner and agent pubkeys must differ".into(), + )); + } + + // Bindings are only meaningful if the supplied projections are genuine + // owner-signed events at the right coordinate. Fail fast before sealing. + let definition_d = + require_owner_projection("definition", &definition_event, KIND_PERSONA, &owner_pubkey)?; + require_owner_projection( + "instance", + &instance_event, + KIND_MANAGED_AGENT, + &owner_pubkey, + )?; + + // Re-mint the unconditional owner->agent attestation. The stored + // record.auth_tag is deliberately ignored: a fresh mint cannot byte-match + // it, and the codec requires an unconditional ("") attestation for this + // exact owner. Never copy the stored string. + let auth_tag = buzz_sdk_pkg::nip_oa::compute_auth_tag(owner_keys, &agent_pubkey, "") + .map_err(|e| MigrationError::AuthTag(e.to_string()))?; + + let backend_value = serde_json::to_value(VersionedBackend::current(record.backend.clone())) + .map_err(|e| MigrationError::Codec(format!("backend envelope: {e}")))?; + + let definition = DefinitionBinding { + revision: cas.definition_revision, + event_id: definition_event.id.to_hex(), + content_sha256: pma::content_sha256(definition_event.content.as_bytes()), + recovery: ProjectionRecoveryV1 { + version: 1, + signed_event: definition_event.clone(), + }, + }; + let instance_projection = InstanceBinding { + event_id: instance_event.id.to_hex(), + content_sha256: pma::content_sha256(instance_event.content.as_bytes()), + recovery: ProjectionRecoveryV1 { + version: 1, + signed_event: instance_event.clone(), + }, + }; + + let config = PrivateConfig { + definition_coordinate: Some(format!("30175:{}:{definition_d}", owner_pubkey.to_hex())), + relay_url: record.relay_url.clone(), + agent_command_override: record.agent_command_override.clone(), + agent_args: record.agent_args.clone(), + idle_timeout_seconds: record.idle_timeout_seconds, + max_turn_duration_seconds: record.max_turn_duration_seconds, + env_vars: record.env_vars.clone().into_iter().collect(), + backend: backend_value, + backend_agent_id: record.backend_agent_id.clone(), + team_id: record.team_id.clone(), + persona_name_in_team: record.persona_name_in_team.clone(), + relay_mesh: record + .relay_mesh + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| MigrationError::Codec(format!("relay mesh envelope: {e}")))?, + }; + + let payload = Payload { + format: pma::FORMAT.to_string(), + version: pma::VERSION, + agent_pubkey: agent_pubkey.to_hex(), + owner_pubkey: owner_pubkey.to_hex(), + generation: cas.generation, + previous_event_id: cas.previous_event_id.clone(), + state: State::Active, + updated_at: record.updated_at.clone(), + active: Some(ActivePayload { + definition, + instance_projection, + identity: PrivateIdentity { + private_key_nsec: agent_keys + .secret_key() + .to_bech32() + .map_err(|e| MigrationError::InvalidInput(format!("agent nsec encode: {e}")))?, + auth_tag: Some(auth_tag), + }, + config, + }), + deleted_at: None, + extensions: Default::default(), + }; + + // Readiness gate: derive the ACTUAL largest inserted codec value (the two + // recovery envelopes dominate) and the encrypted payload size, then let the + // pure gate decide capability + size. Do not trust a caller number. + let block = super::authority::assess_migration_readiness( + serialized_len(&payload)?, + largest_inserted_value_bytes(&payload)?, + advertised_nip11_tokens, + ); + if let Err(block) = block { + return Err(MigrationError::Blocked(block)); + } + + // Seal + sign. build_event re-runs the codec's full payload validation, so + // any semantic slip we made surfaces here rather than at the relay. + let signed_event = pma::build_event(owner_keys, &payload, created_at)?; + + Ok(MigrationCandidate { + signed_event, + payload, + definition_event, + instance_event, + }) +} + +/// Verify a relay read-back is a faithful, owner-signed copy of the candidate. +/// +/// Runs, in order: decrypt+validate of the returned head; owner/agent/ +/// generation/predecessor/state equality; binding event-id, content-hash, and +/// recovery equality against the returned signed projections; definition +/// revision equality; and source-vs-roundtrip equality for **carried field +/// classes only**. Returns [`PromotionEvidence`] only if every check passes. +#[allow(dead_code)] +pub fn verify_promotion( + candidate: &MigrationCandidate, + response: &AggregateResponse, + owner_keys: &Keys, +) -> Result { + // 1. The head must decrypt and self-validate under the owner key. This also + // re-checks both projection bindings inside the payload (signature, id, + // hash, coordinate) via the shared codec. + let (envelope, roundtrip) = pma::validate_and_decrypt(&response.private_event, owner_keys) + .map_err(|e| MigrationError::VerificationFailed(format!("head decrypt/validate: {e}")))?; + + let source = &candidate.payload; + if !response.accepted { + return Err(MigrationError::VerificationFailed( + "relay did not accept aggregate".into(), + )); + } + verify_eq( + "response.event_id", + &response.event_id, + &response.private_event.id.to_hex(), + )?; + verify_eq( + "response.generation", + &response.generation, + &source.generation, + )?; + verify_eq("response.state", &response.state.as_str(), &"active")?; + + let definition_event = response.definition_event.as_ref().ok_or_else(|| { + MigrationError::VerificationFailed("read-back missing definition projection".into()) + })?; + let instance_event = response.instance_event.as_ref().ok_or_else(|| { + MigrationError::VerificationFailed("read-back missing instance projection".into()) + })?; + + if !response.private_event.verify_id() || !response.private_event.verify_signature() { + return Err(MigrationError::VerificationFailed( + "read-back head has invalid id or signature".into(), + )); + } + if !definition_event.verify_id() || !definition_event.verify_signature() { + return Err(MigrationError::VerificationFailed( + "read-back definition has invalid id or signature".into(), + )); + } + if !instance_event.verify_id() || !instance_event.verify_signature() { + return Err(MigrationError::VerificationFailed( + "read-back instance has invalid id or signature".into(), + )); + } + + // 2. CAS + identity metadata must echo the candidate exactly. + verify_eq( + "owner_pubkey", + &roundtrip.owner_pubkey, + &source.owner_pubkey, + )?; + verify_eq( + "agent_pubkey", + &roundtrip.agent_pubkey, + &source.agent_pubkey, + )?; + verify_eq("generation", &roundtrip.generation, &source.generation)?; + verify_eq( + "previous_event_id", + &roundtrip.previous_event_id, + &source.previous_event_id, + )?; + if roundtrip.state != source.state { + return Err(MigrationError::VerificationFailed("state differs".into())); + } + // The decrypted envelope's outer metadata already agreed with the inner + // payload (validate_and_decrypt enforces it); assert it matches the source + // generation too so a swapped-but-valid head cannot slip through. + verify_eq( + "envelope.generation", + &envelope.generation, + &source.generation, + )?; + + let source_active = source + .active + .as_ref() + .ok_or_else(|| MigrationError::VerificationFailed("source is not active".into()))?; + let roundtrip_active = roundtrip + .active + .as_ref() + .ok_or_else(|| MigrationError::VerificationFailed("read-back is not active".into()))?; + + // 3. The returned signed projections must be the exact events the bindings + // name — compare id + content hash + full recovery event, and check the + // returned projection events match the returned response events too. + verify_binding_definition(source_active, roundtrip_active, definition_event)?; + verify_binding_instance(source_active, roundtrip_active, instance_event)?; + + // 4. Definition revision the relay reports must match what we pinned. + verify_eq( + "definition_revision", + &response.definition_revision, + &Some(source_active.definition.revision), + )?; + + // 5. Carried-class equality: the private config we sealed must round-trip + // byte-exact. Derived/transient/bookkeeping fields never enter the + // payload, so they are structurally impossible to diff here — the type + // only carries PrivateConfig, which is exactly the carried classes. + if roundtrip_active.config != source_active.config { + return Err(MigrationError::VerificationFailed( + "carried private config differs".into(), + )); + } + if roundtrip_active.identity != source_active.identity { + return Err(MigrationError::VerificationFailed( + "carried identity differs".into(), + )); + } + + Ok(PromotionEvidence { + head_event_id: response.private_event.id.to_hex(), + generation: source.generation, + previous_event_id: source.previous_event_id.clone(), + definition_event_id: source_active.definition.event_id.clone(), + instance_event_id: source_active.instance_projection.event_id.clone(), + definition_revision: source_active.definition.revision, + }) +} + +/// Verify a relay read-back for an exact deleted aggregate request. +/// +/// The submitted event is the immutable retry input retained by Desktop. The +/// response must return that byte-identical event and echo its deleted CAS head; +/// deleted heads must not grow public projections or a definition revision. +pub fn verify_deletion( + submitted_event: &Event, + response: &AggregateResponse, + owner_keys: &Keys, +) -> Result { + let (envelope, payload) = pma::validate_and_decrypt(submitted_event, owner_keys) + .map_err(|e| MigrationError::VerificationFailed(format!("submitted tombstone: {e}")))?; + + let previous_event_id = payload.previous_event_id.clone().ok_or_else(|| { + MigrationError::VerificationFailed("submitted deletion has no predecessor".into()) + })?; + if payload.state != State::Deleted || payload.active.is_some() || payload.deleted_at.is_none() { + return Err(MigrationError::VerificationFailed( + "submitted aggregate is not a valid deletion".into(), + )); + } + if !response.accepted { + return Err(MigrationError::VerificationFailed( + "relay did not accept aggregate deletion".into(), + )); + } + if &response.private_event != submitted_event { + return Err(MigrationError::VerificationFailed( + "read-back deletion head differs from submitted event".into(), + )); + } + verify_eq( + "response.event_id", + &response.event_id, + &submitted_event.id.to_hex(), + )?; + verify_eq( + "response.generation", + &response.generation, + &payload.generation, + )?; + verify_eq("response.state", &response.state.as_str(), &"deleted")?; + if response.definition_event.is_some() + || response.instance_event.is_some() + || response.definition_revision.is_some() + { + return Err(MigrationError::VerificationFailed( + "deleted read-back unexpectedly contains active projections".into(), + )); + } + verify_eq( + "envelope.generation", + &envelope.generation, + &payload.generation, + )?; + + Ok(DeletionEvidence { + head_event_id: submitted_event.id.to_hex(), + generation: payload.generation, + previous_event_id, + }) +} + +fn verify_binding_definition( + source: &ActivePayload, + roundtrip: &ActivePayload, + definition_event: &Event, +) -> Result<(), MigrationError> { + verify_eq( + "definition.event_id", + &roundtrip.definition.event_id, + &source.definition.event_id, + )?; + verify_eq( + "definition.content_sha256", + &roundtrip.definition.content_sha256, + &source.definition.content_sha256, + )?; + if roundtrip.definition.recovery != source.definition.recovery { + return Err(MigrationError::VerificationFailed( + "definition recovery differs".into(), + )); + } + // The response's standalone definition event must be the same one the + // binding names, so a relay cannot serve a matching binding beside a + // swapped public projection. + if definition_event != &source.definition.recovery.signed_event + || definition_event.id.to_hex() != source.definition.event_id + || pma::content_sha256(definition_event.content.as_bytes()) + != source.definition.content_sha256 + { + return Err(MigrationError::VerificationFailed( + "served definition projection does not match binding".into(), + )); + } + Ok(()) +} + +fn verify_binding_instance( + source: &ActivePayload, + roundtrip: &ActivePayload, + instance_event: &Event, +) -> Result<(), MigrationError> { + verify_eq( + "instance.event_id", + &roundtrip.instance_projection.event_id, + &source.instance_projection.event_id, + )?; + verify_eq( + "instance.content_sha256", + &roundtrip.instance_projection.content_sha256, + &source.instance_projection.content_sha256, + )?; + if roundtrip.instance_projection.recovery != source.instance_projection.recovery { + return Err(MigrationError::VerificationFailed( + "instance recovery differs".into(), + )); + } + if instance_event != &source.instance_projection.recovery.signed_event + || instance_event.id.to_hex() != source.instance_projection.event_id + || pma::content_sha256(instance_event.content.as_bytes()) + != source.instance_projection.content_sha256 + { + return Err(MigrationError::VerificationFailed( + "served instance projection does not match binding".into(), + )); + } + Ok(()) +} + +fn verify_eq( + label: &str, + got: &T, + expected: &T, +) -> Result<(), MigrationError> { + if got != expected { + return Err(MigrationError::VerificationFailed(format!( + "{label} differs" + ))); + } + Ok(()) +} + +/// Serialized encrypted-plaintext size proxy: the payload JSON the codec seals. +fn serialized_len(payload: &Payload) -> Result { + serde_json::to_vec(payload) + .map(|bytes| bytes.len()) + .map_err(|e| MigrationError::Codec(format!("serialize payload: {e}"))) +} + +/// Largest single serialized codec `Value` the payload inserts. The projection +/// recovery envelopes (full signed events) dominate; the backend and any +/// extension values are also candidates. Mirrors what the codec's per-`Value` +/// size check (`MAX_VALUE_BYTES`) will measure. +fn largest_inserted_value_bytes(payload: &Payload) -> Result { + let mut largest = 0usize; + let mut consider = |value: &Value| -> Result<(), MigrationError> { + let len = serde_json::to_vec(value) + .map(|bytes| bytes.len()) + .map_err(|e| MigrationError::Codec(format!("serialize value: {e}")))?; + largest = largest.max(len); + Ok(()) + }; + if let Some(active) = &payload.active { + consider( + &serde_json::to_value(&active.definition.recovery) + .map_err(|e| MigrationError::Codec(format!("definition recovery: {e}")))?, + )?; + consider( + &serde_json::to_value(&active.instance_projection.recovery) + .map_err(|e| MigrationError::Codec(format!("instance recovery: {e}")))?, + )?; + consider(&active.config.backend)?; + if let Some(mesh) = &active.config.relay_mesh { + consider(mesh)?; + } + } + for value in payload.extensions.values() { + consider(value)?; + } + Ok(largest) +} + +fn parse_pubkey(label: &str, hex: &str) -> Result { + PublicKey::from_hex(hex) + .map_err(|_| MigrationError::InvalidInput(format!("{label} is not a valid pubkey"))) +} + +/// Require a supplied projection to be a genuine owner-signed event at the +/// expected kind, returning its single non-empty `d` coordinate. +fn require_owner_projection( + label: &str, + event: &Event, + expected_kind: u32, + owner_pubkey: &PublicKey, +) -> Result { + if event.kind.as_u16() as u32 != expected_kind { + return Err(MigrationError::InvalidInput(format!( + "{label} projection has wrong kind" + ))); + } + if &event.pubkey != owner_pubkey { + return Err(MigrationError::InvalidInput(format!( + "{label} projection is not signed by the owner" + ))); + } + if !event.verify_id() || !event.verify_signature() { + return Err(MigrationError::InvalidInput(format!( + "{label} projection has an invalid id or signature" + ))); + } + let d_tags: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|parts| parts.first().map(String::as_str) == Some("d")) + .collect(); + match d_tags.as_slice() { + [parts] if parts.len() == 2 && !parts[1].is_empty() => Ok(parts[1].clone()), + _ => Err(MigrationError::InvalidInput(format!( + "{label} projection must have exactly one non-empty d tag" + ))), + } +} + +/// The transport half of this seam: submits retained aggregates over HTTP and +/// verifies the read-back through [`verify_promotion`]. Kept in a sibling +/// module so the pure builder/verifier above stays free of HTTP/persistence. +pub(crate) mod activation; +mod driver; + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/migration/tests.rs b/desktop/src-tauri/src/managed_agents/migration/tests.rs new file mode 100644 index 0000000000..01b5a50ee0 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/migration/tests.rs @@ -0,0 +1,638 @@ +//! Adversarial fixture matrix for the pure migration builder/verifier. +//! +//! Each test builds a fully valid candidate + faithful relay read-back, then +//! tampers exactly one axis and asserts the specific rejection. The happy-path +//! test proves an untampered round-trip yields promotion evidence, so a green +//! matrix means every check is load-bearing (a tamper that still passed would +//! fail its negative test). + +use std::collections::BTreeMap; + +use buzz_core_pkg::kind::{KIND_MANAGED_AGENT, KIND_PERSONA}; +use nostr::{Event, EventBuilder, Keys, Kind}; +use serde_json::json; + +use super::*; +use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo}; + +const CAPABLE: [&str; 1] = [super::super::authority::NIP_PMA_AGGREGATE_TOKEN]; +const CREATED_AT: u64 = 1_700_000_000; + +/// Sign a public projection event at `kind` with `owner_keys`, carrying the +/// given `d` coordinate and content — the exact shape a binding validates. +fn signed_projection(owner_keys: &Keys, kind: u32, d: &str, content: &str) -> Event { + EventBuilder::new(Kind::Custom(kind as u16), content) + .tags([nostr::Tag::parse(["d", d]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(CREATED_AT)) + .sign_with_keys(owner_keys) + .expect("sign projection") +} + +/// A record whose `pubkey` matches `agent_keys`, valid for building. +fn record_for(agent_keys: &Keys) -> ManagedAgentRecord { + let mut record = fully_valid_record(); + record.pubkey = agent_keys.public_key().to_hex(); + record +} + +/// A minimal-but-realistic record. Only the carried fields matter for the +/// codec; the rest exercise "transient/derived not carried". +fn fully_valid_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "unset".to_string(), + name: "Test Agent".to_string(), + persona_id: Some("persona-1".to_string()), + team_id: Some("team-1".to_string()), + private_key_nsec: String::new(), + auth_tag: Some("[\"auth\",\"STALE-DO-NOT-TRUST\",\"\",\"deadbeef\"]".to_string()), + relay_url: "wss://relay.example".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_command_override: Some("codex".to_string()), + agent_args: vec!["--flag".to_string()], + mcp_command: "buzz-dev-mcp".to_string(), + turn_timeout_seconds: 0, + idle_timeout_seconds: Some(60), + max_turn_duration_seconds: Some(600), + parallelism: 1, + system_prompt: Some("You are a test agent.".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + persona_source_version: None, + env_vars: BTreeMap::from([("K".to_string(), "V".to_string())]), + start_on_app_launch: true, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::Provider { + id: "buzz-backend-x".to_string(), + config: json!({ "api_key": "secret" }), + }, + backend_agent_id: Some("remote-id".to_string()), + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: Some("member".to_string()), + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + display_name: Some("Display".to_string()), + slug: Some("sample-slug".to_string()), + runtime: Some("goose".to_string()), + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + relay_mesh: Some(crate::managed_agents::RelayMeshConfig { + model_ref: "mesh/model".to_string(), + }), + relay_authority: super::super::authority::RelayAuthority::legacy(), + } +} + +/// Everything a test needs: keys, the signed projections, the record, and the +/// CAS coordinate. `definition_d` is the agent pubkey so the instance binding's +/// coordinate check passes, and a distinct persona `d` for the definition. +struct Fixture { + owner_keys: Keys, + agent_keys: Keys, + record: ManagedAgentRecord, + definition_event: Event, + instance_event: Event, + cas: CasMetadata, +} + +impl Fixture { + fn new() -> Self { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let agent_hex = agent_keys.public_key().to_hex(); + // Definition d is a slug; instance d MUST be the agent pubkey (the codec + // binds the instance projection to 30177::). + let definition_event = signed_projection( + &owner_keys, + KIND_PERSONA, + "def-slug", + "{\"definition\":true}", + ); + let instance_event = signed_projection( + &owner_keys, + KIND_MANAGED_AGENT, + &agent_hex, + "{\"instance\":true}", + ); + Self { + owner_keys, + record: record_for(&agent_keys), + agent_keys, + definition_event, + instance_event, + cas: CasMetadata { + generation: 1, + previous_event_id: None, + definition_revision: 7, + }, + } + } + + fn build(&self) -> Result { + build_migration_candidate( + &self.record, + &self.owner_keys, + &self.agent_keys, + self.definition_event.clone(), + self.instance_event.clone(), + &self.cas, + CAPABLE, + CREATED_AT, + ) + } + + /// A faithful relay read-back for a built candidate: the relay stored and + /// serves back exactly what we submitted. + fn faithful_response(&self, candidate: &MigrationCandidate) -> AggregateResponse { + AggregateResponse { + event_id: candidate.signed_event.id.to_hex(), + generation: self.cas.generation, + state: "active".to_string(), + accepted: true, + inserted: true, + private_event: candidate.signed_event.clone(), + definition_event: Some(candidate.definition_event.clone()), + instance_event: Some(candidate.instance_event.clone()), + definition_revision: Some(self.cas.definition_revision), + } + } +} + +// ----- Happy path ----- + +#[test] +fn happy_round_trip_yields_promotion_evidence() { + let fx = Fixture::new(); + let candidate = fx.build().expect("candidate builds"); + let response = fx.faithful_response(&candidate); + let evidence = verify_promotion(&candidate, &response, &fx.owner_keys) + .expect("faithful read-back promotes"); + assert_eq!(evidence.head_event_id, candidate.signed_event.id.to_hex()); + assert_eq!(evidence.generation, 1); + assert_eq!(evidence.previous_event_id, None); + assert_eq!(evidence.definition_revision, 7); + assert_eq!( + candidate.payload.active.as_ref().unwrap().config.relay_mesh, + Some(json!({ "model_ref": "mesh/model" })), + "relay mesh is private-canonical and must be carried", + ); +} + +#[test] +fn route_response_shape_deserializes_and_missing_projection_is_rejected() { + let fx = Fixture::new(); + let candidate = fx.build().expect("candidate builds"); + let response_json = serde_json::json!({ + "event_id": candidate.signed_event.id.to_hex(), + "generation": 1, + "state": "active", + "accepted": true, + "inserted": true, + "definition_revision": 7, + "private_event": candidate.signed_event, + "definition_event": candidate.definition_event, + "instance_event": candidate.instance_event, + }); + let response: AggregateResponse = + serde_json::from_value(response_json).expect("route JSON deserializes"); + verify_promotion(&candidate, &response, &fx.owner_keys).expect("route response verifies"); + + let mut missing = response; + missing.instance_event = None; + assert!(matches!( + verify_promotion(&candidate, &missing, &fx.owner_keys), + Err(MigrationError::VerificationFailed(_)) + )); +} + +#[test] +fn verify_rejects_response_metadata_drift() { + let fx = Fixture::new(); + let candidate = fx.build().expect("candidate builds"); + for mutate in 0..4 { + let mut response = fx.faithful_response(&candidate); + match mutate { + 0 => response.event_id = "0".repeat(64), + 1 => response.generation += 1, + 2 => response.state = "deleted".to_string(), + 3 => response.accepted = false, + _ => unreachable!(), + } + assert!(matches!( + verify_promotion(&candidate, &response, &fx.owner_keys), + Err(MigrationError::VerificationFailed(_)) + )); + } +} + +#[test] +fn auth_tag_is_re_minted_not_copied_from_record() { + let fx = Fixture::new(); + let candidate = fx.build().expect("candidate builds"); + let identity = &candidate.payload.active.as_ref().unwrap().identity; + let minted = identity.auth_tag.as_deref().unwrap(); + // The stored tag is a distinct-owner sentinel; the mint must not equal it + // and must be an unconditional attestation for the real owner. + assert_ne!(minted, fx.record.auth_tag.as_deref().unwrap()); + let parts: Vec = serde_json::from_str(minted).unwrap(); + assert_eq!(parts[0], "auth"); + assert_eq!(parts[1], fx.owner_keys.public_key().to_hex()); + assert!(parts[2].is_empty(), "attestation must be unconditional"); +} + +#[test] +fn backend_is_carried_as_versioned_envelope() { + let fx = Fixture::new(); + let candidate = fx.build().expect("candidate builds"); + let backend = &candidate.payload.active.as_ref().unwrap().config.backend; + let obj = backend + .as_object() + .expect("backend is a versioned envelope"); + assert_eq!(obj.get("version").and_then(|v| v.as_u64()), Some(1)); + assert!( + obj.contains_key("backend"), + "envelope carries inner backend" + ); +} + +// ----- Build-time input rejections ----- + +#[test] +fn build_rejects_agent_key_not_matching_record_pubkey() { + let fx = Fixture::new(); + let mut record = fx.record.clone(); + record.pubkey = Keys::generate().public_key().to_hex(); + let err = build_migration_candidate( + &record, + &fx.owner_keys, + &fx.agent_keys, + fx.definition_event.clone(), + fx.instance_event.clone(), + &fx.cas, + CAPABLE, + CREATED_AT, + ) + .unwrap_err(); + assert!(matches!(err, MigrationError::InvalidInput(_))); +} + +#[test] +fn build_rejects_projection_not_signed_by_owner() { + let fx = Fixture::new(); + // A definition signed by someone other than the owner. + let impostor = Keys::generate(); + let bad_def = signed_projection(&impostor, KIND_PERSONA, "def-slug", "{\"definition\":true}"); + let err = build_migration_candidate( + &fx.record, + &fx.owner_keys, + &fx.agent_keys, + bad_def, + fx.instance_event.clone(), + &fx.cas, + CAPABLE, + CREATED_AT, + ) + .unwrap_err(); + assert!(matches!(err, MigrationError::InvalidInput(_))); +} + +#[test] +fn build_rejects_wrong_kind_projection() { + let fx = Fixture::new(); + // Instance slot fed a 30175 (persona) event. + let wrong = signed_projection(&fx.owner_keys, KIND_PERSONA, "x", "{}"); + let err = build_migration_candidate( + &fx.record, + &fx.owner_keys, + &fx.agent_keys, + fx.definition_event.clone(), + wrong, + &fx.cas, + CAPABLE, + CREATED_AT, + ) + .unwrap_err(); + assert!(matches!(err, MigrationError::InvalidInput(_))); +} + +// ----- Readiness gating at build ----- + +#[test] +fn build_blocks_when_relay_lacks_capability() { + let fx = Fixture::new(); + let err = build_migration_candidate( + &fx.record, + &fx.owner_keys, + &fx.agent_keys, + fx.definition_event.clone(), + fx.instance_event.clone(), + &fx.cas, + ["some-other-token"], + CREATED_AT, + ) + .unwrap_err(); + assert!(matches!( + err, + MigrationError::Blocked(super::super::authority::MigrationBlock::RelayCapabilityAbsent) + )); +} + +#[test] +fn build_blocks_on_oversize_codec_value() { + let fx = Fixture::new(); + // A recovery event whose content pushes the serialized recovery Value past + // the per-value cap forces a CodecValueTooLarge block. + let huge = "x".repeat(super::super::authority::MIGRATION_MAX_VALUE_BYTES + 1024); + let big_instance = signed_projection( + &fx.owner_keys, + KIND_MANAGED_AGENT, + &fx.agent_keys.public_key().to_hex(), + &huge, + ); + let err = build_migration_candidate( + &fx.record, + &fx.owner_keys, + &fx.agent_keys, + fx.definition_event.clone(), + big_instance, + &fx.cas, + CAPABLE, + CREATED_AT, + ) + .unwrap_err(); + assert!(matches!( + err, + MigrationError::Blocked(super::super::authority::MigrationBlock::CodecValueTooLarge { .. }) + )); +} + +// ----- Verification-time rejections (relay read-back is not faithful) ----- + +#[test] +fn verify_rejects_tampered_head_ciphertext() { + let fx = Fixture::new(); + let candidate = fx.build().expect("candidate builds"); + let mut response = fx.faithful_response(&candidate); + // Re-sign a head with mangled ciphertext: decrypt/validate must fail. + response.private_event = + EventBuilder::new(candidate.signed_event.kind, "not-the-real-ciphertext") + .tags(candidate.signed_event.tags.iter().cloned()) + .custom_created_at(nostr::Timestamp::from(CREATED_AT)) + .sign_with_keys(&fx.owner_keys) + .unwrap(); + let err = verify_promotion(&candidate, &response, &fx.owner_keys).unwrap_err(); + assert!(matches!(err, MigrationError::VerificationFailed(_))); +} + +#[test] +fn verify_rejects_head_signed_by_wrong_owner() { + let fx = Fixture::new(); + let candidate = fx.build().expect("candidate builds"); + let response = fx.faithful_response(&candidate); + // Verifying under a different owner key: the envelope owner check fails. + let stranger = Keys::generate(); + let err = verify_promotion(&candidate, &response, &stranger).unwrap_err(); + assert!(matches!(err, MigrationError::VerificationFailed(_))); +} + +#[test] +fn verify_rejects_swapped_definition_projection() { + let fx = Fixture::new(); + let candidate = fx.build().expect("candidate builds"); + let mut response = fx.faithful_response(&candidate); + // Relay serves a different (validly owner-signed) definition than bound. + response.definition_event = Some(signed_projection( + &fx.owner_keys, + KIND_PERSONA, + "def-slug", + "{\"definition\":\"SWAPPED\"}", + )); + let err = verify_promotion(&candidate, &response, &fx.owner_keys).unwrap_err(); + assert!(matches!(err, MigrationError::VerificationFailed(_))); +} + +#[test] +fn verify_rejects_swapped_instance_projection() { + let fx = Fixture::new(); + let candidate = fx.build().expect("candidate builds"); + let mut response = fx.faithful_response(&candidate); + response.instance_event = Some(signed_projection( + &fx.owner_keys, + KIND_MANAGED_AGENT, + &fx.agent_keys.public_key().to_hex(), + "{\"instance\":\"SWAPPED\"}", + )); + let err = verify_promotion(&candidate, &response, &fx.owner_keys).unwrap_err(); + assert!(matches!(err, MigrationError::VerificationFailed(_))); +} + +#[test] +fn verify_rejects_wrong_definition_revision() { + let fx = Fixture::new(); + let candidate = fx.build().expect("candidate builds"); + let mut response = fx.faithful_response(&candidate); + response.definition_revision = Some(fx.cas.definition_revision + 1); + let err = verify_promotion(&candidate, &response, &fx.owner_keys).unwrap_err(); + assert!(matches!(err, MigrationError::VerificationFailed(_))); +} + +#[test] +fn tombstone_advances_verified_head_without_active_payload() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let previous = EventBuilder::new(Kind::TextNote, "previous") + .sign_with_keys(&owner) + .unwrap(); + let timestamp = "2026-08-05T18:00:00Z"; + + let event = build_tombstone_event( + &owner, + &agent.public_key().to_hex(), + 7, + &previous.id.to_hex(), + timestamp, + CREATED_AT, + ) + .unwrap(); + let (envelope, payload) = + buzz_core_pkg::private_managed_agent::validate_and_decrypt(&event, &owner).unwrap(); + assert_eq!(envelope.generation, 8); + assert_eq!( + envelope.previous_event_id.map(|id| id.to_hex()), + Some(previous.id.to_hex()) + ); + assert_eq!( + payload.state, + buzz_core_pkg::private_managed_agent::State::Deleted + ); + assert!(payload.active.is_none()); + assert_eq!(payload.deleted_at.as_deref(), Some(timestamp)); + assert_eq!(payload.updated_at, timestamp); +} + +#[test] +fn tombstone_rejects_invalid_coordinate_or_generation_overflow() { + let owner = Keys::generate(); + assert!(matches!( + build_tombstone_event( + &owner, + "invalid", + 1, + &"11".repeat(32), + "2026-08-05T18:00:00Z", + CREATED_AT + ), + Err(MigrationError::InvalidInput(_)) + )); + assert!(matches!( + build_tombstone_event( + &owner, + &Keys::generate().public_key().to_hex(), + u64::MAX, + &"11".repeat(32), + "2026-08-05T18:00:00Z", + CREATED_AT, + ), + Err(MigrationError::InvalidInput(_)) + )); +} + +#[test] +fn verify_deleted_readback_accepts_exact_tombstone_and_rejects_active_residue() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let previous_id = "11".repeat(32); + let event = build_tombstone_event( + &owner, + &agent.public_key().to_hex(), + 7, + &previous_id, + "2026-08-05T18:00:00Z", + CREATED_AT, + ) + .unwrap(); + let mut response = AggregateResponse { + event_id: event.id.to_hex(), + generation: 8, + state: "deleted".into(), + accepted: true, + inserted: true, + private_event: event.clone(), + definition_event: None, + instance_event: None, + definition_revision: None, + }; + + assert_eq!( + verify_deletion(&event, &response, &owner).unwrap(), + DeletionEvidence { + head_event_id: event.id.to_hex(), + generation: 8, + previous_event_id: previous_id, + } + ); + + response.definition_revision = Some(7); + assert!(matches!( + verify_deletion(&event, &response, &owner), + Err(MigrationError::VerificationFailed(_)) + )); +} + +#[test] +fn verify_deleted_readback_rejects_swapped_or_drifted_head() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let event = build_tombstone_event( + &owner, + &agent.public_key().to_hex(), + 2, + &"22".repeat(32), + "2026-08-05T18:00:00Z", + CREATED_AT, + ) + .unwrap(); + let other = build_tombstone_event( + &owner, + &agent.public_key().to_hex(), + 2, + &"22".repeat(32), + "2026-08-05T18:00:01Z", + CREATED_AT + 1, + ) + .unwrap(); + let base = AggregateResponse { + event_id: event.id.to_hex(), + generation: 3, + state: "deleted".into(), + accepted: true, + inserted: false, + private_event: event.clone(), + definition_event: None, + instance_event: None, + definition_revision: None, + }; + + for mutate in 0..4 { + let mut response = base.clone(); + match mutate { + 0 => response.private_event = other.clone(), + 1 => response.event_id = other.id.to_hex(), + 2 => response.generation += 1, + 3 => response.accepted = false, + _ => unreachable!(), + } + assert!(matches!( + verify_deletion(&event, &response, &owner), + Err(MigrationError::VerificationFailed(_)) + )); + } +} + +#[test] +fn verify_rejects_stale_generation_head() { + let fx = Fixture::new(); + let candidate = fx.build().expect("candidate builds"); + // Build a DIFFERENT candidate at generation 2 and have the relay serve that + // head back for our generation-1 submission: metadata mismatch. + let cas2 = CasMetadata { + generation: 2, + previous_event_id: Some("a".repeat(64)), + definition_revision: fx.cas.definition_revision, + }; + let candidate2 = build_migration_candidate( + &fx.record, + &fx.owner_keys, + &fx.agent_keys, + fx.definition_event.clone(), + fx.instance_event.clone(), + &cas2, + CAPABLE, + CREATED_AT, + ) + .expect("gen-2 candidate builds"); + let mut response = fx.faithful_response(&candidate); + response.private_event = candidate2.signed_event.clone(); + response.event_id = candidate2.signed_event.id.to_hex(); + response.generation = cas2.generation; + let err = verify_promotion(&candidate, &response, &fx.owner_keys).unwrap_err(); + assert!(matches!(err, MigrationError::VerificationFailed(_))); +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 986ce4e0c0..4078e28b36 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -2,6 +2,8 @@ mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; pub(crate) mod agent_snapshot_envelope; +mod authority; +pub(crate) mod migration; pub(crate) mod team_snapshot; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, @@ -49,6 +51,13 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { } pub use backend::*; +// `RelayAuthority` (the record field + its `legacy()` constructor) and +// `RelayAuthorityEvidence` (carried by the delete path's `TombstoneDisposition` +// and the deletion flush) are consumed by production code. The classification +// API (`classify_field`, `FieldClass`) is exercised solely by `authority`'s own +// tests via `super::*`, so it needs no re-export yet. No blanket +// `#[allow(dead_code)]` — unused API stays unexported. +pub(crate) use authority::{RelayAuthority, RelayAuthorityEvidence, VersionedBackend}; pub use discovery::*; pub use env_vars::*; #[cfg(windows)] diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index cbef171f6f..84489dc85b 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -502,6 +502,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index e1691575b1..870f8a0d1b 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -117,6 +117,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + relay_authority: Default::default(), } } 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 0580b12ce2..d1a42b5c79 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -58,6 +58,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff1..a2dc38629c 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -37,7 +37,6 @@ //! separately because it is not part of the process env — the harness reads //! it at startup. We do not evaluate it here; it is exposed for future //! UI display only. - use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -1530,6 +1529,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), }; let runtime = known_acp_runtime_exact("buzz-agent"); diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 90f05c5750..8de49de7e1 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -104,6 +104,16 @@ fn reconcile_agents_in_dir_at( continue; } + // Once a verified kind:30179 head is canonical, this JSON record is + // only a local compatibility cache. Re-publishing its legacy kind:30177 + // projection at boot would let stale disk state race the aggregate's + // transactionally bound projection (and could resurrect a deleted + // generation). Relay-authoritative and mid-deletion records are + // reconciled through the PMA read-back / deletion-flush paths instead. + if record.relay_authority.is_relay_canonical() { + continue; + } + if retain_agent_record(&conn, keys, record)? { reconciled += 1; } diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index c9269dbf00..2b621a3710 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -159,6 +159,37 @@ fn missing_record_is_never_tombstoned() { assert!(survivor.is_some(), "missing record must stay retained"); } +#[test] +fn relay_authoritative_record_is_never_republished_by_legacy_boot_reconcile() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + let pubkey = "9".repeat(64); + let mut record = sample_record(&pubkey, "relay-authoritative-agent"); + record.relay_authority = crate::managed_agents::RelayAuthority::relay_authoritative( + crate::managed_agents::authority::RelayAuthorityEvidence { + generation: 7, + private_event_id: "a".repeat(64), + }, + ); + write_store(&dir, &[record]); + + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 0); + + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + assert!(get_pending_sync(&conn).unwrap().is_empty()); + assert!( + get_retained_event( + &conn, + KIND_MANAGED_AGENT, + &keys.public_key().to_hex(), + &pubkey, + ) + .unwrap() + .is_none(), + "legacy reconcile must not synthesize a 30177 from an authoritative cache" + ); +} + #[test] fn keyless_record_is_skipped() { let dir = TempDir::new().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 7e97fa1f56..49bb9752c6 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -106,6 +106,9 @@ pub fn arrival_retention_scope( )) } +mod managed_agent_aggregates; +pub use managed_agent_aggregates::*; + /// A retained persona event row. #[derive(Debug, Clone)] pub struct RetainedEvent { @@ -140,13 +143,64 @@ pub fn open_retention_db(path: &Path) -> Result { raw_event TEXT NOT NULL, pending_sync INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (kind, pubkey, d_tag) + ); + CREATE TABLE IF NOT EXISTS managed_agent_aggregates ( + owner_pubkey TEXT NOT NULL, + agent_pubkey TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + private_event_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active', 'deleted')), + request_json TEXT NOT NULL, + pending_sync INTEGER NOT NULL DEFAULT 1, + last_error TEXT, + PRIMARY KEY (owner_pubkey, agent_pubkey, generation) );", ) .map_err(|e| format!("failed to create retention table: {e}"))?; + // Durable terminal-deletion marker: set to 1 the instant a verified deletion + // has applied the local record/key erase, BEFORE `pending_sync` is cleared. + // Crash-replay uses this — never mere record absence — to prove the exact + // deletion reached local authority before it clears the retry. Added via a + // guarded ALTER so stores written before this column deserialize as 0. + add_column_if_missing( + &conn, + "managed_agent_aggregates", + "local_authority_applied", + "INTEGER NOT NULL DEFAULT 0", + )?; + Ok(conn) } +/// Add `column` to `table` if it is not already present. Idempotent: SQLite has +/// no `ADD COLUMN IF NOT EXISTS`, so existence is probed via `PRAGMA +/// table_info` and the `ALTER` is skipped when the column already exists. +fn add_column_if_missing( + conn: &Connection, + table: &str, + column: &str, + definition: &str, +) -> Result<(), String> { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(|e| format!("failed to inspect {table} columns: {e}"))?; + let existing: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| format!("failed to read {table} columns: {e}"))? + .collect::>() + .map_err(|e| format!("failed to collect {table} columns: {e}"))?; + if existing.iter().any(|name| name == column) { + return Ok(()); + } + conn.execute( + &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"), + [], + ) + .map_err(|e| format!("failed to add {table}.{column}: {e}"))?; + Ok(()) +} + fn set_wal_mode(conn: &Connection) -> Result<(), String> { let deadline = Instant::now() + Duration::from_secs(5); loop { @@ -461,474 +515,5 @@ 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 - )); - } -} +#[path = "retention/tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs b/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs new file mode 100644 index 0000000000..80f2ff5430 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs @@ -0,0 +1,375 @@ +use rusqlite::{params, Connection, OptionalExtension}; + +/// One durable kind:30179 aggregate request retained for offline retry. +/// +/// The exact serialized request is immutable for a generation: retrying must +/// submit the same signed events and CAS predecessor, never rebuild them with a +/// fresh timestamp after local state has moved. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] // Consumed by the PMA aggregate submit/retry driver in the next slice. +pub struct RetainedManagedAgentAggregate { + pub owner_pubkey: String, + pub agent_pubkey: String, + pub generation: u64, + pub private_event_id: String, + pub state: String, + pub request_json: String, + pub pending_sync: bool, + pub last_error: Option, + /// Durable proof that a verified deletion has already applied the local + /// record/key erase for this exact generation. Only ever set on a + /// `state = "deleted"` row, immediately before `pending_sync` is cleared, + /// so crash-replay can distinguish "our verified deletion erased the + /// record" from an unrelated/manual local deletion. Always `false` on a + /// freshly retained row. + pub local_authority_applied: bool, +} + +pub fn retire_managed_agent_aggregate( + conn: &Connection, + owner_pubkey: &str, + agent_pubkey: &str, + generation: u64, + private_event_id: &str, +) -> Result { + let changed = conn + .execute( + "DELETE FROM managed_agent_aggregates + WHERE owner_pubkey = ?1 AND agent_pubkey = ?2 + AND generation = ?3 AND private_event_id = ?4 AND pending_sync = 1", + params![ + owner_pubkey, + agent_pubkey, + generation as i64, + private_event_id + ], + ) + .map_err(|error| format!("failed to retire managed-agent aggregate: {error}"))?; + Ok(changed == 1) +} + +pub fn seed_confirmed_managed_agent_aggregate( + conn: &Connection, + aggregate: &RetainedManagedAgentAggregate, +) -> Result<(), String> { + if aggregate.pending_sync || aggregate.state != "active" { + return Err("confirmed managed-agent seed must be a synced active head".to_string()); + } + conn.execute( + "INSERT OR IGNORE INTO managed_agent_aggregates + (owner_pubkey, agent_pubkey, generation, private_event_id, state, + request_json, pending_sync, last_error, local_authority_applied) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, 0)", + params![ + aggregate.owner_pubkey, + aggregate.agent_pubkey, + aggregate.generation as i64, + aggregate.private_event_id, + aggregate.state, + aggregate.request_json, + ], + ) + .map_err(|error| format!("failed to seed confirmed managed-agent aggregate: {error}"))?; + Ok(()) +} + +/// Persist a confirmed deleted tombstone row as a durable generation floor. +/// +/// Called from the inbound reconcile deleted branch so a verified tombstone +/// leaves a durable floor at its coordinate EVEN WHEN no local record exists — +/// the record-present guards upstream never run in that case, so without this +/// floor a delayed stale active head at the same-or-lower generation would +/// reconstruct the deleted agent (including its nsec). The tombstone is already +/// durable on the relay, so it is seeded confirmed (`pending_sync = 0`) and the +/// flush lane never touches it. `local_authority_applied` records whether the +/// local record/key erase was applied here (trivially satisfied when no record +/// existed). `INSERT OR IGNORE` keeps a re-received tombstone idempotent and +/// never downgrades a row the flush lane authored. +pub fn seed_confirmed_managed_agent_tombstone( + conn: &Connection, + aggregate: &RetainedManagedAgentAggregate, +) -> Result<(), String> { + if aggregate.pending_sync || aggregate.state != "deleted" { + return Err( + "confirmed managed-agent tombstone seed must be a synced deleted head".to_string(), + ); + } + conn.execute( + "INSERT OR IGNORE INTO managed_agent_aggregates + (owner_pubkey, agent_pubkey, generation, private_event_id, state, + request_json, pending_sync, last_error, local_authority_applied) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)", + params![ + aggregate.owner_pubkey, + aggregate.agent_pubkey, + aggregate.generation as i64, + aggregate.private_event_id, + aggregate.state, + aggregate.request_json, + aggregate.local_authority_applied as i32, + ], + ) + .map_err(|error| format!("failed to seed confirmed managed-agent tombstone: {error}"))?; + Ok(()) +} + +/// Insert or idempotently refresh a retained aggregate generation. +/// +/// Generation may advance by exactly one. A byte-identical rewrite of the +/// current generation is accepted for crash recovery; divergent same-generation +/// content and skipped/stale generations are rejected before touching disk. +#[allow(dead_code)] // Consumed by the PMA aggregate submit/retry driver in the next slice. +pub fn retain_managed_agent_aggregate( + conn: &mut Connection, + aggregate: &RetainedManagedAgentAggregate, +) -> Result<(), String> { + if aggregate.generation == 0 || aggregate.generation > i64::MAX as u64 { + return Err("managed-agent aggregate generation is out of range".to_string()); + } + if !matches!(aggregate.state.as_str(), "active" | "deleted") { + return Err("managed-agent aggregate state must be active or deleted".to_string()); + } + + if !aggregate.pending_sync || aggregate.last_error.is_some() { + return Err("new managed-agent aggregate must start pending without an error".to_string()); + } + if aggregate.local_authority_applied { + return Err( + "new managed-agent aggregate must not start with local authority applied".to_string(), + ); + } + + let tx = conn + .transaction() + .map_err(|e| format!("failed to retain managed-agent aggregate: {e}"))?; + let current = tx + .query_row( + "SELECT generation, private_event_id, state, request_json + FROM managed_agent_aggregates + WHERE owner_pubkey = ?1 AND agent_pubkey = ?2 + ORDER BY generation DESC + LIMIT 1", + params![aggregate.owner_pubkey, aggregate.agent_pubkey], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .optional() + .map_err(|e| format!("failed to read retained managed-agent aggregate: {e}"))?; + + if let Some((generation, event_id, state, request_json)) = current { + let generation = u64::try_from(generation) + .map_err(|_| "retained managed-agent aggregate generation is invalid".to_string())?; + if aggregate.generation == generation { + if aggregate.private_event_id != event_id + || aggregate.state != state + || aggregate.request_json != request_json + { + return Err( + "managed-agent aggregate conflicts with retained generation".to_string() + ); + } + // An exact retry is a true no-op. Do not re-arm an already + // confirmed row or erase its persisted diagnostic on startup. + return Ok(()); + } + + let next_generation = generation.checked_add(1).ok_or_else(|| { + "retained managed-agent aggregate generation cannot advance".to_string() + })?; + if aggregate.generation != next_generation { + return Err(format!( + "managed-agent aggregate generation must advance from {generation} to {next_generation}" + )); + } + } else if aggregate.generation != 1 { + return Err("first retained managed-agent aggregate must be generation 1".to_string()); + } + + tx.execute( + "INSERT INTO managed_agent_aggregates + (owner_pubkey, agent_pubkey, generation, private_event_id, state, + request_json, pending_sync, last_error) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, NULL)", + params![ + aggregate.owner_pubkey, + aggregate.agent_pubkey, + aggregate.generation as i64, + aggregate.private_event_id, + aggregate.state, + aggregate.request_json, + ], + ) + .map_err(|e| format!("failed to write retained managed-agent aggregate: {e}"))?; + tx.commit() + .map_err(|e| format!("failed to commit retained managed-agent aggregate: {e}")) +} + +#[allow(dead_code)] // Consumed by the PMA aggregate submit/retry driver in the next slice. +pub fn get_retained_managed_agent_aggregate( + conn: &Connection, + owner_pubkey: &str, + agent_pubkey: &str, +) -> Result, String> { + conn.query_row( + "SELECT owner_pubkey, agent_pubkey, generation, private_event_id, state, + request_json, pending_sync, last_error, local_authority_applied + FROM managed_agent_aggregates + WHERE owner_pubkey = ?1 AND agent_pubkey = ?2 + ORDER BY generation DESC + LIMIT 1", + params![owner_pubkey, agent_pubkey], + aggregate_from_row, + ) + .optional() + .map_err(|e| format!("failed to get retained managed-agent aggregate: {e}")) +} + +/// Snapshot every pending aggregate attempt for one captured owner scope. +/// +/// At most one generation per agent may be pending during normal operation, +/// but selecting the latest pending generation defensively avoids replaying a +/// superseded row after a crash between generation advance and acknowledgement. +pub fn get_pending_managed_agent_aggregates( + conn: &Connection, + owner_pubkey: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT a.owner_pubkey, a.agent_pubkey, a.generation, + a.private_event_id, a.state, a.request_json, + a.pending_sync, a.last_error, a.local_authority_applied + FROM managed_agent_aggregates a + WHERE a.owner_pubkey = ?1 AND a.pending_sync = 1 + AND a.generation = ( + SELECT MAX(latest.generation) + FROM managed_agent_aggregates latest + WHERE latest.owner_pubkey = a.owner_pubkey + AND latest.agent_pubkey = a.agent_pubkey + ) + ORDER BY a.agent_pubkey", + ) + .map_err(|e| format!("failed to prepare pending managed-agent aggregates: {e}"))?; + let rows = stmt + .query_map(params![owner_pubkey], aggregate_from_row) + .map_err(|e| format!("failed to query pending managed-agent aggregates: {e}"))?; + rows.collect::, _>>() + .map_err(|e| format!("failed to read pending managed-agent aggregate: {e}")) +} + +fn aggregate_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let generation = row.get::<_, i64>(2)?; + let generation = u64::try_from(generation).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?; + Ok(RetainedManagedAgentAggregate { + owner_pubkey: row.get(0)?, + agent_pubkey: row.get(1)?, + generation, + private_event_id: row.get(3)?, + state: row.get(4)?, + request_json: row.get(5)?, + pending_sync: row.get::<_, i32>(6)? != 0, + last_error: row.get(7)?, + local_authority_applied: row.get::<_, i32>(8)? != 0, + }) +} + +/// Mark one exact retained aggregate request as confirmed by relay read-back. +#[allow(dead_code)] // Consumed by the PMA aggregate submit/retry driver in the next slice. +pub fn mark_managed_agent_aggregate_synced( + conn: &Connection, + owner_pubkey: &str, + agent_pubkey: &str, + generation: u64, + private_event_id: &str, +) -> Result { + let changed = conn + .execute( + "UPDATE managed_agent_aggregates + SET pending_sync = 0, last_error = NULL + WHERE owner_pubkey = ?1 AND agent_pubkey = ?2 + AND generation = ?3 AND private_event_id = ?4", + params![ + owner_pubkey, + agent_pubkey, + generation as i64, + private_event_id + ], + ) + .map_err(|e| format!("failed to mark managed-agent aggregate synced: {e}"))?; + Ok(changed == 1) +} + +/// Persist a diagnostic for one exact retained attempt without clearing retry. +#[allow(dead_code)] // Consumed by the PMA aggregate submit/retry driver in the next slice. +pub fn record_managed_agent_aggregate_error( + conn: &Connection, + owner_pubkey: &str, + agent_pubkey: &str, + generation: u64, + private_event_id: &str, + error: &str, +) -> Result { + let changed = conn + .execute( + "UPDATE managed_agent_aggregates + SET last_error = ?5, pending_sync = 1 + WHERE owner_pubkey = ?1 AND agent_pubkey = ?2 + AND generation = ?3 AND private_event_id = ?4", + params![ + owner_pubkey, + agent_pubkey, + generation as i64, + private_event_id, + error + ], + ) + .map_err(|e| format!("failed to record managed-agent aggregate error: {e}"))?; + Ok(changed == 1) +} + +/// Durably record that a verified deletion has applied the local record/key +/// erase for one exact retained tombstone generation. +/// +/// Written on the `state = "deleted"` row immediately AFTER the local erase and +/// BEFORE [`mark_managed_agent_aggregate_synced`] clears the retry. Together +/// they order the crash-safe deletion seam: erase → mark-applied → clear. On +/// replay, a set marker (never mere record absence) proves the exact deletion +/// reached local authority, so the retry may be cleared without re-erasing an +/// unrelated agent. Returns `true` iff the exact `(owner, agent, generation, +/// event)` deleted row was updated. +#[allow(dead_code)] // Consumed by the deletion flush lane. +pub fn mark_managed_agent_deletion_local_authority_applied( + conn: &Connection, + owner_pubkey: &str, + agent_pubkey: &str, + generation: u64, + private_event_id: &str, +) -> Result { + let changed = conn + .execute( + "UPDATE managed_agent_aggregates + SET local_authority_applied = 1 + WHERE owner_pubkey = ?1 AND agent_pubkey = ?2 + AND generation = ?3 AND private_event_id = ?4 + AND state = 'deleted'", + params![ + owner_pubkey, + agent_pubkey, + generation as i64, + private_event_id + ], + ) + .map_err(|e| format!("failed to mark managed-agent deletion authority applied: {e}"))?; + Ok(changed == 1) +} 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..030a9c4fed --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/tests.rs @@ -0,0 +1,806 @@ +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, + } +} + +fn aggregate( + generation: u64, + event_id: &str, + state: &str, + request_json: &str, +) -> RetainedManagedAgentAggregate { + RetainedManagedAgentAggregate { + owner_pubkey: "owner".to_string(), + agent_pubkey: "agent".to_string(), + generation, + private_event_id: event_id.to_string(), + state: state.to_string(), + request_json: request_json.to_string(), + pending_sync: true, + last_error: None, + local_authority_applied: false, + } +} + +#[test] +fn aggregate_retention_enforces_contiguous_immutable_generations() { + let mut conn = test_db(); + let first = aggregate(1, "event-1", "active", r#"{"generation":1}"#); + retain_managed_agent_aggregate(&mut conn, &first).unwrap(); + retain_managed_agent_aggregate(&mut conn, &first).unwrap(); + + let conflicting = aggregate(1, "other", "active", r#"{"generation":1}"#); + assert!(retain_managed_agent_aggregate(&mut conn, &conflicting) + .unwrap_err() + .contains("conflicts")); + let skipped = aggregate(3, "event-3", "deleted", r#"{"generation":3}"#); + assert!(retain_managed_agent_aggregate(&mut conn, &skipped) + .unwrap_err() + .contains("advance from 1 to 2")); + + let tombstone = aggregate(2, "event-2", "deleted", r#"{"generation":2}"#); + retain_managed_agent_aggregate(&mut conn, &tombstone).unwrap(); + assert_eq!( + get_retained_managed_agent_aggregate(&conn, "owner", "agent").unwrap(), + Some(tombstone) + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM managed_agent_aggregates + WHERE owner_pubkey = 'owner' AND agent_pubkey = 'agent'", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 2 + ); +} + +#[test] +fn pending_aggregate_snapshot_is_owner_scoped_and_latest_only() { + let mut conn = test_db(); + retain_managed_agent_aggregate( + &mut conn, + &aggregate(1, "event-a1", "active", r#"{"generation":1}"#), + ) + .unwrap(); + retain_managed_agent_aggregate( + &mut conn, + &aggregate(2, "event-a2", "deleted", r#"{"generation":2}"#), + ) + .unwrap(); + let mut other = aggregate(1, "event-b1", "active", r#"{"generation":1}"#); + other.owner_pubkey = "other-owner".into(); + other.agent_pubkey = "other-agent".into(); + retain_managed_agent_aggregate(&mut conn, &other).unwrap(); + + let pending = get_pending_managed_agent_aggregates(&conn, "owner").unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].generation, 2); + assert_eq!(pending[0].private_event_id, "event-a2"); +} + +#[test] +fn aggregate_compare_and_clear_cannot_ack_a_newer_attempt() { + let mut conn = test_db(); + retain_managed_agent_aggregate( + &mut conn, + &aggregate(1, "event-1", "active", r#"{"generation":1}"#), + ) + .unwrap(); + retain_managed_agent_aggregate( + &mut conn, + &aggregate(2, "event-2", "deleted", r#"{"generation":2}"#), + ) + .unwrap(); + + assert!(mark_managed_agent_aggregate_synced(&conn, "owner", "agent", 1, "event-1").unwrap()); + // A stale success can clear its own retained attempt, but cannot clear + // or otherwise acknowledge the newer tombstone. + assert!( + get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap() + .pending_sync + ); + assert!(mark_managed_agent_aggregate_synced(&conn, "owner", "agent", 2, "event-2").unwrap()); + assert!( + !get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap() + .pending_sync + ); + + // Rebuilding the exact same generation during startup must not turn a + // confirmed row back into pending work. + retain_managed_agent_aggregate( + &mut conn, + &aggregate(2, "event-2", "deleted", r#"{"generation":2}"#), + ) + .unwrap(); + assert!( + !get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap() + .pending_sync + ); +} + +#[test] +fn aggregate_error_is_scoped_to_the_exact_attempt() { + let mut conn = test_db(); + retain_managed_agent_aggregate( + &mut conn, + &aggregate(1, "event-1", "active", r#"{"generation":1}"#), + ) + .unwrap(); + assert!( + !record_managed_agent_aggregate_error(&conn, "owner", "agent", 1, "wrong", "nope").unwrap() + ); + assert!(record_managed_agent_aggregate_error( + &conn, + "owner", + "agent", + 1, + "event-1", + "relay unavailable" + ) + .unwrap()); + let row = get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap(); + assert!(row.pending_sync); + assert_eq!(row.last_error.as_deref(), Some("relay unavailable")); +} + +#[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 + )); +} + +#[test] +fn deletion_marker_is_the_terminal_crash_replay_proof() { + // Carl's correction: bare record absence must NOT license clearing the + // retry. The durable `local_authority_applied` marker is the proof that + // THIS exact verified deletion reached local authority. + let mut conn = test_db(); + retain_managed_agent_aggregate( + &mut conn, + &aggregate(1, "event-1", "active", r#"{"generation":1}"#), + ) + .unwrap(); + retain_managed_agent_aggregate( + &mut conn, + &aggregate(2, "event-2", "deleted", r#"{"generation":2}"#), + ) + .unwrap(); + + // Freshly retained: marker is unset — absence of the marker means the + // flush has not proven erase yet. + let row = get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap(); + assert!(!row.local_authority_applied); + + // A new aggregate can never be born with the marker set. + let mut premature = aggregate(3, "event-3", "deleted", r#"{"generation":3}"#); + premature.local_authority_applied = true; + assert!(retain_managed_agent_aggregate(&mut conn, &premature) + .unwrap_err() + .contains("must not start with local authority applied")); + + // The marker sets only the EXACT deleted row and only once effectively. + assert!(mark_managed_agent_deletion_local_authority_applied( + &conn, "owner", "agent", 2, "event-2" + ) + .unwrap()); + let marked = get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap(); + assert!( + marked.local_authority_applied, + "the marker is durable and read back" + ); + + // A wrong coordinate (generation/event/owner/agent) never sets it. + assert!(!mark_managed_agent_deletion_local_authority_applied( + &conn, + "owner", + "agent", + 2, + "wrong-event" + ) + .unwrap()); + assert!(!mark_managed_agent_deletion_local_authority_applied( + &conn, + "owner", + "other-agent", + 2, + "event-2" + ) + .unwrap()); +} + +#[test] +fn deletion_marker_refuses_a_non_deleted_row() { + // The marker is a deletion-terminal proof; it must never flip an active + // (promotion) row. `mark_managed_agent_deletion_local_authority_applied` + // is scoped to state = 'deleted'. + let mut conn = test_db(); + retain_managed_agent_aggregate( + &mut conn, + &aggregate(1, "event-1", "active", r#"{"generation":1}"#), + ) + .unwrap(); + assert!(!mark_managed_agent_deletion_local_authority_applied( + &conn, "owner", "agent", 1, "event-1" + ) + .unwrap()); + let row = get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap(); + assert!(!row.local_authority_applied); +} + +/// The durable generation floor for a coordinate: the MAX retained generation +/// across BOTH active and deleted rows. Mirrors the gate the inbound reconcile +/// applies before it inserts a no-match active head — `get_retained_...` +/// returns the latest generation regardless of state, so a persisted tombstone +/// raises the floor exactly like a persisted active head. +fn floored(conn: &Connection, incoming_generation: u64) -> bool { + get_retained_managed_agent_aggregate(conn, "owner", "agent") + .unwrap() + .is_some_and(|floor| incoming_generation <= floor.generation) +} + +/// A confirmed tombstone seed persisted with no prior local record raises the +/// durable floor, so the exact resurrection interleaving is closed: +/// snapshot active N → live deleted N+1 → replayed active N must stay floored, +/// while a genuine re-creation at N+2 clears the floor. +#[test] +fn confirmed_tombstone_seed_floors_a_replayed_stale_active_head() { + let conn = test_db(); + + // Snapshot active N=1 seeds a confirmed active floor at 1. + let mut active_n = aggregate(1, "event-1", "active", r#"{"generation":1}"#); + active_n.pending_sync = false; + seed_confirmed_managed_agent_aggregate(&conn, &active_n).unwrap(); + assert!(floored(&conn, 1), "the replay of active N is floored"); + assert!( + !floored(&conn, 2), + "a genuine advance to N+1 is not floored" + ); + + // Live deleted N+1 seeds a confirmed tombstone floor at 2 — the crux: this + // path runs even when NO local record exists, so the floor is durable and a + // later stale active head cannot resurrect the deleted agent. + let mut tombstone = aggregate(2, "event-2", "deleted", r#"{"generation":2}"#); + tombstone.pending_sync = false; + tombstone.local_authority_applied = true; + seed_confirmed_managed_agent_tombstone(&conn, &tombstone).unwrap(); + let floor = get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap(); + assert_eq!(floor.generation, 2); + assert_eq!(floor.state, "deleted"); + assert!(floor.local_authority_applied); + + // Replayed active N=1: floored — the agent stays absent. + assert!( + floored(&conn, 1), + "replayed active N is rejected by the tombstone floor" + ); + // Deleted N+1 replayed: also floored (idempotent re-receive is a no-op). + assert!(floored(&conn, 2), "re-received tombstone N+1 is floored"); + // Genuine re-creation at N+2 clears the floor and is admitted. + assert!( + !floored(&conn, 3), + "a fresh re-creation at N+2 is not floored" + ); +} + +/// A confirmed tombstone seed with no prior record is idempotent and never +/// downgrades a row the flush lane authored (`INSERT OR IGNORE`). +#[test] +fn confirmed_tombstone_seed_is_idempotent_and_never_downgrades() { + let conn = test_db(); + let mut tombstone = aggregate(2, "event-2", "deleted", r#"{"generation":2}"#); + tombstone.pending_sync = false; + tombstone.local_authority_applied = true; + seed_confirmed_managed_agent_tombstone(&conn, &tombstone).unwrap(); + // Re-receive: no error, no duplicate row, marker preserved. + seed_confirmed_managed_agent_tombstone(&conn, &tombstone).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM managed_agent_aggregates + WHERE owner_pubkey = 'owner' AND agent_pubkey = 'agent'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + let row = get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap(); + assert!(row.local_authority_applied); + assert!(!row.pending_sync); +} + +/// The confirmed tombstone seed refuses anything that is not a synced deleted +/// head — a pending row or an active state would corrupt the floor's meaning. +#[test] +fn confirmed_tombstone_seed_refuses_pending_or_non_deleted() { + let conn = test_db(); + + let mut pending = aggregate(2, "event-2", "deleted", r#"{"generation":2}"#); + pending.pending_sync = true; + assert!(seed_confirmed_managed_agent_tombstone(&conn, &pending) + .unwrap_err() + .contains("synced deleted head")); + + let mut active = aggregate(2, "event-2", "active", r#"{"generation":2}"#); + active.pending_sync = false; + assert!(seed_confirmed_managed_agent_tombstone(&conn, &active) + .unwrap_err() + .contains("synced deleted head")); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index bea4b1c3e3..ffc81d93e9 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1,5 +1,4 @@ use crate::managed_agents::known_acp_runtime; - // ── desktop binary name tests ─────────────────────────────────────────── #[test] @@ -181,6 +180,7 @@ fn fixture( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 1ceeee372f..afd2ef0150 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -70,6 +70,7 @@ fn record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76..4ff26f9531 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -309,6 +309,7 @@ mod tests { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda9..e584e25710 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -213,6 +213,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e5be105fed..36264a6ceb 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -153,6 +153,7 @@ impl AgentDefinition { definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, relay_mesh: None, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), } } } @@ -438,6 +439,16 @@ pub struct ManagedAgentRecord { /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, + /// Per-agent relay-canonical authority state + verified head evidence. + /// + /// `#[serde(default)]` so a store written by a build that predates this + /// field deserializes as [`RelayAuthority::LegacyOnly`]: local JSON + + /// keyring stay canonical and boot reconcile keeps republishing the public + /// projection. No record is silently promoted; promotion happens only + /// through the verified (not-yet-enabled) migration path. See + /// [`super::authority`]. + #[serde(default)] + pub relay_authority: super::authority::RelayAuthority, } /// Typed relay-mesh configuration carried on a [`ManagedAgentRecord`]. @@ -884,109 +895,8 @@ impl RespondTo { /// - Each entry is exactly 64 hex chars (any case in, lowercase out). /// - Duplicates removed, insertion order preserved. /// -/// Empty input is allowed here — the boundary check (allowlist mode requires -/// at least one entry) is the caller's job, because an `UpdateManagedAgentRequest` -/// may want to validate a list without yet knowing the final mode. -pub fn validate_respond_to_allowlist(input: &[String]) -> Result, String> { - let mut seen = std::collections::HashSet::new(); - let mut out = Vec::with_capacity(input.len()); - for entry in input { - let trimmed = entry.trim(); - if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(format!( - "invalid pubkey in respond-to allowlist: '{trimmed}' (must be 64 hex chars)" - )); - } - let lower = trimmed.to_ascii_lowercase(); - if seen.insert(lower.clone()) { - out.push(lower); - } - } - Ok(out) -} - -/// The behavioral fields resolved for a new instance at mint time. -#[derive(Debug, PartialEq, Eq)] -pub struct MintBehavioralDefaults { - pub respond_to: RespondTo, - pub respond_to_allowlist: Vec, - /// Validated (1..=32) when present; caller applies its own default. - pub parallelism: Option, -} - -/// Resolve the NIP-AP behavioral quad for a new instance: explicit input -/// wins, then the linked definition's defaults, then client defaults. -/// -/// This is the ONLY place definition behavioral strings are parsed — an -/// unrecognized `respond_to` mode or out-of-range `parallelism` on a -/// definition fails the mint loudly instead of silently substituting a -/// default the definition author did not choose. The empty-allowlist guard -/// fires here too, because inbound definitions bypass the dialog entirely. -/// -/// `input_allowlist` must already be normalized via -/// [`validate_respond_to_allowlist`]; the definition's allowlist is -/// validated here since it arrives from the wire. -pub fn resolve_mint_behavioral_defaults( - input_respond_to: Option, - input_allowlist: Vec, - input_parallelism: Option, - definition: Option<&AgentDefinition>, -) -> Result { - let (respond_to, respond_to_allowlist) = match input_respond_to { - // Explicit instance-level choice: the definition default is ignored - // wholesale (mode AND list travel together). - Some(mode) => (mode, input_allowlist), - None => match definition.and_then(|d| d.respond_to.as_deref()) { - Some(wire) => { - let mode = RespondTo::parse_wire(wire)?; - let list = if input_allowlist.is_empty() { - validate_respond_to_allowlist( - definition - .map(|d| d.respond_to_allowlist.as_slice()) - .unwrap_or(&[]), - ) - .map_err(|e| format!("definition respond-to allowlist is invalid: {e}"))? - } else { - input_allowlist - }; - (mode, list) - } - None => (RespondTo::default(), input_allowlist), - }, - }; - if respond_to == RespondTo::Allowlist && respond_to_allowlist.is_empty() { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), - ); - } - - let parallelism = match input_parallelism { - // Explicit input is validated here too (not just at the command - // call sites) so the "validated when present" contract on - // `MintBehavioralDefaults.parallelism` is unskippable. - Some(count) if (1..=32).contains(&count) => Some(count), - Some(count) => { - return Err(format!( - "parallelism {count} is out of range (must be between 1 and 32)" - )) - } - None => match definition.and_then(|d| d.parallelism) { - Some(count) if (1..=32).contains(&count) => Some(count), - Some(count) => { - return Err(format!( - "parallelism {count} on the linked agent definition is out of range (must be between 1 and 32)" - )) - } - None => None, - }, - }; - - Ok(MintBehavioralDefaults { - respond_to, - respond_to_allowlist, - parallelism, - }) -} +mod behavioral_defaults; +pub use behavioral_defaults::*; mod catalog_source; pub use catalog_source::CatalogSource; diff --git a/desktop/src-tauri/src/managed_agents/types/behavioral_defaults.rs b/desktop/src-tauri/src/managed_agents/types/behavioral_defaults.rs new file mode 100644 index 0000000000..3cc40d471d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/behavioral_defaults.rs @@ -0,0 +1,105 @@ +use super::{AgentDefinition, RespondTo}; + +/// Empty input is allowed here — the boundary check (allowlist mode requires +/// at least one entry) is the caller's job, because an `UpdateManagedAgentRequest` +/// may want to validate a list without yet knowing the final mode. +pub fn validate_respond_to_allowlist(input: &[String]) -> Result, String> { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::with_capacity(input.len()); + for entry in input { + let trimmed = entry.trim(); + if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "invalid pubkey in respond-to allowlist: '{trimmed}' (must be 64 hex chars)" + )); + } + let lower = trimmed.to_ascii_lowercase(); + if seen.insert(lower.clone()) { + out.push(lower); + } + } + Ok(out) +} + +/// The behavioral fields resolved for a new instance at mint time. +#[derive(Debug, PartialEq, Eq)] +pub struct MintBehavioralDefaults { + pub respond_to: RespondTo, + pub respond_to_allowlist: Vec, + /// Validated (1..=32) when present; caller applies its own default. + pub parallelism: Option, +} + +/// Resolve the NIP-AP behavioral quad for a new instance: explicit input +/// wins, then the linked definition's defaults, then client defaults. +/// +/// This is the ONLY place definition behavioral strings are parsed — an +/// unrecognized `respond_to` mode or out-of-range `parallelism` on a +/// definition fails the mint loudly instead of silently substituting a +/// default the definition author did not choose. The empty-allowlist guard +/// fires here too, because inbound definitions bypass the dialog entirely. +/// +/// `input_allowlist` must already be normalized via +/// [`validate_respond_to_allowlist`]; the definition's allowlist is +/// validated here since it arrives from the wire. +pub fn resolve_mint_behavioral_defaults( + input_respond_to: Option, + input_allowlist: Vec, + input_parallelism: Option, + definition: Option<&AgentDefinition>, +) -> Result { + let (respond_to, respond_to_allowlist) = match input_respond_to { + // Explicit instance-level choice: the definition default is ignored + // wholesale (mode AND list travel together). + Some(mode) => (mode, input_allowlist), + None => match definition.and_then(|d| d.respond_to.as_deref()) { + Some(wire) => { + let mode = RespondTo::parse_wire(wire)?; + let list = if input_allowlist.is_empty() { + validate_respond_to_allowlist( + definition + .map(|d| d.respond_to_allowlist.as_slice()) + .unwrap_or(&[]), + ) + .map_err(|e| format!("definition respond-to allowlist is invalid: {e}"))? + } else { + input_allowlist + }; + (mode, list) + } + None => (RespondTo::default(), input_allowlist), + }, + }; + if respond_to == RespondTo::Allowlist && respond_to_allowlist.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + let parallelism = match input_parallelism { + // Explicit input is validated here too (not just at the command + // call sites) so the "validated when present" contract on + // `MintBehavioralDefaults.parallelism` is unskippable. + Some(count) if (1..=32).contains(&count) => Some(count), + Some(count) => { + return Err(format!( + "parallelism {count} is out of range (must be between 1 and 32)" + )) + } + None => match definition.and_then(|d| d.parallelism) { + Some(count) if (1..=32).contains(&count) => Some(count), + Some(count) => { + return Err(format!( + "parallelism {count} on the linked agent definition is out of range (must be between 1 and 32)" + )) + } + None => None, + }, + }; + + Ok(MintBehavioralDefaults { + respond_to, + respond_to_allowlist, + parallelism, + }) +} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index fbaf1f5274..f3727598c0 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -356,7 +356,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { BOB, ); - assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer"); + assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`); assert.equal(personas[0].isActive, false); }); @@ -377,7 +377,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { ALICE, ); - assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer"); + assert.equal(personas[0].id, `catalog:${BOB}:reviewer`); assert.equal(personas[0].isActive, false); }); diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index 0dc12ddfd1..a9b8a04cac 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -6,6 +6,7 @@ import { KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, + KIND_PRIVATE_MANAGED_AGENT, KIND_TEAM, } from "@/shared/constants/kinds"; import { startPersonaSync } from "./usePersonaSync.ts"; @@ -14,6 +15,7 @@ const EXPECTED_KINDS = [ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_PRIVATE_MANAGED_AGENT, KIND_DELETION, ]; @@ -37,17 +39,21 @@ test("startPersonaSync backfills history including the deletion kind", () => { startPersonaSync("owner-pubkey", "wss://relay.example", () => false); - assert.equal(fetchCalls.length, 1, "must do exactly one backfill fetch"); - assert.deepEqual( - fetchCalls[0].kinds, - EXPECTED_KINDS, - "backfill must cover persona/team/agent + deletion", + assert.equal( + fetchCalls.length, + 2, + "PMA heads need a dedicated backfill fetch", ); - assert.ok( - fetchCalls[0].limit > 0, - "backfill must request a positive limit — limit:0 returns no history", + assert.deepEqual(fetchCalls[0].kinds, [KIND_PRIVATE_MANAGED_AGENT]); + assert.deepEqual( + fetchCalls[1].kinds, + [KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, KIND_DELETION], + "compatibility backfill must cover persona/team/agent + deletion", ); - assert.deepEqual(fetchCalls[0].authors, ["owner-pubkey"]); + for (const call of fetchCalls) { + assert.ok(call.limit > 0, "backfill must request a positive limit"); + assert.deepEqual(call.authors, ["owner-pubkey"]); + } assert.equal(liveCalls.length, 1); assert.deepEqual( @@ -79,9 +85,11 @@ test("startPersonaSync forwards its own relay as the event arrival relay", async const ownEvent = { id: "e1", pubkey: "owner-pubkey", kind: KIND_PERSONA }; const foreignEvent = { id: "e2", pubkey: "someone-else", kind: KIND_PERSONA }; - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ownEvent, foreignEvent]), - ); + let fetchIndex = 0; + mock.method(relayClient, "fetchEvents", () => { + fetchIndex += 1; + return Promise.resolve(fetchIndex === 1 ? [ownEvent, foreignEvent] : []); + }); mock.method(relayClient, "subscribeLive", () => Promise.resolve(() => Promise.resolve()), ); diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index f18194c5c6..a79b1f8afa 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -7,6 +7,7 @@ import { KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, + KIND_PRIVATE_MANAGED_AGENT, KIND_TEAM, } from "@/shared/constants/kinds"; @@ -17,8 +18,61 @@ const PERSONA_SYNC_KINDS = [ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_PRIVATE_MANAGED_AGENT, KIND_DELETION, ]; +const COMPATIBILITY_SYNC_KINDS = [ + KIND_PERSONA, + KIND_TEAM, + KIND_MANAGED_AGENT, + KIND_DELETION, +]; +const HYDRATION_PAGE_SIZE = 1_000; + +async function fetchAllPersonaSyncEvents( + pubkey: string, + kinds: number[], +): Promise { + const byId = new Map(); + let until: number | undefined; + while (true) { + const page = await relayClient.fetchEvents({ + kinds, + authors: [pubkey], + limit: HYDRATION_PAGE_SIZE, + ...(until === undefined ? {} : { until }), + }); + const sizeBefore = byId.size; + let oldestCreatedAt = Number.POSITIVE_INFINITY; + for (const event of page) { + byId.set(event.id, event); + oldestCreatedAt = Math.min(oldestCreatedAt, event.created_at); + } + if (page.length < HYDRATION_PAGE_SIZE || byId.size === sizeBefore) break; + until = oldestCreatedAt; + } + return [...byId.values()]; +} + +export async function hydratePersonaSync( + pubkey: string, + relayUrl: string, + onCancelled: () => boolean = () => false, +): Promise { + // Keep PMA heads in their own writer-backed REQ. They must neither compete + // with compatibility events for one LIMIT nor come from a stale replica when + // Welcome provisioning uses the result to decide whether to mint an identity. + const [privateHeads, compatibilityEvents] = await Promise.all([ + fetchAllPersonaSyncEvents(pubkey, [KIND_PRIVATE_MANAGED_AGENT]), + fetchAllPersonaSyncEvents(pubkey, COMPATIBILITY_SYNC_KINDS), + ]); + const events = [...privateHeads, ...compatibilityEvents]; + for (const event of events) { + if (onCancelled()) return; + if (event.pubkey !== pubkey) continue; + await reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl); + } +} // Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`: // one-shot backfill of existing heads + tombstones, then a live subscription. @@ -46,15 +100,9 @@ export function startPersonaSync( // One-shot backfill of existing heads + tombstones (closes the fresh-start // gap that live-only subscription + reconnect-replay cannot recover). - void relayClient - .fetchEvents({ kinds: PERSONA_SYNC_KINDS, authors: [pubkey], limit: 500 }) - .then((events) => { - if (onCancelled()) return; - for (const event of events) reconcile(event); - }) - .catch((error) => { - console.warn("[usePersonaSync] backfill failed:", error); - }); + void hydratePersonaSync(pubkey, relayUrl, onCancelled).catch((error) => { + console.warn("[usePersonaSync] backfill failed:", error); + }); let unsub: (() => Promise) | null = null; void relayClient diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index b3def930f1..6720d3f971 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { activateWelcomeTeamPersonasSequentially, buildWelcomeStarterCreateInput, + hydrateWelcomeTeamAgentsBeforeProvision, LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT, pickWelcomeGuideAgent, pickWelcomeGuideAgentForRelay, @@ -15,6 +16,30 @@ import { WELCOME_TEAM_STARTERS, } from "./welcomeGuide.ts"; +test("Welcome provisioning awaits relay hydration before inspecting agents", async () => { + const calls = []; + await hydrateWelcomeTeamAgentsBeforeProvision( + "wss://relay.example///", + async () => { + calls.push("identity"); + return { pubkey: PUB_A }; + }, + async (pubkey, relayUrl) => { + calls.push(`hydrate:${pubkey}:${relayUrl}`); + }, + ); + assert.deepEqual(calls, ["identity", `hydrate:${PUB_A}:wss://relay.example`]); +}); + +test("Welcome provisioning skips hydration without a relay scope", async () => { + let called = false; + await hydrateWelcomeTeamAgentsBeforeProvision(null, async () => { + called = true; + return { pubkey: PUB_A }; + }); + assert.equal(called, false); +}); + const PUB_A = "a".repeat(64); const PUB_B = "b".repeat(64); const PUB_C = "c".repeat(64); diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index aa8deb7c19..c2f727c93d 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -1,3 +1,4 @@ +import { hydratePersonaSync } from "@/features/agents/lib/usePersonaSync"; import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, @@ -11,6 +12,7 @@ import { updateManagedAgent, } from "@/shared/api/tauri"; import { getGlobalAgentConfig } from "@/shared/api/tauriGlobalAgentConfig"; +import { getIdentity } from "@/shared/api/tauriIdentity"; import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas"; import type { AcpRuntime, @@ -53,6 +55,17 @@ function normalizeRelayUrl(relayUrl: string | null | undefined) { return relayUrl?.trim().replace(/\/+$/, "") ?? null; } +export async function hydrateWelcomeTeamAgentsBeforeProvision( + relayUrl: string | null | undefined, + resolveIdentity: typeof getIdentity = getIdentity, + hydrate: typeof hydratePersonaSync = hydratePersonaSync, +): Promise { + const normalizedRelay = normalizeRelayUrl(relayUrl); + if (!normalizedRelay) return; + const identity = await resolveIdentity(); + await hydrate(identity.pubkey, normalizedRelay); +} + function isAgentScopedToRelay(agent: ManagedAgent, relayUrl?: string | null) { const targetRelayUrl = normalizeRelayUrl(relayUrl); if (!targetRelayUrl) { @@ -269,6 +282,7 @@ async function provisionWelcomeTeam( channelId: string, relayUrl?: string | null, ): Promise { + await hydrateWelcomeTeamAgentsBeforeProvision(relayUrl); const existingAgents = await listManagedAgents(); await ensureWelcomeTeamPersonasActive(); const [personas, runtimeCatalog, globalConfig] = await Promise.all([ diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index f995a63596..db34de5cbb 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -53,6 +53,8 @@ export const KIND_COMMUNITY_THEME = 30078; export const KIND_PERSONA = 30175; export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; +/** Owner-encrypted relay-canonical managed-agent aggregate (NIP-PMA). */ +export const KIND_PRIVATE_MANAGED_AGENT = 30179; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; export const KIND_AGENT_TURN_METRIC = 44200; diff --git a/docs/nips/NIP-PMA.md b/docs/nips/NIP-PMA.md index 82592b1eca..6c18a8e62f 100644 --- a/docs/nips/NIP-PMA.md +++ b/docs/nips/NIP-PMA.md @@ -4,6 +4,11 @@ privacy, transactional CAS, backup/restore, revocation, and capability gates are deployed. +> **Deployment:** the foundation migration is maintenance-window-only on an +> existing relay. Replacing `events.search_tsv` takes an `ACCESS EXCLUSIVE` +> lock, rewrites the table, and rebuilds its GIN index; do not run it as part of +> a rolling deployment. + ## Purpose and kind Kind `30179` is an owner-authored, addressable, owner-readable aggregate for one @@ -80,33 +85,67 @@ NIP-33 LWW is explicitly insufficient. - transient local only: PID and all last start/stop/exit/error receipts/logs. Adding a `ManagedAgentRecord` field must update an exhaustive Desktop -classification/conversion fixture before migration-writing code can merge. -This inert core-only reservation does not yet depend on the Desktop type and -therefore does not claim to provide that compile-time tripwire. +classification/conversion fixture before migration-writing code can merge. The +fixture is part of the relay-authority follow-up and fails when a durable field +has no explicit private, projection, local/derived, or transient classification. ## Aggregate submission boundary Three ordinary Nostr `EVENT` writes cannot atomically commit an aggregate. The -future relay contract accepts independently signed projection candidates plus -the signed private head through one authenticated aggregate submission and one -PostgreSQL transaction. It validates CAS predecessor/generation, signatures, -hashes, recovery material, definition revision, tombstone watermark, and all -coordinates before exposing any candidate. Fan-out begins only after commit. - -Public catalog definitions require an independently verifiable public -CAS/revision head; browsing must never require decrypting kind `30179`. +relay contract accepts independently signed projection candidates plus the +signed encrypted private head through one authenticated aggregate submission +and one PostgreSQL transaction. The relay validates the outer envelope, owner +and agent coordinates, signed public candidates, relay-owned definition +revision, and CAS predecessor/generation before exposing any candidate. Fan-out +begins only after commit. + +The relay cannot decrypt kind `30179` and MUST NOT receive the plaintext payload +or agent nsec. It therefore cannot prove that ciphertext-internal bindings match +the co-submitted public projections. It records the exact submitted projections +as the active bindings; Desktop performs the integrity gate by decrypting a +writer-consistent read-back and verifying those bindings before promoting the +agent to relay-authoritative state. + +A definition edit shared by multiple authoritative agents advances each agent's +aggregate independently. Agents may transiently pin different revisions of the +same definition coordinate; each immutable aggregate revision retains its exact +bound recovery bytes while catalog browsing reads the latest active public +projection. Browsing never requires decrypting kind `30179`. + +The relay's ordinary-write fence for a `30175` definition coordinate follows +active aggregate bindings, not retained definition revision history. When the +last active agent bound to a definition is tombstoned, that coordinate is no +longer PMA-authoritative and an ordinary owner-authored NIP-33 write may become +the public head. `managed_agent_definition_heads` still retains the revision +floor; a later aggregate rebind advances from that retained revision rather than +adopting generic LWW history as an untracked revision. Deleted `30177` instance +coordinates remain fenced by their deleted aggregate head, so neither ordinary +writes nor legacy kind `5` deletion can bypass generation CAS. + +## Deployment note: migration 0029 + +`0029_private_managed_agent_foundation.sql` changes `events.search_tsv` by +dropping and re-adding the generated stored column, then rebuilding its GIN +index. PostgreSQL performs a full `events` table rewrite while holding an +`ACCESS EXCLUSIVE` lock. Operators MUST treat this as planned write downtime, +size the migration window from production table/index size, and complete the +migration before deploying binaries that advertise `nip-pma-aggregate-v1`. +Rolling application deployment does not make this DDL online. ## Required deployment order -1. this inert codec/kind reservation while ingest still rejects `30179`; -2. author-only privacy gates, SQL visibility before `LIMIT`, and verification - that the positive FTS allowlist continues to exclude `30179`; -3. dark CAS schema/transaction; -4. feature-gated aggregate submission; -5. read/repair/export/import and destructive restore drill; -6. tombstone revocation across authentication/ingest/session caches; -7. owner rotation epoch/freeze/receipts/activation; -8. Desktop reader and verified dual-write migration. - -No phase may publish secrets before step 2 or retire local recovery evidence -before the complete migration exit gate passes. +1. **Inert reservation:** codec and kind reservation while generic ingest still + rejects `30179` (shipped in #4593). +2. **Relay-authority release:** author-only pre-pagination privacy and FTS gates, + transactional CAS/authority storage, capability-advertised aggregate + submission, tombstone revocation across every transport, and the Desktop + reader with verified migration. Desktop keeps every agent `LegacyOnly` when + the relay does not advertise the aggregate capability. Promotion requires a + crash-safe writer-consistent decrypt-and-binding verification; PostgreSQL + backup covers the authority tables and bound recovery bytes as one + consistency domain. +3. **Independent later capabilities:** export/import and restore drills, owner + rotation, and physical legacy cleanup. + +No phase may publish secrets before the privacy gates are active or retire local +recovery evidence before the complete migration exit gate passes. diff --git a/migrations/0029_private_managed_agent_foundation.sql b/migrations/0029_private_managed_agent_foundation.sql new file mode 100644 index 0000000000..1948029c44 --- /dev/null +++ b/migrations/0029_private_managed_agent_foundation.sql @@ -0,0 +1,93 @@ +-- Relay-canonical authority for NIP-PMA kind:30179. The head retains the +-- generation floor after deletion; immutable revisions provide the audit/CAS +-- chain. Public projections are bound by exact signed event IDs and hashes. +CREATE TABLE managed_agent_definition_heads ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + definition_d TEXT NOT NULL, + revision BIGINT NOT NULL CHECK (revision > 0), + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + content_sha256 BYTEA NOT NULL CHECK (length(content_sha256) = 32), + PRIMARY KEY (community_id, owner_pubkey, definition_d) +); + +CREATE TABLE managed_agent_heads ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + agent_pubkey BYTEA NOT NULL CHECK (length(agent_pubkey) = 32), + generation BIGINT NOT NULL CHECK (generation > 0 AND generation <= 9007199254740991), + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + state TEXT NOT NULL CHECK (state IN ('active', 'deleted')), + definition_d TEXT, + definition_revision BIGINT, + definition_event_id BYTEA, + definition_content_sha256 BYTEA, + instance_event_id BYTEA, + instance_content_sha256 BYTEA, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey, agent_pubkey), + UNIQUE (community_id, event_id), + CHECK ((state = 'active') = + (definition_d IS NOT NULL AND definition_revision IS NOT NULL AND + definition_event_id IS NOT NULL AND definition_content_sha256 IS NOT NULL AND + instance_event_id IS NOT NULL AND instance_content_sha256 IS NOT NULL)), + CHECK (definition_event_id IS NULL OR length(definition_event_id) = 32), + CHECK (definition_content_sha256 IS NULL OR length(definition_content_sha256) = 32), + CHECK (instance_event_id IS NULL OR length(instance_event_id) = 32), + CHECK (instance_content_sha256 IS NULL OR length(instance_content_sha256) = 32) +); +CREATE INDEX managed_agent_heads_definition_binding + ON managed_agent_heads (community_id, owner_pubkey, definition_d); + +CREATE TABLE managed_agent_revisions ( + community_id UUID NOT NULL, + owner_pubkey BYTEA NOT NULL, + agent_pubkey BYTEA NOT NULL, + generation BIGINT NOT NULL CHECK (generation > 0 AND generation <= 9007199254740991), + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + previous_event_id BYTEA CHECK (previous_event_id IS NULL OR length(previous_event_id) = 32), + state TEXT NOT NULL CHECK (state IN ('active', 'deleted')), + definition_d TEXT, + definition_revision BIGINT, + definition_event_id BYTEA CHECK (definition_event_id IS NULL OR length(definition_event_id) = 32), + definition_content_sha256 BYTEA CHECK (definition_content_sha256 IS NULL OR length(definition_content_sha256) = 32), + instance_event_id BYTEA CHECK (instance_event_id IS NULL OR length(instance_event_id) = 32), + instance_content_sha256 BYTEA CHECK (instance_content_sha256 IS NULL OR length(instance_content_sha256) = 32), + private_event JSONB NOT NULL, + definition_event JSONB, + instance_event JSONB, + committed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey, agent_pubkey, generation), + UNIQUE (community_id, event_id), + FOREIGN KEY (community_id, owner_pubkey, agent_pubkey) + REFERENCES managed_agent_heads (community_id, owner_pubkey, agent_pubkey) + DEFERRABLE INITIALLY DEFERRED +); + +-- NIP-PMA kind:30179 contains owner-encrypted runnable agent configuration and +-- is author-only. Preserve every installation's current FTS expression while +-- making existing and future private aggregate rows storage-level unsearchable. +DO $$ +DECLARE + existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid + AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass + AND a.attname = 'search_tsv'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 30179 THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/schema/schema.sql b/schema/schema.sql index 4dac29176d..f06f57fc68 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -218,7 +218,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations (final state: 0001 + 0005 + 0009). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, @@ -275,6 +275,57 @@ CREATE INDEX idx_events_not_before ON events (community_id, not_before) -- EXPLAIN before its work lands (Quinn option A; Max's index-spelling caveat). CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +-- ── Private managed-agent authority (NIP-PMA) ──────────────────────────────── +CREATE TABLE managed_agent_definition_heads ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + definition_d TEXT NOT NULL, + revision BIGINT NOT NULL CHECK (revision > 0), + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + content_sha256 BYTEA NOT NULL CHECK (length(content_sha256) = 32), + PRIMARY KEY (community_id, owner_pubkey, definition_d) +); + +CREATE TABLE managed_agent_heads ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + agent_pubkey BYTEA NOT NULL CHECK (length(agent_pubkey) = 32), + generation BIGINT NOT NULL CHECK (generation > 0 AND generation <= 9007199254740991), + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + state TEXT NOT NULL CHECK (state IN ('active', 'deleted')), + definition_d TEXT, definition_revision BIGINT, definition_event_id BYTEA, + definition_content_sha256 BYTEA, instance_event_id BYTEA, instance_content_sha256 BYTEA, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey, agent_pubkey), + UNIQUE (community_id, event_id), + CHECK ((state = 'active') = (definition_d IS NOT NULL AND definition_revision IS NOT NULL AND definition_event_id IS NOT NULL AND definition_content_sha256 IS NOT NULL AND instance_event_id IS NOT NULL AND instance_content_sha256 IS NOT NULL)), + CHECK (definition_event_id IS NULL OR length(definition_event_id) = 32), + CHECK (definition_content_sha256 IS NULL OR length(definition_content_sha256) = 32), + CHECK (instance_event_id IS NULL OR length(instance_event_id) = 32), + CHECK (instance_content_sha256 IS NULL OR length(instance_content_sha256) = 32) +); +CREATE INDEX managed_agent_heads_definition_binding ON managed_agent_heads (community_id, owner_pubkey, definition_d); +CREATE TABLE managed_agent_revisions ( + community_id UUID NOT NULL, owner_pubkey BYTEA NOT NULL, agent_pubkey BYTEA NOT NULL, + generation BIGINT NOT NULL CHECK (generation > 0 AND generation <= 9007199254740991), + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + previous_event_id BYTEA CHECK (previous_event_id IS NULL OR length(previous_event_id) = 32), + state TEXT NOT NULL CHECK (state IN ('active', 'deleted')), + definition_d TEXT, + definition_revision BIGINT, + definition_event_id BYTEA CHECK (definition_event_id IS NULL OR length(definition_event_id) = 32), + definition_content_sha256 BYTEA CHECK (definition_content_sha256 IS NULL OR length(definition_content_sha256) = 32), + instance_event_id BYTEA CHECK (instance_event_id IS NULL OR length(instance_event_id) = 32), + instance_content_sha256 BYTEA CHECK (instance_content_sha256 IS NULL OR length(instance_content_sha256) = 32), + private_event JSONB NOT NULL, + definition_event JSONB, + instance_event JSONB, + committed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey, agent_pubkey, generation), + UNIQUE (community_id, event_id), + FOREIGN KEY (community_id, owner_pubkey, agent_pubkey) REFERENCES managed_agent_heads (community_id, owner_pubkey, agent_pubkey) DEFERRABLE INITIALLY DEFERRED +); + -- ── Event mentions ──────────────────────────────────────────────────────────── -- Conformance: "Channel-less global events and DMs" (#p fan-out). The join to -- events MUST carry the community tuple (e.community_id = m.community_id AND