feat(desktop): track catalog provenance on a copied persona

Adding another member's shared agent minted a fresh local persona with a new
UUID and no link back to the publication it came from, so the catalog had no
way to tell an already-added entry from a new one and every click added
another copy.

A copied persona now carries the publication's coordinate — publisher pubkey
plus the persona's d-tag — through create_persona and the unified agent
store, which is enough for the catalog to answer "already added" for a
foreign entry. The coordinate is normalized at the command boundary because
a value that cannot match a publication silently restores the duplicate it
exists to prevent. Absent on every non-catalog path, and skipped when
serializing, so existing records neither break nor gain a null key.

The create path moves to personas/create.rs, mirroring the edit-path split,
and CatalogSource to its own module to keep both files under the size cap.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Will Pfleger
2026-07-27 13:53:34 -04:00
parent b6439c7505
commit 5fffd16a1e
39 changed files with 368 additions and 76 deletions
+10 -6
View File
@@ -94,10 +94,10 @@ const overrides = new Map([
// 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for
// retry-safety. Load-bearing reviewer-required change; queued to split.
// Consolidation removed the legacy persona-card import/export codecs.
// Retired-in-place ratchet: the edit path moved to personas/update.rs,
// taking mod.rs from 1003 to 779. Kept as a ratchet so the edit-path split
// cannot silently refill.
["src-tauri/src/commands/personas/mod.rs", 779],
// Retired-in-place ratchet: the edit path moved to personas/update.rs and
// the create path to personas/create.rs, taking mod.rs from 1003 to 719.
// Kept as a ratchet so neither split can silently refill.
["src-tauri/src/commands/personas/mod.rs", 719],
// #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS
// const + build_thread_replies_filter helper, mirroring the channel sibling so
// the two p-gate filters can't drift) plus two guard unit tests. The file was
@@ -381,7 +381,9 @@ const overrides = new Map([
// Available both-present AND adapter-present/CLI-absent — the selectability
// regression guard), bound to an injectable resolver so the tests stay
// PATH-independent.
["src-tauri/src/managed_agents/discovery/tests.rs", 1871],
// +2 (1871 -> 1873): the AgentDefinition and ManagedAgentRecord fixtures each
// set the new mandatory `catalog_source` field.
["src-tauri/src/managed_agents/discovery/tests.rs", 1873],
// identity-import-keyring: the identity resolution state machine's behavioral
// matrix (46 tests over FakeIdentityStore — probe × marker × file cells,
// adoption / read-back-corruption / marker-failure arms, recovery-mode
@@ -576,7 +578,9 @@ const overrides = new Map([
// computing had_* so stale materialized snapshot bytes can never be tagged
// BuzzExplicit and shadow the definition/global fallthrough; the dead
// persona-model re-tag branch replaced; two new regression tests added.
["src-tauri/src/commands/agent_config.rs", 1110],
// +2 (1110 -> 1112): the agent_record and persona_with_model test fixtures
// each set the new mandatory `catalog_source` field.
["src-tauri/src/commands/agent_config.rs", 1112],
// codex-install-auto-restart review-fixes: should_restart_after_install
// takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy
// cache tests replaced with 6 pure availability_drift predicate tests;
@@ -687,6 +687,7 @@ mod tests {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
@@ -712,6 +713,7 @@ mod tests {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: Default::default(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -397,6 +397,7 @@ fn model_discovery_ignores_stale_record_for_linked_agent() {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
+1
View File
@@ -938,6 +938,7 @@ pub async fn create_managed_agent(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
@@ -56,6 +56,7 @@ fn bare_agent_record(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
@@ -79,6 +80,7 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: vec![],
@@ -0,0 +1,85 @@
//! The persona creation command surface, split from `mod.rs` (file-size cap)
//! as the sibling of [`super::update`].
use tauri::AppHandle;
use uuid::Uuid;
use crate::{
app_state::AppState,
managed_agents::{
apply_persona_behavior, load_personas, save_personas, try_regenerate_nest, AgentDefinition,
CatalogSource, CreatePersonaRequest,
},
util::now_iso,
};
use super::{pending, retain_persona_pending, trim_optional, trim_required};
#[tauri::command]
pub async fn create_persona(
input: CreatePersonaRequest,
app: AppHandle,
) -> Result<AgentDefinition, String> {
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let display_name = trim_required(&input.display_name, "Display name")?;
// System prompt optional: core memory is auto-injected. Empty is valid.
let system_prompt = input.system_prompt.trim().to_string();
let avatar_url = trim_optional(input.avatar_url);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
let provider = trim_optional(input.provider);
// Normalized before the store is touched: a coordinate that can't match
// a publication is worse than no coordinate, because it silently
// re-enables the duplicate add it exists to prevent.
let catalog_source = input
.catalog_source
.map(CatalogSource::normalized)
.transpose()?;
let now = now_iso();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut personas = load_personas(&app)?;
pending::project_active_persona_sharing(&app, &state, &mut personas);
let name_pool: Vec<String> = input
.name_pool
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;
let mut persona = AgentDefinition {
id: Uuid::new_v4().to_string(),
display_name,
avatar_url,
system_prompt,
runtime,
model,
provider,
name_pool,
is_builtin: false,
is_active: true,
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source,
env_vars: input.env_vars,
respond_to: None,
respond_to_allowlist: Vec::new(),
parallelism: None,
created_at: now.clone(),
updated_at: now,
};
apply_persona_behavior(&mut persona, input.behavior)?;
personas.push(persona.clone());
save_personas(&app, &personas)?;
retain_persona_pending(&app, &state, &persona);
try_regenerate_nest(&app);
Ok(persona)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
@@ -64,6 +64,7 @@ fn make_agent(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
@@ -23,6 +23,7 @@ fn local_in_app() -> AgentDefinition {
shared: false,
source_team: Some("team-1".to_string()),
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::from([("API_KEY".to_string(), "secret".to_string())]),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -49,6 +50,7 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition {
shared: false,
source_team: None,
source_team_persona_slug: Some(d_tag.to_string()),
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -208,6 +210,7 @@ fn local_agent() -> ManagedAgentRecord {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
+8 -68
View File
@@ -1,15 +1,14 @@
use tauri::{AppHandle, Emitter, Manager};
use uuid::Uuid;
use crate::{
app_state::AppState,
managed_agents::{
agent_events::ManagedAgentEventContent, apply_persona_behavior, current_instance_id,
delete_agent_key, load_managed_agents, load_personas, load_teams,
persona_events::persona_d_tag, save_managed_agents, save_personas,
stop_managed_agent_process, sync_managed_agent_processes, team_events::TeamEventContent,
try_regenerate_nest, validate_persona_activation_change, validate_persona_deletion,
AgentDefinition, CreatePersonaRequest, ManagedAgentRecord, TeamRecord,
agent_events::ManagedAgentEventContent, current_instance_id, delete_agent_key,
load_managed_agents, load_personas, load_teams, persona_events::persona_d_tag,
save_managed_agents, save_personas, stop_managed_agent_process,
sync_managed_agent_processes, team_events::TeamEventContent, try_regenerate_nest,
validate_persona_activation_change, validate_persona_deletion, AgentDefinition,
ManagedAgentRecord, TeamRecord,
},
util::now_iso,
};
@@ -32,6 +31,8 @@ fn trim_optional(value: Option<String>) -> Option<String> {
mod pending;
pub(in crate::commands) use pending::retain_persona_pending;
pub(super) use pending::tombstone_persona_pending;
mod create;
pub use create::create_persona;
mod sharing;
pub use sharing::set_persona_shared;
pub use sharing::update_persona_and_publish;
@@ -55,67 +56,6 @@ pub async fn list_personas(app: AppHandle) -> Result<Vec<AgentDefinition>, Strin
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
pub async fn create_persona(
input: CreatePersonaRequest,
app: AppHandle,
) -> Result<AgentDefinition, String> {
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let display_name = trim_required(&input.display_name, "Display name")?;
// System prompt optional: core memory is auto-injected. Empty is valid.
let system_prompt = input.system_prompt.trim().to_string();
let avatar_url = trim_optional(input.avatar_url);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
let provider = trim_optional(input.provider);
let now = now_iso();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut personas = load_personas(&app)?;
pending::project_active_persona_sharing(&app, &state, &mut personas);
let name_pool: Vec<String> = input
.name_pool
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;
let mut persona = AgentDefinition {
id: Uuid::new_v4().to_string(),
display_name,
avatar_url,
system_prompt,
runtime,
model,
provider,
name_pool,
is_builtin: false,
is_active: true,
shared: false,
source_team: None,
source_team_persona_slug: None,
env_vars: input.env_vars,
respond_to: None,
respond_to_allowlist: Vec::new(),
parallelism: None,
created_at: now.clone(),
updated_at: now,
};
apply_persona_behavior(&mut persona, input.behavior)?;
personas.push(persona.clone());
save_personas(&app, &personas)?;
retain_persona_pending(&app, &state, &persona);
try_regenerate_nest(&app);
Ok(persona)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[cfg(test)]
mod delete_cascade_tests;
#[cfg(test)]
@@ -267,6 +267,7 @@ mod tests {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -156,6 +156,7 @@ mod tests {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -427,6 +427,7 @@ pub async fn confirm_agent_snapshot_import(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: std::collections::BTreeMap::new(),
respond_to: respond_to_wire.clone(),
respond_to_allowlist: minted.respond_to_allowlist.clone(),
@@ -496,6 +497,7 @@ pub async fn confirm_agent_snapshot_import(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: respond_to_wire.clone(),
definition_respond_to_allowlist: minted.respond_to_allowlist.clone(),
definition_parallelism: minted_parallelism,
@@ -68,6 +68,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
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,
@@ -53,6 +53,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge
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,
@@ -132,6 +132,7 @@ fn definition_from_snapshot(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: Default::default(),
respond_to,
respond_to_allowlist: behavior.respond_to_allowlist,
@@ -603,6 +604,7 @@ pub async fn confirm_team_snapshot_import(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: respond_to_wire.clone(),
definition_respond_to_allowlist: definition.respond_to_allowlist.clone(),
definition_parallelism: minted_parallelism,
@@ -68,6 +68,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: Default::default(),
respond_to: None,
respond_to_allowlist: vec![],
@@ -89,6 +90,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: Default::default(),
respond_to: None,
respond_to_allowlist: vec![],
@@ -151,6 +153,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: Default::default(),
respond_to: None,
respond_to_allowlist: vec![],
@@ -221,6 +224,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() {
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,
@@ -211,6 +211,7 @@ mod tests {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
@@ -539,6 +539,7 @@ mod tests {
source_team: Some("team-id-123".to_string()), // MUST NOT appear
source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear
definition_respond_to: Some("allowlist".to_string()),
catalog_source: None,
definition_respond_to_allowlist: vec!["abc123def".to_string()],
definition_parallelism: Some(4),
relay_mesh: None,
@@ -109,6 +109,7 @@ fn test_record() -> ManagedAgentRecord {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
@@ -286,6 +286,7 @@ fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agent
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: std::collections::BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -361,6 +362,7 @@ fn record_with(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
@@ -21,6 +21,7 @@ fn definition(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: vec![],
@@ -85,6 +86,7 @@ fn record(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
@@ -346,6 +346,7 @@ fn bare_record() -> ManagedAgentRecord {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
auto_restart_on_config_change: false,
definition_respond_to: None,
@@ -369,6 +370,7 @@ fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefini
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: vec![],
@@ -629,6 +631,7 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: vec![],
@@ -425,6 +425,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: std::collections::BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -484,6 +485,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
@@ -195,6 +195,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result<AgentDefinition, Strin
shared: persona_event_is_shared(event),
source_team: None,
source_team_persona_slug: Some(d_tag),
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: content.respond_to,
respond_to_allowlist: content.respond_to_allowlist,
@@ -53,6 +53,7 @@ fn sample_record() -> ManagedAgentRecord {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
@@ -153,6 +154,7 @@ fn sample_persona() -> AgentDefinition {
shared: false,
source_team: None,
source_team_persona_slug: Some("test-slug".to_string()),
catalog_source: None,
env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -379,6 +381,7 @@ fn content_matches_nip_ap_vector() {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -409,6 +412,7 @@ fn round_trip_minimal_persona() {
shared: false,
source_team: Some("team-1".to_string()),
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -505,6 +509,7 @@ fn quad_absent_definition_hash_stable_across_activation() {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -548,6 +553,7 @@ fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDef
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: content.respond_to,
respond_to_allowlist: content.respond_to_allowlist,
@@ -124,6 +124,7 @@ fn built_in_persona_records(now: &str) -> Vec<AgentDefinition> {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: std::collections::BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -21,6 +21,7 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: std::collections::BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -1513,6 +1513,7 @@ mod tests {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
@@ -176,6 +176,7 @@ fn fixture(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
@@ -296,6 +297,7 @@ fn persona_with_provider(
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: std::collections::BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -52,6 +52,7 @@ fn record() -> ManagedAgentRecord {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
@@ -74,6 +75,7 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -305,6 +305,7 @@ mod tests {
source_team: Some("SENTINEL_SOURCE_TEAM".to_string()), // MUST NOT appear
source_team_persona_slug: Some("SENTINEL_SLUG".to_string()), // MUST NOT appear
definition_respond_to: None,
catalog_source: None,
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
@@ -211,6 +211,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
relay_mesh: None,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
@@ -64,6 +64,13 @@ pub struct AgentDefinition {
alias = "source_pack_persona_slug"
)]
pub source_team_persona_slug: Option<String>,
/// Provenance of a persona copied from another owner's shared catalog.
///
/// Set only on the copy, never on the original. It is what makes
/// "already added" answerable for a foreign catalog entry: the copy carries
/// a new local id, so the only link back to the publication is this pair.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub catalog_source: Option<CatalogSource>,
/// Harness-level configuration passed to the agent subprocess as environment variables.
/// Opaque to Buzz — keys and values are runtime-specific.
///
@@ -141,6 +148,7 @@ impl AgentDefinition {
shared: false,
source_team: self.source_team,
source_team_persona_slug: self.source_team_persona_slug,
catalog_source: self.catalog_source,
definition_respond_to: self.respond_to,
definition_respond_to_allowlist: self.respond_to_allowlist,
definition_parallelism: self.parallelism,
@@ -174,6 +182,7 @@ impl ManagedAgentRecord {
shared: false,
source_team: self.source_team.clone(),
source_team_persona_slug: self.source_team_persona_slug.clone(),
catalog_source: self.catalog_source.clone(),
env_vars: self.env_vars.clone(),
respond_to: self.definition_respond_to.clone(),
respond_to_allowlist: self.definition_respond_to_allowlist.clone(),
@@ -396,6 +405,10 @@ pub struct ManagedAgentRecord {
/// definition's slug within its source team.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_team_persona_slug: Option<String>,
/// Absorbed from `AgentDefinition.catalog_source` — the publication this
/// definition was copied from, when it came from another owner's catalog.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub catalog_source: Option<CatalogSource>,
/// NIP-AP definition-level behavioral defaults, absorbed from
/// `AgentDefinition` in WIRE shape (kebab-case string / optional u32),
/// distinct from the instance-side `respond_to`/`respond_to_allowlist`/
@@ -972,6 +985,8 @@ pub fn resolve_mint_behavioral_defaults(
})
}
mod catalog_source;
pub use catalog_source::CatalogSource;
mod requests;
pub use requests::*;
@@ -0,0 +1,52 @@
//! The catalog-provenance coordinate carried on a copied persona
//! definition, split from `types.rs` (file-size cap).
use serde::{Deserialize, Serialize};
/// Where a persona copy came from in another owner's shared catalog.
///
/// The pair is the publication's NIP-AP coordinate minus the kind: the owner
/// who published it and the `d`-tag identifying the persona within that
/// owner's catalog. A copy carries a fresh local `id`, so this pair is the
/// only thing that can answer "is this catalog entry already added".
///
/// Field casing follows [`super::RelayMeshConfig`]: persisted records use snake_case
/// and the camelCase `alias`es accept the create payload the frontend sends
/// (`rename_all` on the request does not recurse into nested structs).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CatalogSource {
#[serde(alias = "ownerPubkey")]
pub owner_pubkey: String,
#[serde(alias = "personaId")]
pub persona_id: String,
}
impl CatalogSource {
/// Normalize a coordinate arriving from the frontend.
///
/// "Already added" is decided by comparing this pair against a
/// publication's author and `d`-tag, so an un-normalized value silently
/// fails to match and mints another copy — the exact duplicate the field
/// exists to prevent. Owner pubkey: 64 hex, any case in, lowercase out
/// (same contract as [`super::validate_respond_to_allowlist`]). Persona id: the
/// publication's `d`-tag, trimmed and required.
pub fn normalized(self) -> Result<Self, String> {
let owner_pubkey = self.owner_pubkey.trim().to_ascii_lowercase();
if owner_pubkey.len() != 64 || !owner_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!(
"invalid catalog source owner pubkey: '{owner_pubkey}' (must be 64 hex chars)"
));
}
let persona_id = self.persona_id.trim().to_string();
if persona_id.is_empty() {
return Err("catalog source persona id is required".to_string());
}
Ok(Self {
owner_pubkey,
persona_id,
})
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,62 @@
use super::CatalogSource;
fn source(owner_pubkey: &str, persona_id: &str) -> CatalogSource {
CatalogSource {
owner_pubkey: owner_pubkey.to_string(),
persona_id: persona_id.to_string(),
}
}
#[test]
fn normalized_lowercases_and_trims_the_owner_pubkey() {
// "Already added" compares this against a publication's author hex, which
// is always lowercase — a mixed-case value from the UI must not miss.
let normalized = source(&format!(" {} ", "A".repeat(64)), " helper ")
.normalized()
.expect("64 hex chars with surrounding space is valid");
assert_eq!(normalized.owner_pubkey, "a".repeat(64));
assert_eq!(normalized.persona_id, "helper");
}
#[test]
fn normalized_rejects_a_short_owner_pubkey() {
let err = source("abc123", "helper").normalized().unwrap_err();
assert!(err.contains("64 hex"), "error must name the rule: {err}");
}
#[test]
fn normalized_rejects_a_non_hex_owner_pubkey() {
let err = source(&"z".repeat(64), "helper").normalized().unwrap_err();
assert!(err.contains("64 hex"), "error must name the rule: {err}");
}
#[test]
fn normalized_rejects_a_blank_persona_id() {
let err = source(&"a".repeat(64), " ").normalized().unwrap_err();
assert!(
err.contains("persona id"),
"error must name the field: {err}"
);
}
#[test]
fn deserializes_the_camel_case_payload_the_frontend_sends() {
// `rename_all` on CreatePersonaRequest does not recurse into this struct,
// so without the aliases the copy request fails at the Tauri boundary.
let parsed: CatalogSource =
serde_json::from_str(r#"{"ownerPubkey":"abc","personaId":"helper"}"#)
.expect("camelCase payload from TS should deserialize");
assert_eq!(parsed, source("abc", "helper"));
}
#[test]
fn round_trips_persisted_snake_case() {
let value = source(&"a".repeat(64), "helper");
let json = serde_json::to_string(&value).unwrap();
assert!(json.contains("owner_pubkey"), "persisted shape: {json}");
assert_eq!(
serde_json::from_str::<CatalogSource>(&json).unwrap(),
value,
"the camelCase alias must not break the stored-record round trip"
);
}
@@ -7,7 +7,7 @@ use serde::Deserialize;
use super::{
default_start_on_app_launch, validate_respond_to_allowlist, AgentDefinition, BackendKind,
RelayMeshConfig, RespondTo,
CatalogSource, RelayMeshConfig, RespondTo,
};
/// The NIP-AP behavioral group as one grouped request field.
@@ -91,6 +91,10 @@ pub struct CreatePersonaRequest {
/// NIP-AP behavioral group. Absent = behavior group stays unset.
#[serde(default)]
pub behavior: Option<PersonaBehaviorRequest>,
/// Set when this persona is a copy of another owner's shared catalog entry,
/// so the catalog can tell an already-added foreign persona from a new one.
#[serde(default)]
pub catalog_source: Option<CatalogSource>,
}
#[derive(Debug, Deserialize)]
@@ -278,6 +282,7 @@ mod tests {
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -429,4 +434,37 @@ mod tests {
.unwrap();
assert_eq!(record.parallelism, Some(8));
}
/// The catalog copy path is the only caller that sends this field, and it
/// sends camelCase from TS. Without it deserializing, the copy silently
/// lands with no provenance and duplicate-add returns.
#[test]
fn create_request_deserializes_camel_case_catalog_source() {
let request: CreatePersonaRequest = serde_json::from_str(
r#"{
"displayName": "Copy",
"avatarUrl": null,
"systemPrompt": "Prompt",
"catalogSource": { "ownerPubkey": "abc", "personaId": "helper" }
}"#,
)
.expect("camelCase catalogSource payload from TS should deserialize");
assert_eq!(
request.catalog_source,
Some(CatalogSource {
owner_pubkey: "abc".to_string(),
persona_id: "helper".to_string(),
})
);
}
/// Ordinary agent creation never sends the field.
#[test]
fn create_request_without_catalog_source_is_not_a_catalog_copy() {
let request: CreatePersonaRequest = serde_json::from_str(
r#"{ "displayName": "Fresh", "avatarUrl": null, "systemPrompt": "Prompt" }"#,
)
.expect("a create payload without provenance should deserialize");
assert_eq!(request.catalog_source, None);
}
}
@@ -1,4 +1,4 @@
use super::{AgentDefinition, ManagedAgentRecord};
use super::{AgentDefinition, CatalogSource, ManagedAgentRecord};
use std::path::PathBuf;
#[test]
@@ -485,6 +485,7 @@ fn sample_persona() -> AgentDefinition {
shared: false,
source_team: Some("team-1".to_string()),
source_team_persona_slug: Some("helper".to_string()),
catalog_source: None,
env_vars: [("K".to_string(), "v".to_string())].into_iter().collect(),
respond_to: None,
respond_to_allowlist: Vec::new(),
@@ -494,6 +495,49 @@ fn sample_persona() -> AgentDefinition {
}
}
#[test]
fn persona_record_without_catalog_source_deserializes_and_omits_it() {
// Every persona already on disk predates the field — an old record must
// load as "not a catalog copy" and must not gain a null key on save.
let record: AgentDefinition = serde_json::from_str(
r#"{
"id": "persona-1",
"display_name": "Test",
"avatar_url": null,
"system_prompt": "Prompt",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}"#,
)
.expect("pre-catalog-source persona should deserialize");
assert_eq!(record.catalog_source, None);
let json = serde_json::to_string(&record).unwrap();
assert!(
!json.contains("catalog_source"),
"absent provenance must stay absent on disk: {json}"
);
}
#[test]
fn persona_catalog_source_survives_the_agent_store_fold() {
// Provenance is only useful if it is still there on the next launch, and
// `save_personas` funnels every definition through `into_agent_record`.
let mut persona = sample_persona();
persona.catalog_source = Some(CatalogSource {
owner_pubkey: "a".repeat(64),
persona_id: "helper".to_string(),
});
let view = persona
.clone()
.into_agent_record()
.to_definition_view()
.expect("slugged record must present a persona view");
assert_eq!(view.catalog_source, persona.catalog_source);
}
#[test]
fn persona_into_agent_record_is_keyless_and_slugged() {
let record = sample_persona().into_agent_record();
@@ -412,6 +412,7 @@ mod tests {
is_active: true,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: std::collections::BTreeMap::from([
("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()),
(
@@ -38,6 +38,7 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: Default::default(),
respond_to: None,
respond_to_allowlist: Vec::new(),