From ff05a1ce0c2445c6e373d54d0f5aedc395c014cc Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 09:40:52 -0600 Subject: [PATCH 01/40] Enforce author-only event visibility in SQL Push the authenticated reader into event query and count predicates before pagination, including mixed-kind and kindless filters. Add the brownfield FTS exclusion for private managed-agent aggregates without changing prior migration checksums. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-db/src/event.rs | 57 ++++++++++++++++++- crates/buzz-db/src/migration.rs | 12 +++- crates/buzz-relay/src/handlers/req.rs | 14 ++++- .../0028_private_managed_agent_foundation.sql | 27 +++++++++ 4 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 migrations/0028_private_managed_agent_foundation.sql diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index a670a13402..629cf902be 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")?; diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 65ca156721..e9800c1dbd 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(), 27); + assert_eq!(migrations.len(), 28); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -940,6 +940,16 @@ mod tests { desired_schema.contains("idx_channels_id_live"), "desired-state schema must carry the channel-id lookup index", ); + + // Private managed-agent ciphertext is author-only and must remain + // unsearchable on brownfield databases without changing 0001's sqlx + // checksum. The additive wrapper preserves the existing expression. + assert_eq!(migrations[27].version, 28); + let private_managed_agent = migrations[27].sql.as_str(); + assert!(private_managed_agent.contains("kind = 30179")); + assert!(private_managed_agent.contains("pg_get_expr")); + assert!(private_managed_agent.contains("search_tsv")); + assert!(!migrations[0].sql.as_str().contains("30179")); } #[test] diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index fd7deadf51..719b235eeb 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -289,6 +289,12 @@ 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. @@ -752,12 +758,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 diff --git a/migrations/0028_private_managed_agent_foundation.sql b/migrations/0028_private_managed_agent_foundation.sql new file mode 100644 index 0000000000..66bdc4c595 --- /dev/null +++ b/migrations/0028_private_managed_agent_foundation.sql @@ -0,0 +1,27 @@ +-- 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 $$; From 2efbdd52c460248f7b65c4c95107ed6435a986c7 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 09:44:42 -0600 Subject: [PATCH 02/40] Cover author-only SQL privacy regressions Add Postgres-backed coverage for pre-limit owner visibility, kindless ID privacy, and explicit, mixed, and kindless COUNT behavior. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-db/src/event.rs | 157 ++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 629cf902be..5998db6757 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -1931,6 +1931,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() { From 87c5e5d2b85b4c0d2cd97dba59b2522c312b42c8 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 09:50:50 -0600 Subject: [PATCH 03/40] Clarify private managed-agent rollout contract Document the encrypted relay boundary, Desktop verification gate, shared definition revision semantics, runtime capability gate, and bounded deployment phases for the relay-authority follow-up. Co-authored-by: Carl Signed-off-by: Wes --- docs/nips/NIP-PMA.md | 60 +++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/docs/nips/NIP-PMA.md b/docs/nips/NIP-PMA.md index 82592b1eca..1cc7f9770c 100644 --- a/docs/nips/NIP-PMA.md +++ b/docs/nips/NIP-PMA.md @@ -80,33 +80,47 @@ 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`. ## 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. From 4018a9ab57be2ec1ba1ca6c30878d533349e3414 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 09:56:27 -0600 Subject: [PATCH 04/40] Cover private aggregate filter classification Lock explicit, mixed-kind, and kindless private managed-agent filters to the author-only SQL pushdown path. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-relay/src/handlers/req.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 719b235eeb..26dbdbdda8 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1856,6 +1856,19 @@ 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)); + } + #[test] fn mixed_filter_omits_another_authors_push_lease() { let owner_keys = nostr::Keys::generate(); From 345159374369601668a14ef78ec35e773b3eb2b3 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 10:01:40 -0600 Subject: [PATCH 05/40] Add relay authority for private managed agents Introduce an authenticated aggregate endpoint and serializable authority transaction for encrypted private managed-agent heads and their exact public projections. Preserve immutable signed recovery bytes, enforce generation and definition revision CAS, reactivate submitted projections, retire prior heads, and return a writer-consistent signed aggregate snapshot. Keep kind 30179 author-only and out of full-text search, reject generic writes, and advertise the aggregate capability through NIP-11. Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz> Signed-off-by: Wes --- crates/buzz-db/src/error.rs | 4 + crates/buzz-db/src/event.rs | 2 +- crates/buzz-db/src/lib.rs | 21 + crates/buzz-db/src/managed_agent.rs | 668 ++++++++++++++++++ crates/buzz-db/src/migration.rs | 7 +- crates/buzz-relay/src/api/bridge.rs | 2 +- crates/buzz-relay/src/api/managed_agents.rs | 126 ++++ crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/handlers/ingest.rs | 14 + crates/buzz-relay/src/nip11.rs | 16 +- crates/buzz-relay/src/router.rs | 4 + crates/buzz-search/tests/fts_integration.rs | 6 + .../0028_private_managed_agent_foundation.sql | 66 ++ schema/schema.sql | 53 +- 14 files changed, 985 insertions(+), 5 deletions(-) create mode 100644 crates/buzz-db/src/managed_agent.rs create mode 100644 crates/buzz-relay/src/api/managed_agents.rs 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 5998db6757..1adebc1a65 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -1164,7 +1164,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, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9b26876747..0ad73d431c 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,25 @@ 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 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 + } + /// 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 diff --git a/crates/buzz-db/src/managed_agent.rs b/crates/buzz-db/src/managed_agent.rs new file mode 100644 index 0000000000..d606823b51 --- /dev/null +++ b/crates/buzz-db/src/managed_agent.rs @@ -0,0 +1,668 @@ +//! 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, + /// Revision already embedded in the encrypted active payload. Required for + /// active heads and rejected unless it matches the locked definition head. + 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)?; + for attempt in 0..=2 { + match commit_aggregate_once(pool, community, request).await { + Err(DbError::Sqlx(sqlx::Error::Database(error))) + if error.code().as_deref() == Some("40001") && attempt < 2 => {} + result => return result, + } + } + unreachable!("bounded serialization retry always returns") +} + +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?; + 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 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 an ordinary projection write targets PMA-authoritative state. +pub async fn projection_is_authoritative( + pool: &PgPool, + community: CommunityId, + event: &Event, + d_tag: &str, +) -> Result { + let kind = event.kind.as_u16() as u32; + let exists = match kind { + 30177 => 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(event.pubkey.to_bytes()).bind(hex::decode(d_tag).map_err(|_| invalid("invalid managed-agent d tag"))?).fetch_one(pool).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(event.pubkey.to_bytes()).bind(d_tag).fetch_one(pool).await?, + _ => false, + }; + Ok(exists) +} + +#[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); + } + + #[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 e9800c1dbd..4a9cef06be 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -943,12 +943,17 @@ mod tests { // Private managed-agent ciphertext is author-only and must remain // unsearchable on brownfield databases without changing 0001's sqlx - // checksum. The additive wrapper preserves the existing expression. + // checksum. Migration 0028 also installs the relay authority tables. assert_eq!(migrations[27].version, 28); let private_managed_agent = migrations[27].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")); } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453f..99b9e5244d 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, 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..a4bc5d1414 --- /dev/null +++ b/crates/buzz-relay/src/api/managed_agents.rs @@ -0,0 +1,126 @@ +//! 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}")), + })?; + let owner_hex = owner.to_hex(); + for stored in &commit.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..eb04d8920a 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; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index c711538284..2bce8d3fde 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2759,6 +2759,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. 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-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 675d15db8b..d91c6602e9 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/0028_private_managed_agent_authority.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/migrations/0028_private_managed_agent_foundation.sql b/migrations/0028_private_managed_agent_foundation.sql index 66bdc4c595..1948029c44 100644 --- a/migrations/0028_private_managed_agent_foundation.sql +++ b/migrations/0028_private_managed_agent_foundation.sql @@ -1,3 +1,69 @@ +-- 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. diff --git a/schema/schema.sql b/schema/schema.sql index 9f3449b066..b53aade689 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 From e9721697681bebb7b42f834a420e92f5fb7fd959 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 10:03:33 -0600 Subject: [PATCH 06/40] desktop: relay-canonical managed-agent authority foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the Desktop safety foundation for the relay-canonical managed-agent migration. State + classification only — no migration enablement, no destructive local mutation. Every existing record defaults to LegacyOnly, so an upgrade that predates any migration deserializes unchanged and keeps local JSON + keyring authoritative. - Add `managed_agents::authority` with a single internally-tagged `RelayAuthority` enum (`LegacyOnly` | `RelayAuthoritative { evidence }`), so impossible states are unrepresentable: authoritative structurally carries a verified head, legacy structurally cannot. No `{"state":{"state":...}}` nesting — persisted shape is `{"authority":"legacy_only"}` / `{"authority":"relay_authoritative","evidence":{...}}`, asserted exactly. - Add `#[serde(default)] relay_authority: RelayAuthority` to `ManagedAgentRecord`; a store predating the field deserializes as LegacyOnly. - Add an exhaustive field→codec-home classification table (`FIELD_CLASSIFICATIONS`) as a single source of truth, verified against the ACTUAL PersonaEventContent (30175) / ManagedAgentEventContent (30177) builders and the 30177 exclusion asserts — not intent: * start_on_app_launch, auto_restart_on_config_change, provider_binary_path, persona_team_dir carry through NO codec slot -> TransientLocal. * avatar_url IS carried by 30175 -> stays DefinitionProjected. - Tighten the exhaustiveness test to exact set equality: every record field has a class AND every class key still names a live field (no stale drift). - Populate `relay_authority` explicitly (`RelayAuthority::legacy()`) at every production/test constructor rather than relying on a struct-wide default. Foundation-only classification/authority API is consumed by the not-yet-landed reconcile/migration lanes; suppression is per-item with a named consumer, not module-wide. Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../src/commands/agent_config_tests.rs | 1 + desktop/src-tauri/src/commands/agents.rs | 1 + .../src-tauri/src/commands/agents_tests.rs | 1 + .../commands/personas/delete_cascade_tests.rs | 1 + .../personas/inbound/inbound_tests.rs | 1 + .../personas/snapshot/fidelity_tests.rs | 1 + .../src/commands/personas/snapshot/import.rs | 1 + .../src/commands/personas/snapshot/tests.rs | 1 + .../personas/update/name_propagation_tests.rs | 1 + .../src-tauri/src/commands/team_snapshot.rs | 1 + .../src/commands/team_snapshot/tests.rs | 1 + .../src/managed_agents/agent_events.rs | 1 + .../managed_agents/agent_snapshot_envelope.rs | 1 + .../managed_agents/agent_snapshot_tests.rs | 1 + .../src-tauri/src/managed_agents/authority.rs | 255 +++++++++++++++ .../src/managed_agents/authority/tests.rs | 304 ++++++++++++++++++ .../config_bridge/reader_tests.rs | 1 + .../src/managed_agents/discovery/tests.rs | 1 + .../managed_agents/effective_config/tests.rs | 1 + .../src/managed_agents/global_config/tests.rs | 1 + desktop/src-tauri/src/managed_agents/mod.rs | 8 + .../src/managed_agents/nest/tests.rs | 1 + .../managed_agents/persona_events/tests.rs | 1 + .../src-tauri/src/managed_agents/readiness.rs | 1 + .../src/managed_agents/runtime/tests.rs | 1 + .../managed_agents/spawn_snapshot/tests.rs | 1 + .../src/managed_agents/team_snapshot.rs | 1 + .../src/managed_agents/teams_tests.rs | 1 + desktop/src-tauri/src/managed_agents/types.rs | 11 + 29 files changed, 603 insertions(+) create mode 100644 desktop/src-tauri/src/managed_agents/authority.rs create mode 100644 desktop/src-tauri/src/managed_agents/authority/tests.rs 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/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3b114b0474..98882f85e6 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -913,6 +913,7 @@ pub async fn create_managed_agent( } else { relay_mesh.clone() }, + relay_authority: crate::managed_agents::RelayAuthority::legacy(), }; records.push(record); diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index df135298c4..4620c5250e 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![], 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/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432..1414ad0470 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(), } } 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/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..10fa68235e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/authority.rs @@ -0,0 +1,255 @@ +//! 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 }, +} + +/// 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 } + } + + /// 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 { .. }) + } + + /// The verified head evidence, when relay-authoritative. + /// + /// 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 } => Some(evidence), + Self::LegacyOnly => None, + } + } +} + +/// 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), + ("auth_tag", 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: carry NOT, diff NOT --- + ("acp_command", DerivedNotCarried), + ("agent_command", DerivedNotCarried), + ("mcp_command", DerivedNotCarried), + ("turn_timeout_seconds", DerivedNotCarried), + ("shared", DerivedNotCarried), // legacy, `skip_serializing` + // --- 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), + ] +}; + +#[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..6771ecbd79 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/authority/tests.rs @@ -0,0 +1,304 @@ +//! 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 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", "auth_tag", "env_vars", "backend"] { + assert_eq!( + classify_field(secret), + Some(FieldClass::PrivateCanonical), + "secret-bearing field `{secret}` must be PrivateCanonical, never projected publicly", + ); + } +} + +#[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", + ); +} 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..50b3928075 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -283,6 +283,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/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index a848b6f02f..fbaa282e34 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -2,6 +2,7 @@ mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; pub(crate) mod agent_snapshot_envelope; +mod authority; pub(crate) mod team_snapshot; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, @@ -48,6 +49,13 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { } pub use backend::*; +// Only `RelayAuthority` is consumed by production code (the record field + +// its `legacy()` constructor at every call site). The classification API +// (`classify_field`, `FieldClass`) and `RelayAuthorityEvidence` are exercised +// solely by `authority`'s own tests via `super::*`, so they need no re-export +// yet; the not-yet-built migration path will add one when it actually consumes +// them. No blanket `#[allow(dead_code)]` — unused API stays unexported. +pub(crate) use authority::RelayAuthority; 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/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..24ab051e4e 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1530,6 +1530,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/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index bea4b1c3e3..66662d31b1 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -181,6 +181,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 d76605ecff..e0c08222a3 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 c5bb6173d1..d4cc06d50a 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`]. From a9f93ff420897ddd4187b3be4ce3eba4a9c1a867 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 10:16:46 -0600 Subject: [PATCH 07/40] Cover private managed-agent tombstone authority Extend the migrated-Postgres aggregate regression through deletion. Prove a private tombstone advances the CAS generation, returns no public bindings, retires the instance projection, retains the ordinary-ingest authority fence, and rejects a same-generation stale active resurrection. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-db/src/managed_agent.rs | 78 +++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/crates/buzz-db/src/managed_agent.rs b/crates/buzz-db/src/managed_agent.rs index d606823b51..08824c2fc0 100644 --- a/crates/buzz-db/src/managed_agent.rs +++ b/crates/buzz-db/src/managed_agent.rs @@ -651,6 +651,84 @@ mod tests { 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); + + // 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!( + 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] From 2f987007a9d3828483002ea1e95a21d1fc1f15f9 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 10:19:04 -0600 Subject: [PATCH 08/40] desktop: migration-readiness contract for relay-canonical authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the migration-readiness contract on top of the authority foundation (FIX #2, FIX #5). Types + pure gate only — no migration enablement, no destructive local mutation. The migration driver (sibling lane) is the consumer. - Reclassify `auth_tag` PrivateCanonical -> DerivedNotCarried: it is re-minted at spawn from owner keys + agent pubkey (compute_auth_tag), never carried in a projection and never diffed at verification. Carrying a stale tag would pin an agent to legacy forever. - Add a `VersionedBackend` envelope with `BACKEND_PAYLOAD_VERSION = 1` and a stable serialized shape; deny-unknown-fields so a newer envelope is rejected rather than silently truncated. Round-trip + reject-unknown fixtures. - Add the `MigrationBlock` failure taxonomy and the pure `assess_migration_readiness` gate (FIX #5): capability FIRST, then payload size, then per-value size. Capability is the exact NIP-11 aggregate token `nip-pma-aggregate-v1` (NIP_PMA_AGGREGATE_TOKEN) matched over the advertised token set — not a boolean "kind accepted". Size boundaries are inclusive-safe; exactly-at-limit passes, strictly-over blocks. On any block the caller MUST stay LegacyOnly and NOT retry until an input changes (no timer/retry loop). - `MIGRATION_MAX_VALUE_BYTES = 32_768` tracks the relay per-`Value` codec limit (`private_managed_agent::MAX_VALUE_BYTES`); it is NOT a keyring floor or a combined recovery blob. `MigrationBlock::reason()` renders a diagnostic string for logging/surfacing — the variant is the signal; durable persistence is owned by the migration driver record, not this gate. These are foundation contract types; the next slice wires them into a real payload builder / verification consumer so they prevent drift rather than sit beside `PrivateConfig.backend: Value` decoratively. Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../src-tauri/src/managed_agents/authority.rs | 176 +++++++++++++++++- .../src/managed_agents/authority/tests.rs | 160 +++++++++++++++- 2 files changed, 333 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/authority.rs b/desktop/src-tauri/src/managed_agents/authority.rs index 10fa68235e..67cd28e31a 100644 --- a/desktop/src-tauri/src/managed_agents/authority.rs +++ b/desktop/src-tauri/src/managed_agents/authority.rs @@ -103,6 +103,49 @@ impl RelayAuthority { } } +/// 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. /// @@ -178,7 +221,6 @@ pub(crate) const FIELD_CLASSIFICATIONS: &[(&str, FieldClass)] = { // --- Identity + private canonical config (encrypted kind:30179) --- ("pubkey", PrivateCanonical), // the d-tag coordinate itself ("private_key_nsec", PrivateCanonical), - ("auth_tag", PrivateCanonical), ("relay_url", PrivateCanonical), ("agent_command_override", PrivateCanonical), ("agent_args", PrivateCanonical), @@ -218,12 +260,20 @@ pub(crate) const FIELD_CLASSIFICATIONS: &[(&str, FieldClass)] = { ("parallelism", InstanceProjected), ("respond_to", InstanceProjected), ("respond_to_allowlist", InstanceProjected), - // --- Deprecated / re-derived at spawn: carry NOT, diff NOT --- + // --- 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), @@ -251,5 +301,127 @@ pub(crate) const FIELD_CLASSIFICATIONS: &[(&str, FieldClass)] = { ] }; +/// 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 index 6771ecbd79..2c3de736e5 100644 --- a/desktop/src-tauri/src/managed_agents/authority/tests.rs +++ b/desktop/src-tauri/src/managed_agents/authority/tests.rs @@ -145,11 +145,156 @@ fn field_classification_is_exhaustive() { ); } +#[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", "auth_tag", "env_vars", "backend"] { + for secret in ["private_key_nsec", "env_vars", "backend"] { assert_eq!( classify_field(secret), Some(FieldClass::PrivateCanonical), @@ -158,6 +303,19 @@ fn no_secret_field_is_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 From 6644d48796e12e7b27862c8e16abe53c75925ba8 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 10:24:30 -0600 Subject: [PATCH 09/40] desktop: fence legacy reconcile after relay promotion Skip kind:30177 boot reconciliation for records whose verified kind:30179 head is canonical. This prevents stale local compatibility JSON from republishing a legacy projection after promotion or deletion. Add a regression proving an authoritative record creates no retained or pending kind:30177 row. Co-authored-by: Carl Signed-off-by: Wes --- .../src-tauri/src/managed_agents/reconcile.rs | 10 ++++++ .../src/managed_agents/reconcile/tests.rs | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 90f05c5750..3817a4a41b 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 records are reconciled through the + // PMA read-back path instead. + if record.relay_authority.is_relay_authoritative() { + 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(); From 20eea67663b65aa3de2ce1f2a610447e9461e596 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:18:00 -0600 Subject: [PATCH 10/40] desktop: retain private agent aggregate attempts Persist each exact signed kind:30179 aggregate request by generation so retries reuse immutable bytes and newer tombstones do not erase older in-flight attempts. Enforce contiguous generations and exact per-attempt compare-and-clear/error updates. Add focused coverage for immutable generations, stale acknowledgements, and diagnostic scoping. Co-authored-by: Carl Signed-off-by: Wes --- .../src-tauri/src/managed_agents/retention.rs | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 7e97fa1f56..6868f50a2f 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -106,6 +106,201 @@ pub fn arrival_retention_scope( )) } +/// 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, +} + +/// 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()); + } + + 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 + FROM managed_agent_aggregates + WHERE owner_pubkey = ?1 AND agent_pubkey = ?2 + ORDER BY generation DESC + LIMIT 1", + params![owner_pubkey, agent_pubkey], + |row| { + Ok(RetainedManagedAgentAggregate { + owner_pubkey: row.get(0)?, + agent_pubkey: row.get(1)?, + generation: row.get::<_, i64>(2)? as u64, + 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)?, + }) + }, + ) + .optional() + .map_err(|e| format!("failed to get retained managed-agent aggregate: {e}")) +} + +/// 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) +} + /// A retained persona event row. #[derive(Debug, Clone)] pub struct RetainedEvent { @@ -140,6 +335,17 @@ 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}"))?; @@ -553,6 +759,136 @@ mod tests { } } + 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, + } + } + + #[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 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(); From f0501d6c939a58f800c1dc65e86764354db2c17b Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:19:17 -0600 Subject: [PATCH 11/40] test(search): load renamed private agent migration Point the FTS integration fixture at the folded migration filename so the private kind:30179 exclusion coverage compiles and can run. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-search/tests/fts_integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index d91c6602e9..3096891446 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -29,7 +29,7 @@ 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/0028_private_managed_agent_authority.sql"); + include_str!("../../../migrations/0028_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()); From 394b4c1600b61a5929e48678d458732fa957d480 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:18:02 -0600 Subject: [PATCH 12/40] desktop: pure relay-canonical managed-agent migration builder + verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `managed_agents::migration`, the first real consumer of the slice-1/2 authority contract. It converts one hydrated `ManagedAgentRecord` + owner/agent keys + exact signed kind:30175/30177 projections + CAS coordinate into the shared `buzz_core::private_managed_agent::Payload` and a signed, inert kind:30179 aggregate candidate, and verifies the relay read-back byte-exact before yielding promotion evidence. Pure by construction: no HTTP, no persistence, no authority mutation. The transport/driver lane (sibling) owns submission, retry, durable state, and the actual RelayAuthoritative write. This module is the gate those lanes pass through, so every "is the relay head a faithful owner-signed copy?" check lives here behind an adversarial fixture matrix. Builder (`build_migration_candidate`): - `PrivateConfig.backend` carries the versioned `VersionedBackend` envelope, not a bare `BackendKind`, so a shape change is an explicit version bump. - `auth_tag` is RE-MINTED unconditionally via `compute_auth_tag(owner, agent, "")`; the stored record string is never copied or trusted (a re-mint cannot byte-match it, and the codec requires an unconditional attestation). - Readiness gating derives the ACTUAL largest inserted codec `Value` (the two projection recovery envelopes dominate) plus the encrypted payload size and feeds them to `assess_migration_readiness` with the relay's NIP-11 tokens — never a caller-supplied number. - Fails fast on inconsistent keys/projections; `build_event` re-runs full codec validation so any semantic slip surfaces locally, not at the relay. Verifier (`verify_promotion`): treats the aggregate response as a LOCAL typed fixture matching the documented route JSON, not a buzz-db type. Checks, in order: `validate_and_decrypt` of the returned head; owner/agent/generation/ predecessor/state equality; binding event-id + content-hash + recovery equality against the returned signed projections (and that the served standalone projection matches its binding, so a swapped public event is caught); definition revision equality; and source-vs-roundtrip equality for CARRIED classes only (the payload type structurally carries only PrivateConfig + identity, i.e. the carried classes — derived/transient/bookkeeping fields never enter it). Returns `PromotionEvidence` only after every check passes. Adversarial fixtures (14, all green): happy exact round-trip; re-mint-not-copy; versioned backend envelope; agent-key/record-pubkey mismatch; projection not owner-signed; wrong-kind projection; capability-absent block; oversize codec value block; tampered head ciphertext; head signed by wrong owner; swapped definition projection; swapped instance projection; wrong definition revision; stale-generation head. Uses shared codec kind/size constants directly. Foundation API is `#[allow(dead_code)]` with a named sibling consumer (the transport lane); prod `cargo check --lib` is warning-free. `managed_agents::` suite: 1014 passed, 0 failed. Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../src/managed_agents/migration/mod.rs | 547 ++++++++++++++++++ .../src/managed_agents/migration/tests.rs | 424 ++++++++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 1 + 3 files changed, 972 insertions(+) create mode 100644 desktop/src-tauri/src/managed_agents/migration/mod.rs create mode 100644 desktop/src-tauri/src/managed_agents/migration/tests.rs 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..e6830ae337 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -0,0 +1,547 @@ +//! 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_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)] +#[allow(dead_code)] +pub struct AggregateResponse { + /// 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: Event, + /// The signed kind:30177 instance projection the relay serves. + pub instance_event: Event, + /// The definition revision the relay reports for the served projection. + pub definition_revision: u64, +} + +/// 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, +} + +/// 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)] +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: None, + }; + + 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; + + // 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, response)?; + verify_binding_instance(source_active, roundtrip_active, response)?; + + // 4. Definition revision the relay reports must match what we pinned. + verify_eq( + "definition_revision", + &response.definition_revision, + &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, + }) +} + +fn verify_binding_definition( + source: &ActivePayload, + roundtrip: &ActivePayload, + response: &AggregateResponse, +) -> 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 response.definition_event.id.to_hex() != source.definition.event_id + || pma::content_sha256(response.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, + response: &AggregateResponse, +) -> 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 response.instance_event.id.to_hex() != source.instance_projection.event_id + || pma::content_sha256(response.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" + ))), + } +} + +#[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..4411c083b7 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/migration/tests.rs @@ -0,0 +1,424 @@ +//! 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: None, + 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 { + private_event: candidate.signed_event.clone(), + definition_event: candidate.definition_event.clone(), + instance_event: candidate.instance_event.clone(), + definition_revision: 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); +} + +#[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 = 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 = 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 = fx.cas.definition_revision + 1; + let err = verify_promotion(&candidate, &response, &fx.owner_keys).unwrap_err(); + assert!(matches!(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(); + 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 fbaa282e34..e641368394 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod agent_events; pub(crate) mod agent_snapshot; pub(crate) mod agent_snapshot_envelope; mod authority; +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, From b4abf61255466f1d01f2708b9f9e485429f8d551 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:23:49 -0600 Subject: [PATCH 13/40] desktop: bind verifier to aggregate response contract Deserialize the exact aggregate route shape, require its acceptance, head ID, generation, state, optional active projections, and definition revision to match the signed payload, and compare the complete served projection events to their recovery copies. Add route-shape and response-metadata regression coverage. Co-authored-by: Carl Signed-off-by: Wes --- .../src/managed_agents/migration/mod.rs | 79 ++++++++++++++++--- .../src/managed_agents/migration/tests.rs | 70 ++++++++++++++-- 2 files changed, 128 insertions(+), 21 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/migration/mod.rs b/desktop/src-tauri/src/managed_agents/migration/mod.rs index e6830ae337..3c0c0a859a 100644 --- a/desktop/src-tauri/src/managed_agents/migration/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -33,6 +33,7 @@ use buzz_core_pkg::private_managed_agent::{ PrivateIdentity, ProjectionRecoveryV1, State, }; use nostr::{Event, Keys, PublicKey, ToBech32}; +use serde::Deserialize; use serde_json::Value; use super::authority::VersionedBackend; @@ -109,17 +110,28 @@ pub struct MigrationCandidate { /// 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)] +#[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: Event, + pub definition_event: Option, /// The signed kind:30177 instance projection the relay serves. - pub instance_event: Event, + pub instance_event: Option, /// The definition revision the relay reports for the served projection. - pub definition_revision: u64, + pub definition_revision: Option, } /// Proof that a submitted migration was stored and served back byte-exactly. @@ -306,6 +318,45 @@ pub fn verify_promotion( .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( @@ -348,14 +399,14 @@ pub fn verify_promotion( // 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, response)?; - verify_binding_instance(source_active, roundtrip_active, response)?; + 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, - &source_active.definition.revision, + &Some(source_active.definition.revision), )?; // 5. Carried-class equality: the private config we sealed must round-trip @@ -386,7 +437,7 @@ pub fn verify_promotion( fn verify_binding_definition( source: &ActivePayload, roundtrip: &ActivePayload, - response: &AggregateResponse, + definition_event: &Event, ) -> Result<(), MigrationError> { verify_eq( "definition.event_id", @@ -406,8 +457,9 @@ fn verify_binding_definition( // 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 response.definition_event.id.to_hex() != source.definition.event_id - || pma::content_sha256(response.definition_event.content.as_bytes()) + 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( @@ -420,7 +472,7 @@ fn verify_binding_definition( fn verify_binding_instance( source: &ActivePayload, roundtrip: &ActivePayload, - response: &AggregateResponse, + instance_event: &Event, ) -> Result<(), MigrationError> { verify_eq( "instance.event_id", @@ -437,8 +489,9 @@ fn verify_binding_instance( "instance recovery differs".into(), )); } - if response.instance_event.id.to_hex() != source.instance_projection.event_id - || pma::content_sha256(response.instance_event.content.as_bytes()) + 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( diff --git a/desktop/src-tauri/src/managed_agents/migration/tests.rs b/desktop/src-tauri/src/managed_agents/migration/tests.rs index 4411c083b7..fcacc1a2e5 100644 --- a/desktop/src-tauri/src/managed_agents/migration/tests.rs +++ b/desktop/src-tauri/src/managed_agents/migration/tests.rs @@ -161,10 +161,15 @@ impl Fixture { /// 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: candidate.definition_event.clone(), - instance_event: candidate.instance_event.clone(), - definition_revision: self.cas.definition_revision, + definition_event: Some(candidate.definition_event.clone()), + instance_event: Some(candidate.instance_event.clone()), + definition_revision: Some(self.cas.definition_revision), } } } @@ -184,6 +189,53 @@ fn happy_round_trip_yields_promotion_evidence() { assert_eq!(evidence.definition_revision, 7); } +#[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(); @@ -360,12 +412,12 @@ fn verify_rejects_swapped_definition_projection() { 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 = signed_projection( + 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(_))); } @@ -375,12 +427,12 @@ 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 = signed_projection( + 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(_))); } @@ -390,7 +442,7 @@ 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 = fx.cas.definition_revision + 1; + 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(_))); } @@ -419,6 +471,8 @@ fn verify_rejects_stale_generation_head() { .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(_))); } From 467e67bee5a1199cd1bcb601bb213294eefa43dd Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:36:20 -0600 Subject: [PATCH 14/40] Fence relay-managed agent authority races Serialize aggregate instance projections with ordinary NIP-33 writes, recheck PMA authority inside the replacement transaction, and prevent both NIP-09 deletion paths from mutating bound projections. Add real-Postgres coverage for the first-aggregate ingest race and kind:5 a/e-tag fencing. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-db/src/event.rs | 61 +++++- crates/buzz-db/src/lib.rs | 38 ++++ crates/buzz-db/src/managed_agent.rs | 199 +++++++++++++++++- .../buzz-relay/src/handlers/side_effects.rs | 58 ++++- 4 files changed, 348 insertions(+), 8 deletions(-) diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 1adebc1a65..441d4b6f94 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -880,6 +880,28 @@ 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 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 \ @@ -890,8 +912,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) } @@ -910,6 +933,42 @@ 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 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", ) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 0ad73d431c..c79a38f69c 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -4798,6 +4798,24 @@ impl Db { 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 @@ -4844,6 +4862,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 index 08824c2fc0..d56a28aeb2 100644 --- a/crates/buzz-db/src/managed_agent.rs +++ b/crates/buzz-db/src/managed_agent.rs @@ -304,6 +304,14 @@ async fn commit_aggregate_once( } } + 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) { @@ -416,22 +424,64 @@ pub async fn read_head( .transpose() } -/// Whether an ordinary projection write targets PMA-authoritative state. -pub async fn projection_is_authoritative( +/// Whether a projection coordinate is controlled by a PMA head. +pub async fn projection_coordinate_is_authoritative( pool: &PgPool, community: CommunityId, - event: &Event, + 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 kind = event.kind.as_u16() as u32; let exists = match kind { - 30177 => 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(event.pubkey.to_bytes()).bind(hex::decode(d_tag).map_err(|_| invalid("invalid managed-agent d tag"))?).fetch_one(pool).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(event.pubkey.to_bytes()).bind(d_tag).fetch_one(pool).await?, + 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::*; @@ -652,6 +702,143 @@ mod tests { .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. + 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(), + ), + ] { + 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 diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 98f8a9aa84..22a6c46c2a 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2165,6 +2165,30 @@ 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 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 +2257,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 From 4da22a76354f63c7841cf02fc26446039647aa05 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:37:38 -0600 Subject: [PATCH 15/40] Document PMA fence and migration lock cost Define the last-binding behavior for shared definition coordinates and call out migration 0028's ACCESS EXCLUSIVE full-table rewrite before capability rollout. Co-authored-by: Carl Signed-off-by: Wes --- docs/nips/NIP-PMA.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/nips/NIP-PMA.md b/docs/nips/NIP-PMA.md index 1cc7f9770c..95c7c1b881 100644 --- a/docs/nips/NIP-PMA.md +++ b/docs/nips/NIP-PMA.md @@ -107,6 +107,26 @@ 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 0028 + +`0028_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. **Inert reservation:** codec and kind reservation while generic ingest still From aa7efe0e32b84af64f0aaec4eece4d092887a880 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:40:11 -0600 Subject: [PATCH 16/40] desktop: build private agent tombstones Add a pure owner-signed deleted aggregate builder that advances verified relay authority evidence without carrying active projections, with codec round-trip and invalid-input coverage. Co-authored-by: Carl Signed-off-by: Wes --- .../src/managed_agents/migration/mod.rs | 36 +++++++++++ .../src/managed_agents/migration/tests.rs | 61 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/migration/mod.rs b/desktop/src-tauri/src/managed_agents/migration/mod.rs index 3c0c0a859a..9e4bd68f4a 100644 --- a/desktop/src-tauri/src/managed_agents/migration/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -157,6 +157,42 @@ pub struct PromotionEvidence { pub definition_revision: u64, } +/// 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 diff --git a/desktop/src-tauri/src/managed_agents/migration/tests.rs b/desktop/src-tauri/src/managed_agents/migration/tests.rs index fcacc1a2e5..feb79cd6ec 100644 --- a/desktop/src-tauri/src/managed_agents/migration/tests.rs +++ b/desktop/src-tauri/src/managed_agents/migration/tests.rs @@ -447,6 +447,67 @@ fn verify_rejects_wrong_definition_revision() { 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_rejects_stale_generation_head() { let fx = Fixture::new(); From 7908cd066e6794c142176443bd78d116c59ba86c Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:42:33 -0600 Subject: [PATCH 17/40] desktop: route authoritative deletion through PMA Preserve authority evidence through single-agent and persona-cascade deletion, enqueue a minimal next-generation aggregate tombstone for relay-authoritative agents, and retain legacy kind:5 deletion only for LegacyOnly records. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src-tauri/src/commands/agents.rs | 57 +++++++++++++++++-- .../src-tauri/src/commands/personas/mod.rs | 14 ++++- desktop/src-tauri/src/managed_agents/mod.rs | 2 +- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 98882f85e6..a05b64ec56 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -74,12 +74,14 @@ pub(super) fn tombstone_managed_agent_pending( app: &AppHandle, state: &AppState, agent_pubkey: &str, + relay_authority: &crate::managed_agents::RelayAuthority, ) { use crate::managed_agents::{ agent_events::build_agent_delete, + migration::build_tombstone_event, retention::{ - delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, - RetainedEvent, + 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; @@ -90,11 +92,51 @@ pub(super) fn tombstone_managed_agent_pending( 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 mut conn = open_retention_db(&scope.db_path)?; + delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; + + if let Some(evidence) = relay_authority.evidence() { + 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}"))?; + return 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, + }, + ); + } + 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 { @@ -1322,6 +1364,11 @@ 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)?; } @@ -1338,7 +1385,7 @@ pub async fn delete_managed_agent( // 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); + tombstone_managed_agent_pending(&app, &state, &pubkey, &relay_authority); // 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. diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 0cd7ad0324..42607aa7ec 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 ─────────────────────────────────────────────── // @@ -236,7 +241,14 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { 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::tombstone_managed_agent_pending( + &app, + &state, + pk, + cascade_authorities + .get(pk) + .ok_or_else(|| format!("agent {pk} authority missing"))?, + ); super::agents::archive_managed_agent_pending(&app, &state, pk); } tombstone_persona_pending(&app, &state, &d_tag); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index e641368394..e89c48704a 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -3,7 +3,7 @@ pub(crate) mod agent_events; pub(crate) mod agent_snapshot; pub(crate) mod agent_snapshot_envelope; mod authority; -mod migration; +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, From 82dc81625ed484c4ae118b3c8ed715fed3fb560d Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:47:07 -0600 Subject: [PATCH 18/40] desktop: verify PMA deletion read-back Require deleted aggregate responses to return the exact retained tombstone, echo its generation and event ID, and omit all active projections before an attempt may be acknowledged. Co-authored-by: Carl Signed-off-by: Wes --- .../src/managed_agents/migration/mod.rs | 75 +++++++++++++++ .../src/managed_agents/migration/tests.rs | 92 +++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/migration/mod.rs b/desktop/src-tauri/src/managed_agents/migration/mod.rs index 9e4bd68f4a..da01a6b7ba 100644 --- a/desktop/src-tauri/src/managed_agents/migration/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -157,6 +157,18 @@ pub struct PromotionEvidence { 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 @@ -470,6 +482,69 @@ pub fn verify_promotion( }) } +/// 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, diff --git a/desktop/src-tauri/src/managed_agents/migration/tests.rs b/desktop/src-tauri/src/managed_agents/migration/tests.rs index feb79cd6ec..592856f368 100644 --- a/desktop/src-tauri/src/managed_agents/migration/tests.rs +++ b/desktop/src-tauri/src/managed_agents/migration/tests.rs @@ -508,6 +508,98 @@ fn tombstone_rejects_invalid_coordinate_or_generation_overflow() { )); } +#[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(); From 853dee620cde835e2d0c960f37bd15794763c297 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:49:39 -0600 Subject: [PATCH 19/40] desktop: enumerate pending PMA attempts Snapshot the latest pending retained aggregate per agent within the captured owner scope so the flush driver can drain immutable retry inputs without crossing identities or replaying superseded generations. Co-authored-by: Carl Signed-off-by: Wes --- .../src-tauri/src/managed_agents/retention.rs | 90 ++++++++++++++++--- 1 file changed, 78 insertions(+), 12 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 6868f50a2f..c518f68623 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -230,23 +230,65 @@ pub fn get_retained_managed_agent_aggregate( ORDER BY generation DESC LIMIT 1", params![owner_pubkey, agent_pubkey], - |row| { - Ok(RetainedManagedAgentAggregate { - owner_pubkey: row.get(0)?, - agent_pubkey: row.get(1)?, - generation: row.get::<_, i64>(2)? as u64, - 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)?, - }) - }, + 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 + 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)?, + }) +} + /// 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( @@ -811,6 +853,30 @@ mod tests { ); } + #[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(); From fcdaf22232de3d348fe50e39f02759d298764eb6 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:54:08 -0600 Subject: [PATCH 20/40] desktop: PMA aggregate submit/retry driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `managed_agents::migration::driver`, the transport half of the relay-canonical migration seam. It is the only code that moves a retained aggregate across the wire: it consumes one immutable row from `managed_agent_aggregates`, POSTs the row's `request_json` verbatim to `/api/managed-agents/aggregate` with fresh owner-signed NIP-98 auth, strict-deserializes the corrected `migration::AggregateResponse`, verifies it through `verify_promotion`, and flips the durable row to synced only after a fully verified read-back. Design invariants: - Exact bytes on the wire. The body is `request_json` verbatim; the driver never re-serializes it, so the stored request and the request the relay authenticates are the same bytes. The verification source (`MigrationCandidate`) is reconstructed from those same bytes — parse the three signed events, decrypt the head under the owner key — so the candidate and the wire request cannot diverge and no build-side crypto is duplicated. - Fresh owner NIP-98 per attempt via `build_nip98_auth_header_for_keys` (unique nonce), so a retry never replays a stale token. Mirrors the `/events` submit path: egress guard, rate-limit wait, `classify_request_error` / `relay_error_message` / `parse_json_response`. - Only the exact retained (generation, private_event_id) is marked synced. Beyond `verify_promotion`'s response-vs-candidate checks, the driver binds the *durable row's* coordinate to the verified evidence, so a row whose columns disagree with its own request_json (and thus the verified head) is never confirmed. - Errors preserve retry. Every failure path — transport, non-2xx, malformed body, reconstruction, verification, coordinate mismatch — persists an exact-attempt diagnostic via `record_managed_agent_aggregate_error` and leaves `pending_sync = 1`. Nothing here clears retry except a verified read-back. Transport tests use a loopback axum stub in the shape of the aggregate route (6, all green): faithful read-back marks the exact generation synced and posts verbatim; conflict status preserves retry; malformed success body preserves retry; tampered read-back fails verification and preserves retry; row coordinate disagreeing with the verified head is not synced; no pending row is a no-op with no network submission. Pure transport confined to the new module + one `mod` line; no delete/tombstone, docs, or relay-crate changes. `managed_agents::` suite: 1026 passed, 0 failed; `cargo check --lib` clean. Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../managed_agents/migration/driver/mod.rs | 226 +++++++++ .../managed_agents/migration/driver/tests.rs | 463 ++++++++++++++++++ .../src/managed_agents/migration/mod.rs | 5 + 3 files changed, 694 insertions(+) create mode 100644 desktop/src-tauri/src/managed_agents/migration/driver/mod.rs create mode 100644 desktop/src-tauri/src/managed_agents/migration/driver/tests.rs 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..5c767d31a7 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs @@ -0,0 +1,226 @@ +//! 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 POSTs the exact +//! retained JSON, verifies the response through [`super::verify_promotion`], and +//! flips the durable row to synced only when the relay served back a faithful, +//! owner-signed copy. +//! +//! 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. +//! * **Only the exact generation/event is marked synced.** Confirmation binds +//! to `(generation, private_event_id)`; a relay that echoes a different +//! generation or head id is a verification failure, not a sync. +//! * **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`; nothing here clears retry except a fully +//! verified read-back. + +use nostr::{Event, Keys}; +use reqwest::Method; +use rusqlite::Connection; +use serde::Deserialize; + +use super::{verify_promotion, AggregateResponse, MigrationCandidate}; +use crate::app_state::AppState; +use crate::managed_agents::retention::{ + get_retained_managed_agent_aggregate, mark_managed_agent_aggregate_synced, + 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, +} + +/// 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 copy and the row was marked synced. + Synced { + generation: u64, + private_event_id: String, + }, + /// 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, POSTs its exact `request_json` with fresh owner-signed +/// NIP-98 auth, strict-deserializes the response, verifies it against the +/// candidate reconstructed from the same bytes, and — only on a fully verified +/// read-back — marks that exact generation/event synced. Every failure persists +/// an exact-attempt diagnostic without clearing retry. +#[allow(dead_code)] // Consumed by the boot/reconcile lane (sibling); exercised by this module's tests. +pub(crate) async fn submit_retained_aggregate( + state: &AppState, + conn: &Connection, + owner_keys: &Keys, + owner_pubkey: &str, + agent_pubkey: &str, +) -> Result { + let row = 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. + let _ = record_managed_agent_aggregate_error( + conn, + owner_pubkey, + agent_pubkey, + generation, + &private_event_id, + &error, + ); + Ok(SubmitOutcome::Retained { error }) + }; + + let response = match submit_to_relay(state, owner_keys, row.request_json.as_bytes()).await { + Ok(response) => response, + Err(error) => return record_failure(error), + }; + + // Reconstruct the verification source from the *same bytes* we sent, so the + // candidate and the wire request can never diverge. + let candidate = match reconstruct_candidate(row.request_json.as_bytes(), owner_keys) { + Ok(candidate) => candidate, + 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, not a sync. + 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 + )); + } + + match mark_managed_agent_aggregate_synced( + conn, + owner_pubkey, + agent_pubkey, + generation, + &private_event_id, + ) { + Ok(true) => Ok(SubmitOutcome::Synced { + generation, + private_event_id, + }), + // The row moved under us (superseded by a newer generation) between read + // and confirm. Nothing verified was lost; the newer row drives next. + Ok(false) => Ok(SubmitOutcome::Nothing), + Err(error) => Err(error), + } +} + +/// POST the exact request bytes with fresh owner-signed NIP-98 auth and +/// strict-deserialize the aggregate response. +async fn submit_to_relay( + state: &AppState, + 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 base = crate::relay::relay_api_base_url_with_override(state); + let url = format!("{}{AGGREGATE_PATH}", base.trim_end_matches('/')); + let auth_header = + crate::relay::build_nip98_auth_header_for_keys(owner_keys, &Method::POST, &url, body)?; + + let response = state + .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() { + return Err(crate::relay::relay_error_message(response).await); + } + + 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 (_, 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}"))?; + + Ok(MigrationCandidate { + signed_event: parsed.private_event, + payload, + definition_event, + instance_event, + }) +} + +#[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..65a752e597 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/migration/driver/tests.rs @@ -0,0 +1,463 @@ +//! 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 marks the row synced; 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, + } + } +} + +// ── 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") +} + +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_marks_exact_generation_synced_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, + &conn, + &fx.owner_keys, + &fx.owner_hex(), + &fx.agent_hex(), + ) + .await + .expect("drive succeeds"); + + assert_eq!( + outcome, + SubmitOutcome::Synced { + generation: fx.cas.generation, + private_event_id: fx.candidate.signed_event.id.to_hex(), + } + ); + + // Posted bytes are the retained request verbatim. + let posted = captured.lock().unwrap().clone().expect("body captured"); + assert_eq!(posted, fx.request_json().into_bytes()); + + // The row is no longer pending and carries no error. + let confirmed = get_retained_managed_agent_aggregate(&conn, &fx.owner_hex(), &fx.agent_hex()) + .unwrap() + .unwrap(); + assert!(!confirmed.pending_sync, "verified row is marked synced"); + assert_eq!(confirmed.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, + &conn, + &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, + &conn, + &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, + &conn, + &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_verified_head_is_not_synced() { + // 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, + &conn, + &fx.owner_keys, + &fx.owner_hex(), + &fx.agent_hex(), + ) + .await + .expect("drive resolves"); + + assert!( + matches!(outcome, SubmitOutcome::Retained { .. }), + "row whose coordinate disagrees with the verified head is not synced" + ); + 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"); + let conn = 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, + &conn, + &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" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/migration/mod.rs b/desktop/src-tauri/src/managed_agents/migration/mod.rs index da01a6b7ba..390a0d52b3 100644 --- a/desktop/src-tauri/src/managed_agents/migration/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -707,5 +707,10 @@ fn require_owner_projection( } } +/// 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 driver; + #[cfg(test)] mod tests; From 27f3143e61d25bbdfe6b545403d8df8486dd404a Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 11:58:01 -0600 Subject: [PATCH 21/40] desktop: preserve PMA retry through promotion Co-authored-by: Wes Signed-off-by: Wes --- .../managed_agents/migration/driver/mod.rs | 110 +++++++++++------- .../managed_agents/migration/driver/tests.rs | 51 +++++--- 2 files changed, 103 insertions(+), 58 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs b/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs index 5c767d31a7..b86b64d608 100644 --- a/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs @@ -4,10 +4,10 @@ //! [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 POSTs the exact -//! retained JSON, verifies the response through [`super::verify_promotion`], and -//! flips the durable row to synced only when the relay served back a faithful, -//! owner-signed copy. +//! 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 @@ -19,24 +19,22 @@ //! * **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. -//! * **Only the exact generation/event is marked synced.** Confirmation binds -//! to `(generation, private_event_id)`; a relay that echoes a different -//! generation or head id is a verification failure, not a sync. +//! * **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`; nothing here clears retry except a fully -//! verified read-back. +//! and leaves `pending_sync = 1`. use nostr::{Event, Keys}; use reqwest::Method; use rusqlite::Connection; use serde::Deserialize; -use super::{verify_promotion, AggregateResponse, MigrationCandidate}; +use super::{verify_promotion, AggregateResponse, MigrationCandidate, PromotionEvidence}; use crate::app_state::AppState; use crate::managed_agents::retention::{ - get_retained_managed_agent_aggregate, mark_managed_agent_aggregate_synced, - record_managed_agent_aggregate_error, + get_retained_managed_agent_aggregate, record_managed_agent_aggregate_error, }; /// The route the relay mounts for atomic PMA aggregate commits. NIP-98 is @@ -61,16 +59,27 @@ struct RetainedAggregateBody { 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, +} + /// 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 copy and the row was marked synced. - Synced { - generation: u64, - private_event_id: String, + /// The relay served back a faithful active aggregate. Persistence and + /// compare-and-clear remain the caller's responsibility. + Verified { + attempt: RetainedAttempt, + evidence: PromotionEvidence, }, /// The attempt failed; a diagnostic was persisted and retry is preserved. Retained { error: String }, @@ -78,11 +87,12 @@ pub(crate) enum SubmitOutcome { /// Submit (or retry) the latest retained aggregate for one owner→agent pair. /// -/// Reads the durable row, POSTs its exact `request_json` with fresh owner-signed -/// NIP-98 auth, strict-deserializes the response, verifies it against the -/// candidate reconstructed from the same bytes, and — only on a fully verified -/// read-back — marks that exact generation/event synced. Every failure persists -/// an exact-attempt diagnostic without clearing retry. +/// 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( state: &AppState, @@ -116,15 +126,25 @@ pub(crate) async fn submit_retained_aggregate( Ok(SubmitOutcome::Retained { error }) }; - let response = match submit_to_relay(state, owner_keys, row.request_json.as_bytes()).await { - Ok(response) => response, + let candidate = match reconstruct_candidate(row.request_json.as_bytes(), owner_keys) { + Ok(candidate) => candidate, Err(error) => return record_failure(error), }; + if row.state != "active" + || 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(), + ); + } - // Reconstruct the verification source from the *same bytes* we sent, so the - // candidate and the wire request can never diverge. - let candidate = match reconstruct_candidate(row.request_json.as_bytes(), owner_keys) { - Ok(candidate) => candidate, + let response = match submit_to_relay(state, owner_keys, row.request_json.as_bytes()).await { + Ok(response) => response, Err(error) => return record_failure(error), }; @@ -144,22 +164,16 @@ pub(crate) async fn submit_retained_aggregate( )); } - match mark_managed_agent_aggregate_synced( - conn, - owner_pubkey, - agent_pubkey, - generation, - &private_event_id, - ) { - Ok(true) => Ok(SubmitOutcome::Synced { + Ok(SubmitOutcome::Verified { + attempt: RetainedAttempt { + owner_pubkey: row.owner_pubkey, + agent_pubkey: row.agent_pubkey, generation, private_event_id, - }), - // The row moved under us (superseded by a newer generation) between read - // and confirm. Nothing verified was lost; the newer row drives next. - Ok(false) => Ok(SubmitOutcome::Nothing), - Err(error) => Err(error), - } + state: row.state, + }, + evidence, + }) } /// POST the exact request bytes with fresh owner-signed NIP-98 auth and @@ -208,11 +222,25 @@ fn reconstruct_candidate(body: &[u8], owner_keys: &Keys) -> Result crate::app_state::AppState { // ── Tests ───────────────────────────────────────────────────────────────── #[tokio::test] -async fn faithful_read_back_marks_exact_generation_synced_and_posts_verbatim() { +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()); @@ -275,24 +276,39 @@ async fn faithful_read_back_marks_exact_generation_synced_and_posts_verbatim() { .await .expect("drive succeeds"); - assert_eq!( - outcome, - SubmitOutcome::Synced { - generation: fx.cas.generation, - private_event_id: fx.candidate.signed_event.id.to_hex(), + 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!(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()); - // The row is no longer pending and carries no error. - let confirmed = get_retained_managed_agent_aggregate(&conn, &fx.owner_hex(), &fx.agent_hex()) + // 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!(!confirmed.pending_sync, "verified row is marked synced"); - assert_eq!(confirmed.last_error, None); + assert!( + retained.pending_sync, + "transport leaves confirmation pending" + ); + assert_eq!(retained.last_error, None); } #[tokio::test] @@ -393,7 +409,7 @@ async fn tampered_read_back_fails_verification_and_preserves_retry() { } #[tokio::test] -async fn row_coordinate_disagreeing_with_verified_head_is_not_synced() { +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 @@ -404,7 +420,7 @@ async fn row_coordinate_disagreeing_with_verified_head_is_not_synced() { 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 { + let (url, captured) = spawn_stub(StubReply { status: StatusCode::OK, body: fx.faithful_response_json().to_string(), }) @@ -421,9 +437,10 @@ async fn row_coordinate_disagreeing_with_verified_head_is_not_synced() { .await .expect("drive resolves"); + assert!(matches!(outcome, SubmitOutcome::Retained { .. })); assert!( - matches!(outcome, SubmitOutcome::Retained { .. }), - "row whose coordinate disagrees with the verified head is not synced" + 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() From 1d702afc21dd6e6b255c9d966c1578368e70cbeb Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 12:09:11 -0600 Subject: [PATCH 22/40] desktop: activate PMA migration on capable relays Co-authored-by: Wes Signed-off-by: Wes --- desktop/src-tauri/src/commands/workspace.rs | 65 ++++- .../managed_agents/migration/activation.rs | 262 ++++++++++++++++++ .../managed_agents/migration/driver/mod.rs | 61 ++-- .../managed_agents/migration/driver/tests.rs | 33 ++- .../src/managed_agents/migration/mod.rs | 3 +- 5 files changed, 386 insertions(+), 38 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/migration/activation.rs diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 731a99d9d9..38dcb01ede 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -224,9 +224,68 @@ 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 { + 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}"); + return; + } + }; + let app = migration_app.clone(); + let keys = scope.owner_keys.clone(); + let db_path = scope.db_path.clone(); + let enqueue_extensions = extensions.clone(); + match tauri::async_runtime::spawn_blocking(move || { + crate::managed_agents::migration::activation::enqueue_initial_migrations( + &app, + &keys, + &db_path, + &enqueue_extensions, + ) + }) + .await + { + Ok(Ok(_)) => {} + Ok(Err(error)) => { + eprintln!("buzz-desktop: PMA migration enqueue failed: {error}"); + return; + } + Err(error) => { + eprintln!("buzz-desktop: PMA migration enqueue task failed: {error}"); + return; + } + } + 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}"); + } + }); } Err(error) => { eprintln!("buzz-desktop: scoped event-sync unavailable after workspace apply: {error}"); 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..8e05da9e9a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/migration/activation.rs @@ -0,0 +1,262 @@ +//! 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}; + +#[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, +} + +/// 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, + }, + )?; + 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| { + record.pubkey == attempt.agent_pubkey + && record.updated_at == attempt.source_updated_at + && !record.relay_authority.is_relay_authoritative() + }) { + 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) +} diff --git a/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs b/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs index b86b64d608..93e8bb612f 100644 --- a/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs @@ -26,13 +26,13 @@ //! [`record_managed_agent_aggregate_error`](crate::managed_agents::retention::record_managed_agent_aggregate_error) //! and leaves `pending_sync = 1`. +use std::path::Path; + use nostr::{Event, Keys}; use reqwest::Method; -use rusqlite::Connection; use serde::Deserialize; use super::{verify_promotion, AggregateResponse, MigrationCandidate, PromotionEvidence}; -use crate::app_state::AppState; use crate::managed_agents::retention::{ get_retained_managed_agent_aggregate, record_managed_agent_aggregate_error, }; @@ -67,6 +67,8 @@ pub(crate) struct RetainedAttempt { 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. @@ -95,15 +97,19 @@ pub(crate) enum SubmitOutcome { /// 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( - state: &AppState, - conn: &Connection, + http_client: &reqwest::Client, + relay_api_base_url: &str, + db_path: &Path, owner_keys: &Keys, owner_pubkey: &str, agent_pubkey: &str, ) -> Result { - let row = match get_retained_managed_agent_aggregate(conn, owner_pubkey, agent_pubkey)? { - Some(row) if row.pending_sync => row, - _ => return Ok(SubmitOutcome::Nothing), + 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. @@ -115,14 +121,16 @@ pub(crate) async fn submit_retained_aggregate( 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. - let _ = record_managed_agent_aggregate_error( - conn, - owner_pubkey, - agent_pubkey, - generation, - &private_event_id, - &error, - ); + 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 }) }; @@ -143,7 +151,14 @@ pub(crate) async fn submit_retained_aggregate( ); } - let response = match submit_to_relay(state, owner_keys, row.request_json.as_bytes()).await { + 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), }; @@ -164,6 +179,7 @@ pub(crate) async fn submit_retained_aggregate( )); } + let source_updated_at = candidate.payload.updated_at.clone(); Ok(SubmitOutcome::Verified { attempt: RetainedAttempt { owner_pubkey: row.owner_pubkey, @@ -171,6 +187,7 @@ pub(crate) async fn submit_retained_aggregate( generation, private_event_id, state: row.state, + source_updated_at, }, evidence, }) @@ -179,20 +196,22 @@ pub(crate) async fn submit_retained_aggregate( /// POST the exact request bytes with fresh owner-signed NIP-98 auth and /// strict-deserialize the aggregate response. async fn submit_to_relay( - state: &AppState, + 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 base = crate::relay::relay_api_base_url_with_override(state); - let url = format!("{}{AGGREGATE_PATH}", base.trim_end_matches('/')); + 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 = state - .http_client + let response = http_client .post(&url) .header("Authorization", auth_header) .header("Content-Type", "application/json") diff --git a/desktop/src-tauri/src/managed_agents/migration/driver/tests.rs b/desktop/src-tauri/src/managed_agents/migration/driver/tests.rs index 92e53e10da..adb51fba56 100644 --- a/desktop/src-tauri/src/managed_agents/migration/driver/tests.rs +++ b/desktop/src-tauri/src/managed_agents/migration/driver/tests.rs @@ -267,8 +267,9 @@ async fn faithful_read_back_returns_evidence_leaves_pending_and_posts_verbatim() let state = app_state_at(url); let outcome = submit_retained_aggregate( - &state, - &conn, + &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(), @@ -286,6 +287,7 @@ async fn faithful_read_back_returns_evidence_leaves_pending_and_posts_verbatim() 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, @@ -325,8 +327,9 @@ async fn conflict_status_preserves_retry_with_diagnostic() { let state = app_state_at(url); let outcome = submit_retained_aggregate( - &state, - &conn, + &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(), @@ -356,8 +359,9 @@ async fn malformed_success_body_preserves_retry() { let state = app_state_at(url); let outcome = submit_retained_aggregate( - &state, - &conn, + &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(), @@ -391,8 +395,9 @@ async fn tampered_read_back_fails_verification_and_preserves_retry() { let state = app_state_at(url); let outcome = submit_retained_aggregate( - &state, - &conn, + &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(), @@ -428,8 +433,9 @@ async fn row_coordinate_disagreeing_with_request_is_rejected_before_egress() { let state = app_state_at(url); let outcome = submit_retained_aggregate( - &state, - &conn, + &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(), @@ -453,7 +459,7 @@ async fn row_coordinate_disagreeing_with_request_is_rejected_before_egress() { async fn no_pending_row_is_a_noop() { let fx = Fixture::new(); let dir = tempfile::tempdir().expect("tempdir"); - let conn = open_retention_db(&dir.path().join("retention.db")).expect("open db"); + open_retention_db(&dir.path().join("retention.db")).expect("open db"); let (url, captured) = spawn_stub(StubReply { status: StatusCode::OK, @@ -463,8 +469,9 @@ async fn no_pending_row_is_a_noop() { let state = app_state_at(url); let outcome = submit_retained_aggregate( - &state, - &conn, + &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(), diff --git a/desktop/src-tauri/src/managed_agents/migration/mod.rs b/desktop/src-tauri/src/managed_agents/migration/mod.rs index 390a0d52b3..4629be4ce5 100644 --- a/desktop/src-tauri/src/managed_agents/migration/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -710,7 +710,8 @@ fn require_owner_projection( /// 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 driver; +pub(crate) mod activation; +mod driver; #[cfg(test)] mod tests; From 94b0a6e83ad1ae508a5d530d2dfee5073d1dc778 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 12:23:12 -0600 Subject: [PATCH 23/40] desktop: reconstruct relay-canonical agents Subscribe to owner-private PMA heads, strictly decrypt and validate their bound projections, and reconstruct the portable managed-agent record on a fresh device without allowing unsolicited heads to overwrite LegacyOnly state. Carry the relay-mesh marker that the field classification already declares private-canonical. Co-authored-by: Carl Signed-off-by: Wes --- .../src/commands/personas/inbound.rs | 136 +++++++++++++++++- .../src/managed_agents/migration/mod.rs | 7 +- .../src/managed_agents/migration/tests.rs | 9 +- desktop/src-tauri/src/managed_agents/mod.rs | 2 +- .../agents/lib/usePersonaSync.test.mjs | 2 + .../src/features/agents/lib/usePersonaSync.ts | 2 + desktop/src/shared/constants/kinds.ts | 2 + 7 files changed, 155 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d..2f182ec280 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,129 @@ 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(()); + } + } + + match payload.state { + State::Deleted => { + 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)?; + match existing_index { + Some(index) => 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 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/managed_agents/migration/mod.rs b/desktop/src-tauri/src/managed_agents/migration/mod.rs index 4629be4ce5..938e151dd5 100644 --- a/desktop/src-tauri/src/managed_agents/migration/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -294,7 +294,12 @@ where 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: None, + 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 { diff --git a/desktop/src-tauri/src/managed_agents/migration/tests.rs b/desktop/src-tauri/src/managed_agents/migration/tests.rs index 592856f368..01b5a50ee0 100644 --- a/desktop/src-tauri/src/managed_agents/migration/tests.rs +++ b/desktop/src-tauri/src/managed_agents/migration/tests.rs @@ -94,7 +94,9 @@ fn fully_valid_record() -> ManagedAgentRecord { definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, - relay_mesh: None, + relay_mesh: Some(crate::managed_agents::RelayMeshConfig { + model_ref: "mesh/model".to_string(), + }), relay_authority: super::super::authority::RelayAuthority::legacy(), } } @@ -187,6 +189,11 @@ fn happy_round_trip_yields_promotion_evidence() { 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] diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index e89c48704a..1c227b6e12 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -56,7 +56,7 @@ pub use backend::*; // solely by `authority`'s own tests via `super::*`, so they need no re-export // yet; the not-yet-built migration path will add one when it actually consumes // them. No blanket `#[allow(dead_code)]` — unused API stays unexported. -pub(crate) use authority::RelayAuthority; +pub(crate) use authority::{RelayAuthority, RelayAuthorityEvidence, VersionedBackend}; pub use discovery::*; pub use env_vars::*; #[cfg(windows)] diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index 0dc12ddfd1..f3d9b93acf 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, ]; diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index f18194c5c6..cc34adb45b 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,6 +18,7 @@ const PERSONA_SYNC_KINDS = [ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_PRIVATE_MANAGED_AGENT, KIND_DELETION, ]; diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index f851e459af..c637b3b64d 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -52,6 +52,8 @@ export const KIND_CHANNEL_SORT = 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; From 57bb681b6911b5a0f8fb9bc9f7639130955ba2e4 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 12:35:49 -0600 Subject: [PATCH 24/40] relay: fence private agent heads from NIP-09 deletion Reject both a-tag and e-tag kind:5 attempts to soft-delete kind:30179 at the handler and database boundaries. Extend the managed-agent integration assertion to cover the private head alongside its bound projections. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-db/src/event.rs | 8 ++++++ crates/buzz-db/src/managed_agent.rs | 6 +++++ .../buzz-relay/src/handlers/side_effects.rs | 25 ++++++++++--------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 441d4b6f94..334fb1bfe1 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -887,6 +887,10 @@ pub async fn soft_delete_by_coordinate( .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 @@ -940,6 +944,10 @@ pub async fn soft_delete_event_and_update_thread( .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 diff --git a/crates/buzz-db/src/managed_agent.rs b/crates/buzz-db/src/managed_agent.rs index d56a28aeb2..d77349f332 100644 --- a/crates/buzz-db/src/managed_agent.rs +++ b/crates/buzz-db/src/managed_agent.rs @@ -795,6 +795,7 @@ mod tests { // 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, @@ -806,6 +807,11 @@ mod tests { 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( diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 22a6c46c2a..4bb1f7ccc7 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2168,19 +2168,20 @@ 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 matches!( - k, - buzz_core::kind::KIND_PERSONA | buzz_core::kind::KIND_MANAGED_AGENT - ) && state - .db - .managed_agent_projection_coordinate_is_authoritative( - tenant.community(), + if k == buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT + || (matches!( k, - &pubkey_bytes, - d_tag, - ) - .await - .map_err(|e| anyhow::anyhow!("PMA deletion fence failed: {e}"))? + 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, From 94b96752c9696c6957d1c4c526d2abc8c09bd4a0 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 12:39:33 -0600 Subject: [PATCH 25/40] desktop: hydrate managed agents before Welcome provisioning Make the owner-scoped persona sync backfill awaitable and run it before Welcome onboarding inspects local managed agents. This lets a second installation reconstruct relay-canonical identities before deciding whether to mint replacements. Co-authored-by: Carl Signed-off-by: Wes --- .../src/features/agents/lib/usePersonaSync.ts | 29 +++++++++++++------ .../features/onboarding/welcomeGuide.test.mjs | 25 ++++++++++++++++ .../src/features/onboarding/welcomeGuide.ts | 14 +++++++++ 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index cc34adb45b..484638bb28 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -22,6 +22,23 @@ const PERSONA_SYNC_KINDS = [ KIND_DELETION, ]; +export async function hydratePersonaSync( + pubkey: string, + relayUrl: string, + onCancelled: () => boolean = () => false, +): Promise { + const events = await relayClient.fetchEvents({ + kinds: PERSONA_SYNC_KINDS, + authors: [pubkey], + limit: 500, + }); + 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. // Returns a disposer that closes the live subscription. Extracted from the hook @@ -48,15 +65,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([ From ddf10f96a4b01b4d725a5b7896da8bf6ff5f998c Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 12:48:14 -0600 Subject: [PATCH 26/40] desktop: test private agent reconstruction directly Build and decrypt a real private managed-agent aggregate in the inbound reconciliation test module, then assert portable secrets, backend configuration, relay mesh, and authoritative head evidence survive reconstruction. Co-authored-by: Carl Signed-off-by: Wes --- .../personas/inbound/inbound_tests.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) 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 1414ad0470..729d1cb7fe 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -257,6 +257,60 @@ 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); From 33c41d10c3370729fb72d837033735f839b76616 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 13:02:51 -0600 Subject: [PATCH 27/40] desktop: make relay-canonical deletion crash safe Retain authoritative tombstones before local destruction, preserve terminal deletion evidence, submit and verify deleted aggregates, and apply local erase with a marker-before-erase replay protocol. Keep legacy deletion immediate and make persona cascades resumable across partial enqueue/save failures. Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Signed-off-by: Wes --- desktop/src-tauri/src/commands/agents.rs | 328 ++++++++++++++---- .../src-tauri/src/commands/agents_tests.rs | 60 ++++ desktop/src-tauri/src/commands/mod.rs | 2 +- .../src-tauri/src/commands/personas/mod.rs | 78 ++++- desktop/src-tauri/src/commands/workspace.rs | 12 + desktop/src-tauri/src/egress_guard.rs | 1 + desktop/src-tauri/src/egress_guard_tests.rs | 4 + .../src-tauri/src/managed_agents/authority.rs | 43 ++- .../src/managed_agents/authority/tests.rs | 58 ++++ .../managed_agents/migration/activation.rs | 307 ++++++++++++++++ .../managed_agents/migration/driver/mod.rs | 226 +++++++++--- .../managed_agents/migration/driver/tests.rs | 234 +++++++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 12 +- .../src-tauri/src/managed_agents/reconcile.rs | 6 +- .../src-tauri/src/managed_agents/retention.rs | 178 +++++++++- 15 files changed, 1398 insertions(+), 151 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index a05b64ec56..8a9ee1ac1d 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -59,23 +59,102 @@ 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`. +/// 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)] +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`). +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(super) 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, 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. +/// never resurrect it after the tombstone publishes. pub(super) 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, @@ -89,50 +168,146 @@ pub(super) fn tombstone_managed_agent_pending( const KIND_DELETE: u32 = 5; - let result = (|| -> Result<(), String> { + // 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)?; - if let Some(evidence) = relay_authority.evidence() { - 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}"))?; - return 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, - }, - ); - } + 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) @@ -155,6 +330,7 @@ pub(super) fn tombstone_managed_agent_pending( if let Err(e) = result { eprintln!("buzz-desktop: agent-tombstone: {e}"); } + Ok(TombstoneDisposition::EraseNow) } /// Build and sign the NIP-IA `kind:9035` archive request enqueued when an @@ -215,7 +391,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; @@ -1373,23 +1549,45 @@ pub async fn delete_managed_agent( 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, &relay_authority); - // 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_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 4620c5250e..f78893217a 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -502,3 +502,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/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 42607aa7ec..023bc29741 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -213,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) })?; } @@ -236,21 +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, - cascade_authorities - .get(pk) - .ok_or_else(|| format!("agent {pk} authority missing"))?, - ); 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/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 38dcb01ede..0956f654c2 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -285,6 +285,18 @@ pub async fn apply_workspace( { 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) => { 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/authority.rs b/desktop/src-tauri/src/managed_agents/authority.rs index 67cd28e31a..a5132a7264 100644 --- a/desktop/src-tauri/src/managed_agents/authority.rs +++ b/desktop/src-tauri/src/managed_agents/authority.rs @@ -45,6 +45,15 @@ pub enum RelayAuthority { /// 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. @@ -81,6 +90,14 @@ impl RelayAuthority { 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. /// @@ -90,14 +107,36 @@ impl RelayAuthority { matches!(self, Self::RelayAuthoritative { .. }) } - /// The verified head evidence, when relay-authoritative. + /// 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 } => Some(evidence), + Self::RelayAuthoritative { evidence } | Self::Deleting { evidence } => Some(evidence), Self::LegacyOnly => None, } } diff --git a/desktop/src-tauri/src/managed_agents/authority/tests.rs b/desktop/src-tauri/src/managed_agents/authority/tests.rs index 2c3de736e5..882a047c63 100644 --- a/desktop/src-tauri/src/managed_agents/authority/tests.rs +++ b/desktop/src-tauri/src/managed_agents/authority/tests.rs @@ -460,3 +460,61 @@ fn impossible_authority_states_are_unrepresentable() { "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/migration/activation.rs b/desktop/src-tauri/src/managed_agents/migration/activation.rs index 8e05da9e9a..841ee822e1 100644 --- a/desktop/src-tauri/src/managed_agents/migration/activation.rs +++ b/desktop/src-tauri/src/managed_agents/migration/activation.rs @@ -162,6 +162,7 @@ pub(crate) fn enqueue_initial_migrations( request_json, pending_sync: true, last_error: None, + local_authority_applied: false, }, )?; enqueued += 1; @@ -260,3 +261,309 @@ pub(crate) async fn flush_pending_migrations( } 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 index 93e8bb612f..a5b0ac326f 100644 --- a/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs @@ -28,11 +28,15 @@ 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_promotion, AggregateResponse, MigrationCandidate, PromotionEvidence}; +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, }; @@ -83,6 +87,13 @@ pub(crate) enum SubmitOutcome { 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 }, } @@ -134,63 +145,132 @@ pub(crate) async fn submit_retained_aggregate( Ok(SubmitOutcome::Retained { error }) }; - let candidate = match reconstruct_candidate(row.request_json.as_bytes(), owner_keys) { - Ok(candidate) => candidate, - Err(error) => return record_failure(error), - }; - if row.state != "active" - || 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(), - ); - } + // 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 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:?}")), - }; + 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, not a sync. - 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 - )); - } + // 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, - }) + 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 @@ -269,5 +349,43 @@ fn reconstruct_candidate(body: &[u8], owner_keys: &Keys) -> Result 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 index adb51fba56..69456c8935 100644 --- a/desktop/src-tauri/src/managed_agents/migration/driver/tests.rs +++ b/desktop/src-tauri/src/managed_agents/migration/driver/tests.rs @@ -189,8 +189,62 @@ impl Fixture { 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 ────────────────────────────────────────────────────────────── @@ -245,6 +299,21 @@ fn seeded_conn( 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); @@ -485,3 +554,168 @@ async fn no_pending_row_is_a_noop() { "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/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 1c227b6e12..1d5f807ae2 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -50,12 +50,12 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { } pub use backend::*; -// Only `RelayAuthority` is consumed by production code (the record field + -// its `legacy()` constructor at every call site). The classification API -// (`classify_field`, `FieldClass`) and `RelayAuthorityEvidence` are exercised -// solely by `authority`'s own tests via `super::*`, so they need no re-export -// yet; the not-yet-built migration path will add one when it actually consumes -// them. No blanket `#[allow(dead_code)]` — unused API stays unexported. +// `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::*; diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 3817a4a41b..8de49de7e1 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -108,9 +108,9 @@ fn reconcile_agents_in_dir_at( // 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 records are reconciled through the - // PMA read-back path instead. - if record.relay_authority.is_relay_authoritative() { + // 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; } diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index c518f68623..703243066a 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -122,6 +122,13 @@ pub struct RetainedManagedAgentAggregate { 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, } /// Insert or idempotently refresh a retained aggregate generation. @@ -144,6 +151,11 @@ pub fn retain_managed_agent_aggregate( 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() @@ -224,7 +236,7 @@ pub fn get_retained_managed_agent_aggregate( ) -> Result, String> { conn.query_row( "SELECT owner_pubkey, agent_pubkey, generation, private_event_id, state, - request_json, pending_sync, last_error + 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 @@ -249,7 +261,7 @@ pub fn get_pending_managed_agent_aggregates( .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.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 = ( @@ -286,6 +298,7 @@ fn aggregate_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result(6)? != 0, last_error: row.get(7)?, + local_authority_applied: row.get::<_, i32>(8)? != 0, }) } @@ -343,6 +356,42 @@ pub fn record_managed_agent_aggregate_error( 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) +} + /// A retained persona event row. #[derive(Debug, Clone)] pub struct RetainedEvent { @@ -392,9 +441,49 @@ pub fn open_retention_db(path: &Path) -> Result { ) .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 { @@ -816,6 +905,7 @@ mod tests { request_json: request_json.to_string(), pending_sync: true, last_error: None, + local_authority_applied: false, } } @@ -1333,4 +1423,88 @@ mod tests { &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); + } } From f960d5665ed52a5e8a4d347ccbbc945228779325 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 13:04:03 -0600 Subject: [PATCH 28/40] Revoke deleted managed agent participation Add an owner- and community-bound durable tombstone predicate and enforce it across shared transport admission, ingest, and live WebSocket commands. Await cluster-wide disconnect publication after tombstone commits so exact retries repair failed propagation. Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz> Signed-off-by: Wes --- crates/buzz-db/src/lib.rs | 10 ++ crates/buzz-db/src/managed_agent.rs | 109 ++++++++++++++++ crates/buzz-relay/src/api/bridge.rs | 9 +- crates/buzz-relay/src/api/managed_agents.rs | 17 +++ crates/buzz-relay/src/api/mod.rs | 122 +++++++++++++----- crates/buzz-relay/src/connection.rs | 40 +++++- crates/buzz-relay/src/handlers/auth.rs | 33 ++--- crates/buzz-relay/src/handlers/event.rs | 4 +- crates/buzz-relay/src/handlers/ingest.rs | 46 +++++++ crates/buzz-relay/src/state.rs | 27 ++++ .../personas/inbound/inbound_tests.rs | 8 +- 11 files changed, 371 insertions(+), 54 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index c79a38f69c..fb593abd36 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -4788,6 +4788,16 @@ impl Db { 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, diff --git a/crates/buzz-db/src/managed_agent.rs b/crates/buzz-db/src/managed_agent.rs index d77349f332..6b9560bab5 100644 --- a/crates/buzz-db/src/managed_agent.rs +++ b/crates/buzz-db/src/managed_agent.rs @@ -424,6 +424,37 @@ pub async fn read_head( .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, @@ -882,6 +913,84 @@ mod tests { .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(), + "a foreign owner cannot use another owner's tombstone to revoke an agent" + ); + let db = crate::Db::from_pool(pool.clone()); + db.ensure_user(community, owner.public_key().as_bytes()) + .await + .unwrap(); + db.ensure_user(community, first_agent.public_key().as_bytes()) + .await + .unwrap(); + assert!(db + .set_agent_owner( + community, + first_agent.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .unwrap()); + assert!( + participation_is_revoked(&pool, community, first_agent.public_key().as_bytes(), None,) + .await + .unwrap(), + "the durable owner mapping revokes without request delegation evidence" + ); + 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, diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 99b9e5244d..2eb838badc 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -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 index a4bc5d1414..2c9d97527a 100644 --- a/crates/buzz-relay/src/api/managed_agents.rs +++ b/crates/buzz-relay/src/api/managed_agents.rs @@ -100,6 +100,23 @@ pub async fn submit_aggregate( } _ => 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(); for stored in &commit.events { crate::handlers::event::dispatch_persistent_event( diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index eb04d8920a..7c91c899f9 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -55,6 +55,31 @@ pub mod relay_members { Denied, } + fn verified_nip_oa_owner( + allow_nip_oa_auth: bool, + pubkey_bytes: &[u8], + auth_tag_header: Option<&str>, + ) -> Option { + if !allow_nip_oa_auth { + return None; + } + 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 @@ -65,11 +90,31 @@ pub mod relay_members { pubkey_bytes: &[u8], auth_tag_header: Option<&str>, ) -> Result { + let pubkey_hex = hex::encode(pubkey_bytes); + let verified_owner = verified_nip_oa_owner( + !state.config.require_relay_membership || state.config.allow_nip_oa_auth, + 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) @@ -80,30 +125,20 @@ pub mod relay_members { } 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)); } } } @@ -116,9 +151,9 @@ pub mod relay_members { /// Returns `Ok(Some(owner_pubkey))` when the agent is not a direct member but /// its NIP-OA owner *is* — access is granted via delegation. /// - /// 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`), returns a verified + /// NIP-OA owner unconditionally for durable backfill. The feature flag only + /// controls whether delegation can grant access on a closed relay. /// /// 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). @@ -238,6 +273,35 @@ pub mod relay_members { use buzz_sdk::nip_oa::compute_auth_tag; use nostr::Keys; + #[test] + fn closed_relay_disabled_nip_oa_does_not_supply_policy_owner_evidence() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let tag = compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + assert_eq!( + verified_nip_oa_owner(false, agent.public_key().as_bytes(), Some(&tag)), + None + ); + assert_eq!( + verified_nip_oa_owner(true, agent.public_key().as_bytes(), Some(&tag)), + Some(owner.public_key()) + ); + assert_eq!( + verified_nip_oa_owner(true, 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 96e266779f..86e8eda06c 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -601,13 +601,49 @@ async fn enforce_ws_admission( 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) = diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e..9cbb31c2df 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -1,10 +1,10 @@ //! 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 on open relays unconditionally +//! and on closed relays when delegation auth is enabled, 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 +237,9 @@ 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 - } - }); - + // The shared gate returns a verified owner only when NIP-OA is + // enabled, including on open relays. Preserve that feature gate for + // ownership materialization. // 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 +259,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 a9cdffcdec..9691772c73 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. @@ -76,6 +78,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. @@ -96,6 +100,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 { @@ -1908,6 +1924,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) { @@ -3010,6 +3052,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, }; @@ -3424,6 +3467,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(), @@ -3442,6 +3486,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, }; @@ -3457,6 +3502,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/state.rs b/crates/buzz-relay/src/state.rs index 58a869a995..e9abb30744 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1050,6 +1050,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/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 729d1cb7fe..963829914f 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -300,7 +300,8 @@ fn private_aggregate_reconstructs_portable_agent_and_authority() { .unwrap(); let reconstructed = - reconstruct_managed_agent_from_payload(&candidate.payload, &candidate.signed_event).unwrap(); + 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); @@ -308,7 +309,10 @@ fn private_aggregate_reconstructs_portable_agent_and_authority() { 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()); + assert_eq!( + evidence.private_event_id, + candidate.signed_event.id.to_hex() + ); } #[test] From 3b7af206fdef00110903a6acbfd2a3bdd5f621a7 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 13:14:41 -0600 Subject: [PATCH 29/40] desktop: route authoritative agent edits through relay CAS Build generation-advancing aggregates from the retained definition binding, submit and verify before advancing local authority evidence, suppress fenced legacy projection writes, and roll back local edits when relay confirmation fails. Co-authored-by: Carl Signed-off-by: Wes --- .../src-tauri/src/commands/agent_models.rs | 64 +++++++- .../src/commands/agent_update_rollback.rs | 2 +- desktop/src-tauri/src/commands/agents.rs | 6 + .../src/commands/personas/inbound.rs | 2 +- .../managed_agents/migration/activation.rs | 152 +++++++++++++++++- 5 files changed, 220 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 4704582372..75234aa583 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -737,7 +737,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 +854,13 @@ pub async fn update_managed_agent( record.updated_at = now_iso(); + let authoritative_edit = record.relay_authority.is_relay_authoritative(); + if authoritative_edit { + crate::managed_agents::migration::activation::enqueue_authoritative_edit( + &app, &state, record, + )?; + } + save_managed_agents(&app, &records)?; let record = records @@ -901,12 +908,63 @@ 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 = crate::managed_agents::retention::active_retention_scope(&app, &state)?; + let relay_api = crate::relay::relay_http_base_url(&scope.relay_url); + let verified = + match crate::managed_agents::migration::activation::submit_authoritative_edit( + &state.http_client, + &relay_api, + &scope.owner_keys, + &scope.db_path, + &summary.pubkey, + ) + .await + { + Ok(verified) => verified, + Err(sync_error) => { + let rollback = rollback.as_ref().ok_or_else(|| { + "missing local rollback state after authoritative relay failure".to_string() + })?; + rollback_failed_agent_update( + &app, + &state, + &summary.pubkey, + rollback.clone(), + )?; + return Err(format!( + "Agent edit failed before relay authority could be confirmed. No local changes were kept: {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)?; + } + crate::managed_agents::migration::activation::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. 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 8a9ee1ac1d..9fa25781d2 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> { diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 2f182ec280..bc9ecbe646 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -259,7 +259,7 @@ fn reconcile_inbound_private_managed_agent( Ok(()) } -fn reconstruct_managed_agent_from_payload( +pub(crate) fn reconstruct_managed_agent_from_payload( payload: &buzz_core_pkg::private_managed_agent::Payload, private_event: &nostr::Event, ) -> Result { diff --git a/desktop/src-tauri/src/managed_agents/migration/activation.rs b/desktop/src-tauri/src/managed_agents/migration/activation.rs index 841ee822e1..f63f23dee6 100644 --- a/desktop/src-tauri/src/managed_agents/migration/activation.rs +++ b/desktop/src-tauri/src/managed_agents/migration/activation.rs @@ -38,6 +38,151 @@ struct AggregateRequest<'a> { 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, @@ -212,9 +357,14 @@ pub(crate) async fn flush_pending_migrations( .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() + && (!record.relay_authority.is_relay_authoritative() || authority_can_advance) }) { records[index].relay_authority = RelayAuthority::relay_authoritative(RelayAuthorityEvidence { From e7fca223c3691bd198f61d3aa6872121b28a1780 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 13:21:29 -0600 Subject: [PATCH 30/40] desktop: document migration builder argument seam The migration builder deliberately accepts the complete cryptographic candidate inputs; suppress the argument-count lint at that boundary rather than obscuring them behind an unrelated bag type. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src-tauri/src/managed_agents/migration/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/src-tauri/src/managed_agents/migration/mod.rs b/desktop/src-tauri/src/managed_agents/migration/mod.rs index 938e151dd5..67e48263cf 100644 --- a/desktop/src-tauri/src/managed_agents/migration/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -213,6 +213,7 @@ pub fn build_tombstone_event( /// 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, From d88f3f1544674b392957f7d8152edc01500098ac Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 13:40:12 -0600 Subject: [PATCH 31/40] desktop: close relay-authority retry gaps Retire proven conflicts while preserving transport retries, seed reconstructed relay heads into retention, preserve device-local policy during inbound aggregate advances, and suppress fenced legacy persona publication. Co-authored-by: Carl Signed-off-by: Wes --- .../src-tauri/src/commands/agent_models.rs | 43 +++++++++++---- .../src/commands/personas/inbound.rs | 55 ++++++++++++++++++- .../src/commands/personas/pending.rs | 10 ++++ .../managed_agents/migration/driver/mod.rs | 8 ++- .../src-tauri/src/managed_agents/retention.rs | 25 +++++++++ 5 files changed, 129 insertions(+), 12 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 75234aa583..d4c0df06b5 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -930,17 +930,40 @@ pub async fn update_managed_agent( { Ok(verified) => verified, Err(sync_error) => { - let rollback = rollback.as_ref().ok_or_else(|| { - "missing local rollback state after authoritative relay failure".to_string() - })?; - rollback_failed_agent_update( - &app, - &state, - &summary.pubkey, - rollback.clone(), - )?; + if sync_error.starts_with("conflict:") { + let pending = crate::managed_agents::retention::get_retained_managed_agent_aggregate( + &crate::managed_agents::retention::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 = crate::managed_agents::retention::open_retention_db( + &scope.db_path, + )?; + if !crate::managed_agents::retention::mark_managed_agent_aggregate_synced( + &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 failed before relay authority could be confirmed. No local changes were kept: {sync_error}" + "Agent edit is retained for relay retry; local changes remain pending confirmation: {sync_error}" )); } }; diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index bc9ecbe646..2eb059e195 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -246,8 +246,36 @@ fn reconcile_inbound_private_managed_agent( } 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}"))?; + let scope = crate::managed_agents::retention::arrival_retention_scope( + app, + state, + arrival_relay_url, + )? + .ok_or_else(|| "private aggregate arrival scope changed".to_string())?; + let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?; + crate::managed_agents::retention::seed_confirmed_managed_agent_aggregate( + &conn, + &crate::managed_agents::retention::RetainedManagedAgentAggregate { + 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) => agents[index] = reconstructed, + Some(index) => merge_reconstructed_managed_agent(&mut agents[index], reconstructed), None => agents.push(reconstructed), } crate::managed_agents::save_managed_agents(app, &agents)?; @@ -259,6 +287,31 @@ fn reconcile_inbound_private_managed_agent( Ok(()) } +fn merge_reconstructed_managed_agent( + local: &mut ManagedAgentRecord, + inbound: ManagedAgentRecord, +) { + let start_on_app_launch = local.start_on_app_launch; + let auto_restart = local.auto_restart_on_config_change; + let provider_binary_path = local.provider_binary_path.clone(); + let runtime_pid = local.runtime_pid; + let last_started_at = local.last_started_at.clone(); + let last_stopped_at = local.last_stopped_at.clone(); + let last_exit_code = local.last_exit_code; + let last_error = local.last_error.clone(); + let last_error_code = local.last_error_code; + *local = inbound; + local.start_on_app_launch = start_on_app_launch; + local.auto_restart_on_config_change = auto_restart; + local.provider_binary_path = provider_binary_path; + local.runtime_pid = runtime_pid; + local.last_started_at = last_started_at; + local.last_stopped_at = last_stopped_at; + local.last_exit_code = last_exit_code; + local.last_error = last_error; + local.last_error_code = last_error_code; +} + pub(crate) fn reconstruct_managed_agent_from_payload( payload: &buzz_core_pkg::private_managed_agent::Payload, private_event: &nostr::Event, 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/managed_agents/migration/driver/mod.rs b/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs index a5b0ac326f..6cc17b8ff6 100644 --- a/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/driver/mod.rs @@ -301,7 +301,13 @@ async fn submit_to_relay( .map_err(|e| crate::relay::classify_request_error(&e))?; if !response.status().is_success() { - return Err(crate::relay::relay_error_message(response).await); + 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 diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 703243066a..5d25b7f953 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -131,6 +131,31 @@ pub struct RetainedManagedAgentAggregate { pub local_authority_applied: bool, } +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(()) +} + /// Insert or idempotently refresh a retained aggregate generation. /// /// Generation may advance by exactly one. A byte-identical rewrite of the From 25fca37e01f9022a128904abf9550dfeb1d74f4d Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 13:40:56 -0600 Subject: [PATCH 32/40] desktop: retire conflicted aggregate attempts Delete an exact pending edit attempt after a proven relay conflict so the next edit can advance from newly hydrated authority instead of being blocked by a dead retained generation. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src-tauri/src/commands/agent_models.rs | 2 +- .../src-tauri/src/managed_agents/retention.rs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index d4c0df06b5..8d9456f95d 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -940,7 +940,7 @@ pub async fn update_managed_agent( let conn = crate::managed_agents::retention::open_retention_db( &scope.db_path, )?; - if !crate::managed_agents::retention::mark_managed_agent_aggregate_synced( + if !crate::managed_agents::retention::retire_managed_agent_aggregate( &conn, &pending.owner_pubkey, &pending.agent_pubkey, diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 5d25b7f953..1fcf7b1f4b 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -131,6 +131,24 @@ pub struct RetainedManagedAgentAggregate { 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, From 21a4f03b8b7491ef80445ccfe2652beebda53bc2 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 14:47:21 -0600 Subject: [PATCH 33/40] desktop: satisfy relay authority CI gates Format the Tauri changes, use transaction auto-deref accepted by Windows Clippy, and split oversized desktop modules along existing responsibility boundaries so the file-size ratchet remains enforceable. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-db/src/event.rs | 4 +- crates/buzz-db/src/lib.rs | 2 +- .../src-tauri/src/commands/agent_models.rs | 192 +-- .../commands/agent_models/normalization.rs | 85 ++ desktop/src-tauri/src/commands/agents.rs | 277 +---- .../src/commands/agents/tombstone.rs | 275 +++++ .../src/commands/personas/inbound.rs | 5 +- .../src/managed_agents/discovery/tests.rs | 3 +- .../managed_agents/migration/activation.rs | 9 +- .../src-tauri/src/managed_agents/readiness.rs | 1 - .../src-tauri/src/managed_agents/retention.rs | 1042 +---------------- .../retention/managed_agent_aggregates.rs | 335 ++++++ .../src/managed_agents/retention/tests.rs | 703 +++++++++++ .../src/managed_agents/runtime/tests.rs | 1 - desktop/src-tauri/src/managed_agents/types.rs | 105 +- .../types/behavioral_defaults.rs | 105 ++ .../agents/lib/personaCatalogRelay.test.mjs | 4 +- 17 files changed, 1582 insertions(+), 1566 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_models/normalization.rs create mode 100644 desktop/src-tauri/src/commands/agents/tombstone.rs create mode 100644 desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs create mode 100644 desktop/src-tauri/src/managed_agents/retention/tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/types/behavioral_defaults.rs diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 334fb1bfe1..fc61d68a52 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -895,7 +895,7 @@ pub async fn soft_delete_by_coordinate( kind as u32, buzz_core::kind::KIND_PERSONA | buzz_core::kind::KIND_MANAGED_AGENT ) && crate::managed_agent::projection_coordinate_is_authoritative_on( - &mut *tx, + &mut tx, community_id, kind as u32, pubkey, @@ -963,7 +963,7 @@ pub async fn soft_delete_event_and_update_thread( .execute(&mut *tx) .await?; if crate::managed_agent::projection_coordinate_is_authoritative_on( - &mut *tx, + &mut tx, community_id, kind as u32, &pubkey, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index fb593abd36..998336c3d9 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -4879,7 +4879,7 @@ impl Db { kind_i32 as u32, buzz_core::kind::KIND_PERSONA | buzz_core::kind::KIND_MANAGED_AGENT ) && managed_agent::projection_coordinate_is_authoritative_on( - &mut *tx, + &mut tx, community_id, kind_i32 as u32, pubkey_bytes.as_slice(), diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 8d9456f95d..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}, @@ -856,9 +864,7 @@ pub async fn update_managed_agent( let authoritative_edit = record.relay_authority.is_relay_authoritative(); if authoritative_edit { - crate::managed_agents::migration::activation::enqueue_authoritative_edit( - &app, &state, record, - )?; + enqueue_authoritative_edit(&app, &state, record)?; } save_managed_agents(&app, &records)?; @@ -916,57 +922,51 @@ pub async fn update_managed_agent( try_regenerate_nest(&app); if authoritative_edit { - let scope = crate::managed_agents::retention::active_retention_scope(&app, &state)?; + let scope = active_retention_scope(&app, &state)?; let relay_api = crate::relay::relay_http_base_url(&scope.relay_url); - let verified = - match crate::managed_agents::migration::activation::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 = crate::managed_agents::retention::get_retained_managed_agent_aggregate( - &crate::managed_agents::retention::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 = crate::managed_agents::retention::open_retention_db( - &scope.db_path, - )?; - if !crate::managed_agents::retention::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}" - )); + 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 @@ -982,10 +982,7 @@ pub async fn update_managed_agent( ); save_managed_agents(&app, &records)?; } - crate::managed_agents::migration::activation::confirm_authoritative_edit( - &scope.db_path, - &verified, - )?; + confirm_authoritative_edit(&scope.db_path, &verified)?; } // Phase 2: relay profile sync (async, outside lock). A rename is committed @@ -1018,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/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 9fa25781d2..a38dddd877 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -65,279 +65,10 @@ pub(super) fn retain_managed_agent_pending( } } -/// 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)] -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`). -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(super) 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(super) 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) -} +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 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/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 2eb059e195..25e427dbcc 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -287,10 +287,7 @@ fn reconcile_inbound_private_managed_agent( Ok(()) } -fn merge_reconstructed_managed_agent( - local: &mut ManagedAgentRecord, - inbound: ManagedAgentRecord, -) { +fn merge_reconstructed_managed_agent(local: &mut ManagedAgentRecord, inbound: ManagedAgentRecord) { let start_on_app_launch = local.start_on_app_launch; let auto_restart = local.auto_restart_on_config_change; let provider_binary_path = local.provider_binary_path.clone(); diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 50b3928075..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() { diff --git a/desktop/src-tauri/src/managed_agents/migration/activation.rs b/desktop/src-tauri/src/managed_agents/migration/activation.rs index f63f23dee6..f80771b052 100644 --- a/desktop/src-tauri/src/managed_agents/migration/activation.rs +++ b/desktop/src-tauri/src/managed_agents/migration/activation.rs @@ -64,13 +64,18 @@ pub(crate) fn enqueue_authoritative_edit( 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()); + 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()); + 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}"))?; diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 24ab051e4e..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; diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 1fcf7b1f4b..49bb9752c6 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -106,334 +106,8 @@ pub fn arrival_retention_scope( )) } -/// 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(()) -} - -/// 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) -} +mod managed_agent_aggregates; +pub use managed_agent_aggregates::*; /// A retained persona event row. #[derive(Debug, Clone)] @@ -841,713 +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, - } - } - - 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); - } -} +#[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..e05b9ee643 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs @@ -0,0 +1,335 @@ +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(()) +} + +/// 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..70b157c6b3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/tests.rs @@ -0,0 +1,703 @@ +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); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 66662d31b1..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] diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index d4cc06d50a..422a71d34c 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -897,109 +897,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); }); From b7f117c8e1738c1ad4a927fcf9bc94563982b9f7 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 15:01:41 -0600 Subject: [PATCH 34/40] desktop: update parallelism authority fixture Initialize the relay authority field in the managed-agent parallelism test fixture so all-target Clippy and the Tauri test suite compile after the authority model landed. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src-tauri/src/managed_agents/parallelism.rs | 1 + 1 file changed, 1 insertion(+) 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(), } } From 2fcf4b9ceb45729f200c9636a95c9ea97775450e Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 15:44:58 -0600 Subject: [PATCH 35/40] Fix managed-agent hydration and retry gaps Route mutation-gating PMA hydration through the writer, page all sync history without mixed-kind starvation, replay durable heads after retrying post-commit propagation, and clarify the deployment and revision trust boundaries. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-db/src/managed_agent.rs | 6 ++- crates/buzz-relay/src/api/managed_agents.rs | 12 ++++- crates/buzz-relay/src/handlers/req.rs | 28 ++++++++++-- .../agents/lib/usePersonaSync.test.mjs | 30 ++++++++----- .../src/features/agents/lib/usePersonaSync.ts | 45 ++++++++++++++++--- docs/nips/NIP-PMA.md | 5 +++ 6 files changed, 102 insertions(+), 24 deletions(-) diff --git a/crates/buzz-db/src/managed_agent.rs b/crates/buzz-db/src/managed_agent.rs index 6b9560bab5..00f4471612 100644 --- a/crates/buzz-db/src/managed_agent.rs +++ b/crates/buzz-db/src/managed_agent.rs @@ -18,8 +18,10 @@ pub struct AggregateRequest { pub definition_event: Option, /// Signed kind:30177 candidate, required for active heads. pub instance_event: Option, - /// Revision already embedded in the encrypted active payload. Required for - /// active heads and rejected unless it matches the locked definition head. + /// 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, } diff --git a/crates/buzz-relay/src/api/managed_agents.rs b/crates/buzz-relay/src/api/managed_agents.rs index 2c9d97527a..ccfcf0e901 100644 --- a/crates/buzz-relay/src/api/managed_agents.rs +++ b/crates/buzz-relay/src/api/managed_agents.rs @@ -118,7 +118,17 @@ pub async fn submit_aggregate( })?; } let owner_hex = owner.to_hex(); - for stored in &commit.events { + let retry_private_event; + let dispatch_events: &[buzz_core::StoredEvent] = if commit.inserted { + &commit.events + } else { + // 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) + }; + for stored in dispatch_events { crate::handlers::event::dispatch_persistent_event( &tenant, &state, diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 26dbdbdda8..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)| { @@ -301,7 +301,12 @@ pub async fn handle_req( 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(); @@ -312,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) } }, @@ -1160,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() @@ -1867,6 +1883,10 @@ mod tests { 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] diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index f3d9b93acf..a9b8a04cac 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -39,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( @@ -81,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 484638bb28..a79b1f8afa 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -21,17 +21,52 @@ const PERSONA_SYNC_KINDS = [ 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 { - const events = await relayClient.fetchEvents({ - kinds: PERSONA_SYNC_KINDS, - authors: [pubkey], - limit: 500, - }); + // 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; diff --git a/docs/nips/NIP-PMA.md b/docs/nips/NIP-PMA.md index e9f2f2b127..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 From d22d587004d8df507943de1d53f1d0eb680b6ddc Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 15:45:20 -0600 Subject: [PATCH 36/40] Enforce managed-agent revocation for direct members Verify signed NIP-OA owner evidence independently from the closed-relay delegation feature flag. Preserve and materialize that evidence for directly enrolled agents so AUTH binds subsequent REQ and EVENT revalidation to the durable PMA tombstone. Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz> Signed-off-by: Wes --- crates/buzz-relay/src/api/mod.rs | 48 +++++++++++--------------- crates/buzz-relay/src/connection.rs | 38 +++++++++++++++++++- crates/buzz-relay/src/handlers/auth.rs | 14 ++++---- 3 files changed, 66 insertions(+), 34 deletions(-) diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 7c91c899f9..2137888d36 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -47,8 +47,10 @@ 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. @@ -56,13 +58,9 @@ pub mod relay_members { } fn verified_nip_oa_owner( - allow_nip_oa_auth: bool, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, ) -> Option { - if !allow_nip_oa_auth { - return None; - } 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) { @@ -91,11 +89,10 @@ pub mod relay_members { auth_tag_header: Option<&str>, ) -> Result { let pubkey_hex = hex::encode(pubkey_bytes); - let verified_owner = verified_nip_oa_owner( - !state.config.require_relay_membership || state.config.allow_nip_oa_auth, - pubkey_bytes, - auth_tag_header, - ); + // 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( @@ -121,7 +118,7 @@ pub mod relay_members { .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 { @@ -148,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 a verified - /// NIP-OA owner unconditionally for durable backfill. The feature flag only - /// controls whether delegation can grant access on a closed relay. + /// 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, @@ -164,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, @@ -274,20 +272,16 @@ pub mod relay_members { use nostr::Keys; #[test] - fn closed_relay_disabled_nip_oa_does_not_supply_policy_owner_evidence() { + 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(false, agent.public_key().as_bytes(), Some(&tag)), - None - ); - assert_eq!( - verified_nip_oa_owner(true, agent.public_key().as_bytes(), Some(&tag)), + verified_nip_oa_owner(agent.public_key().as_bytes(), Some(&tag)), Some(owner.public_key()) ); assert_eq!( - verified_nip_oa_owner(true, agent.public_key().as_bytes(), Some("invalid")), + verified_nip_oa_owner(agent.public_key().as_bytes(), Some("invalid")), None ); } diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 86e8eda06c..d7ef3b779b 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -591,13 +591,20 @@ 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; } @@ -808,6 +815,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 9cbb31c2df..6e2dfced44 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -2,9 +2,10 @@ //! //! Relay membership enforcement uses the shared //! [`crate::api::relay_members::enforce_relay_membership`] helper. NIP-OA -//! owner evidence is returned by the shared gate on open relays unconditionally -//! and on closed relays when delegation auth is enabled, then durably -//! materialized for later transport-neutral revocation checks. +//! 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,9 +238,10 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }; - // The shared gate returns a verified owner only when NIP-OA is - // enabled, including on open relays. Preserve that feature gate for - // ownership materialization. + // 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 { From 2d107740e216145f5bb4845ed1d13d1ebf4a1eff Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 15:48:12 -0600 Subject: [PATCH 37/40] Persist a durable tombstone generation floor for private managed agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inbound private managed-agent (PMA) tombstone could be resurrected locally. The deleted branch of reconcile_inbound_private_managed_agent persisted nothing when no local record existed, and the generation guards only run when a record IS present. So a delayed stale active head at the tombstone's generation (or lower) — a fresh-device hydration/live race, or a post-erase replay — found no record, skipped every guard, and hit the no-match insert path, reconstructing the deleted agent including its nsec. Fix: give every coordinate a DURABLE generation floor, keyed by the retained managed_agent_aggregates rows (the MAX generation across active AND deleted rows). The deleted branch now seeds a confirmed tombstone row even when no local record exists, and both branches gate on the floor before touching the store: an inbound head at <= the floor generation is dropped, while a genuine re-creation at a strictly higher generation still passes. - seed_confirmed_managed_agent_tombstone: idempotent (INSERT OR IGNORE) confirmed deleted-row seed; never downgrades a flush-authored row. - reconcile_inbound_private_managed_agent: open retention once for both branches, read the floor, reject stale heads, seed the tombstone floor before erasing the local record. Regression: the exact active-N / deleted-N+1 / replay-active-N interleaving stays floored (agent absent), a re-created N+2 clears the floor, plus the seed's idempotency and synced-deleted-head contract. Full desktop/src-tauri workspace: 2310 passed / 0 failed / 13 ignored; fmt and clippy -D warnings clean. Addresses PR #4940 review thread discussion_r3724146156. Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../src/commands/personas/inbound.rs | 60 ++++++++-- .../retention/managed_agent_aggregates.rs | 40 +++++++ .../src/managed_agents/retention/tests.rs | 103 ++++++++++++++++++ 3 files changed, 196 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 25e427dbcc..55c16f3d2f 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -235,8 +235,61 @@ fn reconcile_inbound_private_managed_agent( } } + // Open the retention store once for both branches. It carries the DURABLE + // generation floor per coordinate — the deleted branch persists a tombstone + // row here even when no local record exists, and the active branch gates its + // no-match insert on it. The in-memory `relay_authority` guard above only + // fires for a record that is still present; a delayed stale active head that + // arrives after a tombstone erased (or preceded) the record finds no record, + // skips that guard, and would otherwise resurrect the deleted agent — nsec + // included. The persisted floor closes that window. + let scope = + crate::managed_agents::retention::arrival_retention_scope(app, state, arrival_relay_url)? + .ok_or_else(|| "private aggregate arrival scope changed".to_string())?; + let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?; + let durable_floor = crate::managed_agents::retention::get_retained_managed_agent_aggregate( + &conn, + &payload.owner_pubkey, + &payload.agent_pubkey, + )?; + // A retained row (active OR deleted) at this generation or newer means this + // head is stale relative to durable local authority — drop it. A legitimate + // re-creation advances to a strictly higher generation and passes. + if durable_floor + .as_ref() + .is_some_and(|floor| payload.generation <= floor.generation) + { + return Ok(()); + } + match payload.state { State::Deleted => { + // Persist the tombstone as a durable floor FIRST, so even with no + // local record a later stale active head at <= this generation is + // rejected by the floor gate above. Seeded confirmed (already on the + // relay); `local_authority_applied` reflects the record/key erase + // this branch performs (trivially true when no record exists). + let request_json = serde_json::to_string(&serde_json::json!({ + "private_event": event, + "definition_event": Option::<&nostr::Event>::None, + "instance_event": Option::<&nostr::Event>::None, + "expected_definition_revision": Option::::None, + })) + .map_err(|error| format!("failed to retain inbound private tombstone: {error}"))?; + crate::managed_agents::retention::seed_confirmed_managed_agent_tombstone( + &conn, + &crate::managed_agents::retention::RetainedManagedAgentAggregate { + owner_pubkey: payload.owner_pubkey.clone(), + agent_pubkey: payload.agent_pubkey.clone(), + generation: payload.generation, + private_event_id: event.id.to_hex(), + state: "deleted".to_string(), + request_json, + pending_sync: false, + last_error: None, + local_authority_applied: true, + }, + )?; if let Some(index) = existing_index { agents.remove(index); crate::managed_agents::delete_agent_key(&payload.agent_pubkey); @@ -253,13 +306,6 @@ fn reconcile_inbound_private_managed_agent( "expected_definition_revision": payload.active.as_ref().map(|active| active.definition.revision), })) .map_err(|error| format!("failed to retain inbound private aggregate: {error}"))?; - let scope = crate::managed_agents::retention::arrival_retention_scope( - app, - state, - arrival_relay_url, - )? - .ok_or_else(|| "private aggregate arrival scope changed".to_string())?; - let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?; crate::managed_agents::retention::seed_confirmed_managed_agent_aggregate( &conn, &crate::managed_agents::retention::RetainedManagedAgentAggregate { diff --git a/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs b/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs index e05b9ee643..80f2ff5430 100644 --- a/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs +++ b/desktop/src-tauri/src/managed_agents/retention/managed_agent_aggregates.rs @@ -73,6 +73,46 @@ pub fn seed_confirmed_managed_agent_aggregate( Ok(()) } +/// Persist a confirmed deleted tombstone row as a durable generation floor. +/// +/// Called from the inbound reconcile deleted branch so a verified tombstone +/// leaves a durable floor at its coordinate EVEN WHEN no local record exists — +/// the record-present guards upstream never run in that case, so without this +/// floor a delayed stale active head at the same-or-lower generation would +/// reconstruct the deleted agent (including its nsec). The tombstone is already +/// durable on the relay, so it is seeded confirmed (`pending_sync = 0`) and the +/// flush lane never touches it. `local_authority_applied` records whether the +/// local record/key erase was applied here (trivially satisfied when no record +/// existed). `INSERT OR IGNORE` keeps a re-received tombstone idempotent and +/// never downgrades a row the flush lane authored. +pub fn seed_confirmed_managed_agent_tombstone( + conn: &Connection, + aggregate: &RetainedManagedAgentAggregate, +) -> Result<(), String> { + if aggregate.pending_sync || aggregate.state != "deleted" { + return Err( + "confirmed managed-agent tombstone seed must be a synced deleted head".to_string(), + ); + } + conn.execute( + "INSERT OR IGNORE INTO managed_agent_aggregates + (owner_pubkey, agent_pubkey, generation, private_event_id, state, + request_json, pending_sync, last_error, local_authority_applied) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)", + params![ + aggregate.owner_pubkey, + aggregate.agent_pubkey, + aggregate.generation as i64, + aggregate.private_event_id, + aggregate.state, + aggregate.request_json, + aggregate.local_authority_applied as i32, + ], + ) + .map_err(|error| format!("failed to seed confirmed managed-agent tombstone: {error}"))?; + Ok(()) +} + /// Insert or idempotently refresh a retained aggregate generation. /// /// Generation may advance by exactly one. A byte-identical rewrite of the diff --git a/desktop/src-tauri/src/managed_agents/retention/tests.rs b/desktop/src-tauri/src/managed_agents/retention/tests.rs index 70b157c6b3..030a9c4fed 100644 --- a/desktop/src-tauri/src/managed_agents/retention/tests.rs +++ b/desktop/src-tauri/src/managed_agents/retention/tests.rs @@ -701,3 +701,106 @@ fn deletion_marker_refuses_a_non_deleted_row() { .unwrap(); assert!(!row.local_authority_applied); } + +/// The durable generation floor for a coordinate: the MAX retained generation +/// across BOTH active and deleted rows. Mirrors the gate the inbound reconcile +/// applies before it inserts a no-match active head — `get_retained_...` +/// returns the latest generation regardless of state, so a persisted tombstone +/// raises the floor exactly like a persisted active head. +fn floored(conn: &Connection, incoming_generation: u64) -> bool { + get_retained_managed_agent_aggregate(conn, "owner", "agent") + .unwrap() + .is_some_and(|floor| incoming_generation <= floor.generation) +} + +/// A confirmed tombstone seed persisted with no prior local record raises the +/// durable floor, so the exact resurrection interleaving is closed: +/// snapshot active N → live deleted N+1 → replayed active N must stay floored, +/// while a genuine re-creation at N+2 clears the floor. +#[test] +fn confirmed_tombstone_seed_floors_a_replayed_stale_active_head() { + let conn = test_db(); + + // Snapshot active N=1 seeds a confirmed active floor at 1. + let mut active_n = aggregate(1, "event-1", "active", r#"{"generation":1}"#); + active_n.pending_sync = false; + seed_confirmed_managed_agent_aggregate(&conn, &active_n).unwrap(); + assert!(floored(&conn, 1), "the replay of active N is floored"); + assert!( + !floored(&conn, 2), + "a genuine advance to N+1 is not floored" + ); + + // Live deleted N+1 seeds a confirmed tombstone floor at 2 — the crux: this + // path runs even when NO local record exists, so the floor is durable and a + // later stale active head cannot resurrect the deleted agent. + let mut tombstone = aggregate(2, "event-2", "deleted", r#"{"generation":2}"#); + tombstone.pending_sync = false; + tombstone.local_authority_applied = true; + seed_confirmed_managed_agent_tombstone(&conn, &tombstone).unwrap(); + let floor = get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap(); + assert_eq!(floor.generation, 2); + assert_eq!(floor.state, "deleted"); + assert!(floor.local_authority_applied); + + // Replayed active N=1: floored — the agent stays absent. + assert!( + floored(&conn, 1), + "replayed active N is rejected by the tombstone floor" + ); + // Deleted N+1 replayed: also floored (idempotent re-receive is a no-op). + assert!(floored(&conn, 2), "re-received tombstone N+1 is floored"); + // Genuine re-creation at N+2 clears the floor and is admitted. + assert!( + !floored(&conn, 3), + "a fresh re-creation at N+2 is not floored" + ); +} + +/// A confirmed tombstone seed with no prior record is idempotent and never +/// downgrades a row the flush lane authored (`INSERT OR IGNORE`). +#[test] +fn confirmed_tombstone_seed_is_idempotent_and_never_downgrades() { + let conn = test_db(); + let mut tombstone = aggregate(2, "event-2", "deleted", r#"{"generation":2}"#); + tombstone.pending_sync = false; + tombstone.local_authority_applied = true; + seed_confirmed_managed_agent_tombstone(&conn, &tombstone).unwrap(); + // Re-receive: no error, no duplicate row, marker preserved. + seed_confirmed_managed_agent_tombstone(&conn, &tombstone).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM managed_agent_aggregates + WHERE owner_pubkey = 'owner' AND agent_pubkey = 'agent'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + let row = get_retained_managed_agent_aggregate(&conn, "owner", "agent") + .unwrap() + .unwrap(); + assert!(row.local_authority_applied); + assert!(!row.pending_sync); +} + +/// The confirmed tombstone seed refuses anything that is not a synced deleted +/// head — a pending row or an active state would corrupt the floor's meaning. +#[test] +fn confirmed_tombstone_seed_refuses_pending_or_non_deleted() { + let conn = test_db(); + + let mut pending = aggregate(2, "event-2", "deleted", r#"{"generation":2}"#); + pending.pending_sync = true; + assert!(seed_confirmed_managed_agent_tombstone(&conn, &pending) + .unwrap_err() + .contains("synced deleted head")); + + let mut active = aggregate(2, "event-2", "active", r#"{"generation":2}"#); + active.pending_sync = false; + assert!(seed_confirmed_managed_agent_tombstone(&conn, &active) + .unwrap_err() + .contains("synced deleted head")); +} From 125f3d7cef3f19ae276c580496db726d37f9a969 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 15:50:25 -0600 Subject: [PATCH 38/40] Make inbound agent authority merge exhaustive Destructure every managed-agent field at the relay-to-device boundary so new fields cannot silently inherit the wrong authority class. Apply only portable state while preserving device policy, derived commands, paths, timestamps, and runtime receipts. Co-authored-by: Carl Signed-off-by: Wes --- .../src/commands/personas/inbound.rs | 115 +++++++++++++++--- 1 file changed, 96 insertions(+), 19 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 55c16f3d2f..fd5e04285f 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -334,25 +334,102 @@ fn reconcile_inbound_private_managed_agent( } fn merge_reconstructed_managed_agent(local: &mut ManagedAgentRecord, inbound: ManagedAgentRecord) { - let start_on_app_launch = local.start_on_app_launch; - let auto_restart = local.auto_restart_on_config_change; - let provider_binary_path = local.provider_binary_path.clone(); - let runtime_pid = local.runtime_pid; - let last_started_at = local.last_started_at.clone(); - let last_stopped_at = local.last_stopped_at.clone(); - let last_exit_code = local.last_exit_code; - let last_error = local.last_error.clone(); - let last_error_code = local.last_error_code; - *local = inbound; - local.start_on_app_launch = start_on_app_launch; - local.auto_restart_on_config_change = auto_restart; - local.provider_binary_path = provider_binary_path; - local.runtime_pid = runtime_pid; - local.last_started_at = last_started_at; - local.last_stopped_at = last_stopped_at; - local.last_exit_code = last_exit_code; - local.last_error = last_error; - local.last_error_code = last_error_code; + // 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( From 63563d4e5d19b77c3bc4ac485ee2193de6d280a2 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 15:50:41 -0600 Subject: [PATCH 39/40] Limit aggregate retry fan-out to tombstones Only deletion retries need to repair the post-commit disconnect/fan-out gap; leave byte-identical active retries as no-op deliveries. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-relay/src/api/managed_agents.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/api/managed_agents.rs b/crates/buzz-relay/src/api/managed_agents.rs index ccfcf0e901..f18eadcf35 100644 --- a/crates/buzz-relay/src/api/managed_agents.rs +++ b/crates/buzz-relay/src/api/managed_agents.rs @@ -121,12 +121,14 @@ pub async fn submit_aggregate( let retry_private_event; let dispatch_events: &[buzz_core::StoredEvent] = if commit.inserted { &commit.events - } else { + } 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( From 4a21424e212df79bd37e5cb29cf55e3407ec1a7c Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 16:55:29 -0600 Subject: [PATCH 40/40] Close PMA revocation gap and gate migration rollout Bind each aggregate-controlled agent to its owner in the same serializable transaction as the canonical head, including idempotent retries that repair brownfield rows. This makes tombstones revoke direct members even when they never supplied NIP-OA evidence, while rejecting conflicting owner mappings. Keep automatic legacy-agent promotion dark unless the desktop build opts in with BUZZ_DESKTOP_BUILD_PMA_MIGRATION=1. Existing authoritative edit and deletion retries continue to flush so the rollout gate cannot strand them. Also return the final serialization failure normally instead of retaining a panic-shaped retry tail. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-db/src/managed_agent.rs | 106 ++++++++++++++---- desktop/src-tauri/src/commands/workspace.rs | 53 ++++----- .../managed_agents/migration/activation.rs | 25 +++++ 3 files changed, 139 insertions(+), 45 deletions(-) diff --git a/crates/buzz-db/src/managed_agent.rs b/crates/buzz-db/src/managed_agent.rs index 00f4471612..f3a1dae297 100644 --- a/crates/buzz-db/src/managed_agent.rs +++ b/crates/buzz-db/src/managed_agent.rs @@ -207,14 +207,20 @@ pub async fn commit_aggregate( request: &AggregateRequest, ) -> Result { validate_request(request)?; - for attempt in 0..=2 { - match commit_aggregate_once(pool, community, request).await { + 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") && attempt < 2 => {} - result => return result, + if error.code().as_deref() == Some("40001") + ); + if retryable && serialization_retries < 2 { + serialization_retries += 1; + continue; } + return result; } - unreachable!("bounded serialization retry always returns") } async fn commit_aggregate_once( @@ -249,6 +255,41 @@ async fn commit_aggregate_once( .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 { @@ -928,7 +969,7 @@ mod tests { ); let foreign_owner = Keys::generate(); assert!( - !participation_is_revoked( + participation_is_revoked( &pool, community, first_agent.public_key().as_bytes(), @@ -936,28 +977,53 @@ mod tests { ) .await .unwrap(), - "a foreign owner cannot use another owner's tombstone to revoke an agent" + "foreign delegation evidence cannot bypass the aggregate's durable owner binding" ); - let db = crate::Db::from_pool(pool.clone()); - db.ensure_user(community, owner.public_key().as_bytes()) + 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(); - db.ensure_user(community, first_agent.public_key().as_bytes()) + 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!(db - .set_agent_owner( - community, - first_agent.public_key().as_bytes(), - owner.public_key().as_bytes(), - ) - .await - .unwrap()); + assert!(!repaired.inserted); assert!( - participation_is_revoked(&pool, community, first_agent.public_key().as_bytes(), None,) + participation_is_revoked(&pool, community, first_agent.public_key().as_bytes(), None) .await .unwrap(), - "the durable owner mapping revokes without request delegation evidence" + "an exact aggregate retry must repair missing durable revocation authority" ); assert!( !participation_is_revoked( diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 0956f654c2..9f4672cdd5 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -236,8 +236,11 @@ pub async fn apply_workspace( 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 { - let extensions = - match crate::managed_agents::migration::activation::discover_relay_extensions( + // 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, ) @@ -246,31 +249,31 @@ pub async fn apply_workspace( Ok(extensions) => extensions, Err(error) => { eprintln!("buzz-desktop: PMA capability discovery failed: {error}"); - return; + Vec::new() } }; - let app = migration_app.clone(); - let keys = scope.owner_keys.clone(); - let db_path = scope.db_path.clone(); - let enqueue_extensions = extensions.clone(); - match tauri::async_runtime::spawn_blocking(move || { - crate::managed_agents::migration::activation::enqueue_initial_migrations( - &app, - &keys, - &db_path, - &enqueue_extensions, - ) - }) - .await - { - Ok(Ok(_)) => {} - Ok(Err(error)) => { - eprintln!("buzz-desktop: PMA migration enqueue failed: {error}"); - return; - } - Err(error) => { - eprintln!("buzz-desktop: PMA migration enqueue task failed: {error}"); - return; + 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) = diff --git a/desktop/src-tauri/src/managed_agents/migration/activation.rs b/desktop/src-tauri/src/managed_agents/migration/activation.rs index f80771b052..88be4328f0 100644 --- a/desktop/src-tauri/src/managed_agents/migration/activation.rs +++ b/desktop/src-tauri/src/managed_agents/migration/activation.rs @@ -24,6 +24,31 @@ use crate::managed_agents::retention::{ 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)]