From 5f956282e86e18969396ee57fb2d6740af040b1f Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Fri, 4 Sep 2026 20:31:52 -0600 Subject: [PATCH] feat: expose configured provider instances --- api/operations.yaml | 6 +-- crates/iris-cli/src/commands.rs | 3 +- crates/iris-cli/src/watch.rs | 1 + crates/iris-core/src/model.rs | 12 ++++- crates/iris-mcp/src/lib.rs | 3 ++ crates/iris-providers/src/config.rs | 54 +++++++++++++++++-- crates/iris-providers/src/email.rs | 2 + crates/iris-providers/src/herdr/mod.rs | 2 + crates/iris-providers/src/mock.rs | 4 ++ crates/iris-providers/src/sms.rs | 3 ++ crates/iris-providers/src/telegram/mod.rs | 3 ++ .../iris-providers/src/telegram/realtime.rs | 6 +-- crates/iris-server/src/routes.rs | 28 +++++----- crates/iris-server/tests/sse.rs | 1 + generated/cli.rs | 6 +-- generated/mcp.json | 6 +-- 16 files changed, 106 insertions(+), 34 deletions(-) diff --git a/api/operations.yaml b/api/operations.yaml index 57849ac..6e8629a 100644 --- a/api/operations.yaml +++ b/api/operations.yaml @@ -23,7 +23,7 @@ operations: required: false location: query - name: list_threads - description: List conversation threads across providers. + description: List conversation threads across configured provider instances. Each result includes `provider_instance`, the exact configured instance that owns it. method: GET path: /threads read: true @@ -40,7 +40,7 @@ operations: required: false location: query - name: list_contacts - description: List contacts across providers. + description: List contacts across configured provider instances. Each result includes `provider_instance`, the exact configured instance that owns it. method: GET path: /contacts read: true @@ -74,7 +74,7 @@ operations: required: true location: body - name: provider - description: Provider ID to route the send through when ownership is ambiguous. + description: Configured provider instance ID to route the send through when ownership is ambiguous. Discover exact IDs via `GET /providers`; an explicit instance is authoritative. type: string required: false location: body diff --git a/crates/iris-cli/src/commands.rs b/crates/iris-cli/src/commands.rs index 5d1c5aa..9ea4ed9 100644 --- a/crates/iris-cli/src/commands.rs +++ b/crates/iris-cli/src/commands.rs @@ -317,10 +317,11 @@ async fn query_audit_entries( .map_err(Into::into) } +/// List registered configured provider instances, including their static type. pub fn list_providers() -> anyhow::Result<()> { for provider in get_providers(&attachment_store())? { let meta = provider.metadata(); - println!(" {} — {}", meta.id, meta.name); + println!(" {} ({}) — {}", provider.id(), meta.id, meta.name); } Ok(()) } diff --git a/crates/iris-cli/src/watch.rs b/crates/iris-cli/src/watch.rs index 94ed83f..d7adf25 100644 --- a/crates/iris-cli/src/watch.rs +++ b/crates/iris-cli/src/watch.rs @@ -620,6 +620,7 @@ mod tests { sender: Contact { id: uuid::Uuid::new_v4(), source: "fake".into(), + provider_instance: None, source_id: "sender-1".into(), display_name: Some("Sender".into()), avatar_url: None, diff --git a/crates/iris-core/src/model.rs b/crates/iris-core/src/model.rs index 241a3c9..77b6685 100644 --- a/crates/iris-core/src/model.rs +++ b/crates/iris-core/src/model.rs @@ -12,8 +12,12 @@ use uuid::Uuid; pub struct Contact { /// Globally unique Iris contact ID (assigned by Iris, not the source). pub id: Uuid, - /// The provider this contact originated from. + /// The provider type this contact originated from. pub source: String, + /// Configured provider instance that owns this contact. `None` is accepted + /// when decoding older persisted data; public aggregators populate it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_instance: Option, /// The provider-specific identifier (e.g. phone number, username, user ID). pub source_id: String, /// Human-readable display name, if available. @@ -110,8 +114,12 @@ pub struct Attachment { pub struct Thread { /// Globally unique Iris thread ID. pub id: Uuid, - /// The provider this thread originated from. + /// The provider type this thread originated from. pub source: String, + /// Configured provider instance that owns this thread. `None` is accepted + /// when decoding older persisted data; public aggregators populate it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_instance: Option, /// The provider-specific thread ID. pub source_id: String, /// Human-readable thread title (contact name, group name, etc.). diff --git a/crates/iris-mcp/src/lib.rs b/crates/iris-mcp/src/lib.rs index 5f1c17c..32da1c9 100644 --- a/crates/iris-mcp/src/lib.rs +++ b/crates/iris-mcp/src/lib.rs @@ -182,6 +182,9 @@ impl McpServer { body: args.body, attachments, }; + // An explicit configured instance is authoritative: callers discover it + // from the provider listing and Iris dispatches to that exact instance. + // Without one, resolve ownership from the thread. let provider = match args.provider.as_deref() { Some(provider_id) => self.provider_by_id(provider_id)?, None => self.provider_for_thread(&args.thread_id).await?, diff --git a/crates/iris-providers/src/config.rs b/crates/iris-providers/src/config.rs index beb89d7..4994a59 100644 --- a/crates/iris-providers/src/config.rs +++ b/crates/iris-providers/src/config.rs @@ -490,7 +490,14 @@ impl MessageProvider for InstanceProvider { } async fn list_threads(&self, limit: Option) -> IrisResult> { - self.inner.list_threads(limit).await + let mut threads = self.inner.list_threads(limit).await?; + for thread in &mut threads { + thread.provider_instance = Some(self.id.clone()); + for contact in &mut thread.participants { + contact.provider_instance = Some(self.id.clone()); + } + } + Ok(threads) } async fn list_messages( @@ -503,7 +510,11 @@ impl MessageProvider for InstanceProvider { } async fn list_contacts(&self, limit: Option) -> IrisResult> { - self.inner.list_contacts(limit).await + let mut contacts = self.inner.list_contacts(limit).await?; + for contact in &mut contacts { + contact.provider_instance = Some(self.id.clone()); + } + Ok(contacts) } async fn send_message( @@ -569,9 +580,14 @@ mod tests { impl AuditLog for NullAudit { async fn record( &self, - _event: iris_core::AuditEvent, + event: iris_core::AuditEvent, ) -> iris_core::Result { - unreachable!("config tests never record audit events") + Ok(iris_core::AuditEntry { + id: uuid::Uuid::nil(), + event, + prev_hash: None, + self_hash: "test".into(), + }) } async fn record_once( &self, @@ -788,6 +804,36 @@ from = "support@example.com" ); } + #[tokio::test] + async fn named_mock_instances_attribute_threads_and_contacts() { + let config = IrisConfig::from_toml( + r" +[providers.mock.instances.alpha] +[providers.mock.instances.beta] +", + ) + .expect("valid config"); + let providers = + providers_from_config(&config, &test_store(), &test_audit()).expect("registry builds"); + assert_eq!(providers.len(), 2); + for provider in providers { + let instance = provider.id().to_owned(); + let threads = provider.list_threads(Some(1)).await.expect("list threads"); + let contacts = provider + .list_contacts(Some(1)) + .await + .expect("list contacts"); + assert_eq!( + threads[0].provider_instance.as_deref(), + Some(instance.as_str()) + ); + assert_eq!( + contacts[0].provider_instance.as_deref(), + Some(instance.as_str()) + ); + } + } + #[test] fn named_env_selection_disables_default_and_unselected_instances() { let _guard = env_lock().lock().expect("environment lock"); diff --git a/crates/iris-providers/src/email.rs b/crates/iris-providers/src/email.rs index 915faab..09f411f 100644 --- a/crates/iris-providers/src/email.rs +++ b/crates/iris-providers/src/email.rs @@ -674,6 +674,7 @@ impl EmailEnvelope { Thread { id: self.thread_id(), source: PROVIDER_ID.into(), + provider_instance: None, source_id: self.thread_key(), title: self.subject.clone(), participants, @@ -784,6 +785,7 @@ impl EmailAddress { Contact { id: uuid_for(format!("contact:{}", self.address.to_lowercase()).as_bytes()), source: PROVIDER_ID.into(), + provider_instance: None, source_id: self.address.clone(), display_name: self.name.clone(), avatar_url: None, diff --git a/crates/iris-providers/src/herdr/mod.rs b/crates/iris-providers/src/herdr/mod.rs index 504355d..76c487e 100644 --- a/crates/iris-providers/src/herdr/mod.rs +++ b/crates/iris-providers/src/herdr/mod.rs @@ -333,6 +333,7 @@ fn thread_with_metadata( Thread { id: thread_uuid(id), source: SOURCE.to_owned(), + provider_instance: None, source_id: id.to_owned(), title: title.map(ToOwned::to_owned), participants: Vec::new(), @@ -347,6 +348,7 @@ fn agent_contact(agent: &str, host: &str) -> Contact { Contact { id: contact_uuid(&source_id), source: SOURCE.to_owned(), + provider_instance: None, source_id, display_name: Some(agent.to_owned()), avatar_url: None, diff --git a/crates/iris-providers/src/mock.rs b/crates/iris-providers/src/mock.rs index f98663e..dd5b5fa 100644 --- a/crates/iris-providers/src/mock.rs +++ b/crates/iris-providers/src/mock.rs @@ -122,6 +122,7 @@ impl MessageProvider for MockProvider { let threads = vec![Thread { id: Uuid::new_v4(), source: "mock".into(), + provider_instance: None, source_id: "thread-1".into(), title: Some("Test Conversation".into()), participants: vec![], @@ -156,6 +157,7 @@ impl MessageProvider for MockProvider { sender: Contact { id: Uuid::new_v4(), source: "mock".into(), + provider_instance: None, source_id: "user-1".into(), display_name: Some("Test User".into()), avatar_url: None, @@ -185,6 +187,7 @@ impl MessageProvider for MockProvider { let contacts = vec![Contact { id: Uuid::new_v4(), source: "mock".into(), + provider_instance: None, source_id: "user-1".into(), display_name: Some("Test User".into()), avatar_url: None, @@ -210,6 +213,7 @@ impl MessageProvider for MockProvider { sender: Contact { id: Uuid::new_v4(), source: "mock".into(), + provider_instance: None, source_id: "self".into(), display_name: Some("You".into()), avatar_url: None, diff --git a/crates/iris-providers/src/sms.rs b/crates/iris-providers/src/sms.rs index 8f96f53..e76f842 100644 --- a/crates/iris-providers/src/sms.rs +++ b/crates/iris-providers/src/sms.rs @@ -353,6 +353,7 @@ impl TermuxSmsRecord { Thread { id: thread_uuid(&key), source: PROVIDER_ID.into(), + provider_instance: None, source_id: key, title: Some(self.address.clone()), participants, @@ -370,6 +371,7 @@ impl TermuxSmsRecord { Contact { id: contact_uuid(&address), source: PROVIDER_ID.into(), + provider_instance: None, source_id: address, display_name: Some(self.address.clone()), avatar_url: None, @@ -489,6 +491,7 @@ fn self_contact(self_number: Option<&str>) -> Contact { Contact { id: contact_uuid(&source_id), source: PROVIDER_ID.into(), + provider_instance: None, source_id, display_name: Some("Me".into()), avatar_url: None, diff --git a/crates/iris-providers/src/telegram/mod.rs b/crates/iris-providers/src/telegram/mod.rs index efc3ac6..3bd1aaa 100644 --- a/crates/iris-providers/src/telegram/mod.rs +++ b/crates/iris-providers/src/telegram/mod.rs @@ -762,6 +762,7 @@ impl TelegramMessage { Thread { id: thread_uuid(self.chat.id), source: PROVIDER_ID.into(), + provider_instance: None, source_id: self.chat.id.to_string(), title: self.chat.title(), participants, @@ -795,6 +796,7 @@ impl TelegramMessage { || Contact { id: contact_uuid(format!("chat:{}", self.chat.id).as_bytes()), source: PROVIDER_ID.into(), + provider_instance: None, source_id: self.chat.id.to_string(), display_name: self.chat.title(), avatar_url: None, @@ -921,6 +923,7 @@ impl TelegramUser { Contact { id: contact_uuid(format!("user:{}", self.id).as_bytes()), source: PROVIDER_ID.into(), + provider_instance: None, source_id: self.id.to_string(), display_name: Some(self.display_name()), avatar_url: None, diff --git a/crates/iris-providers/src/telegram/realtime.rs b/crates/iris-providers/src/telegram/realtime.rs index 2e504c6..1b5804d 100644 --- a/crates/iris-providers/src/telegram/realtime.rs +++ b/crates/iris-providers/src/telegram/realtime.rs @@ -635,7 +635,7 @@ impl RealtimeHub { .await .map_err(ProcessError::Terminal)?; self.fan_out(snapshot, &message); - self.retain_event(update_id, message.as_ref(), thread, contacts); + self.retain_event(update_id, message.as_ref(), *thread, contacts); self.cursor.store(update_id + 1, Ordering::SeqCst); } Classified::Ignored(metadata) => { @@ -742,7 +742,7 @@ enum Classified { /// A normalizable message plus source-normalized query data and fixed audit metadata. Message { message: Box, - thread: Thread, + thread: Box, contacts: Vec, metadata: RealtimeAuditMetadata, }, @@ -958,7 +958,7 @@ impl TelegramProvider { ); Ok(Classified::Message { message: Box::new(normalized), - thread, + thread: Box::new(thread), contacts, metadata, }) diff --git a/crates/iris-server/src/routes.rs b/crates/iris-server/src/routes.rs index 3e691c2..4d0beb4 100644 --- a/crates/iris-server/src/routes.rs +++ b/crates/iris-server/src/routes.rs @@ -60,7 +60,10 @@ pub struct ErrorResponse { #[derive(Debug, Serialize)] pub struct ProviderResponse { - pub id: &'static str, + /// Configured provider instance id (for example `email.ops-codefold`). + pub id: String, + /// Static provider type (for example `email`). + pub provider_type: &'static str, pub name: &'static str, pub capabilities: Vec<&'static str>, } @@ -201,7 +204,8 @@ async fn list_providers(State(state): State) -> Json { - let provider = provider_by_id(state, provider_id)?; - let owner = provider_for_thread(state, &thread_id).await?; - if provider.id() != owner.id() { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: format!("provider '{provider_id}' does not own thread {thread_id}"), - }), - )); - } - provider - } + // A configured instance id is an explicit routing directive. It is + // intentionally authoritative even when two same-type instances expose + // colliding source thread IDs; without it, ownership discovery remains + // the deterministic fallback. + Some(provider_id) => provider_by_id(state, provider_id)?, None => provider_for_thread(state, &thread_id).await?, }; let message = provider @@ -961,6 +957,7 @@ mod tests { Thread { id, source: source.to_string(), + provider_instance: None, source_id: format!("{source}-{id}"), title: Some(source.to_string()), participants: Vec::new(), @@ -974,6 +971,7 @@ mod tests { Contact { id: Uuid::new_v4(), source: source.to_string(), + provider_instance: None, source_id: source_id.to_string(), display_name: Some(name.to_string()), avatar_url: None, diff --git a/crates/iris-server/tests/sse.rs b/crates/iris-server/tests/sse.rs index 8ed45ac..cbaca5f 100644 --- a/crates/iris-server/tests/sse.rs +++ b/crates/iris-server/tests/sse.rs @@ -264,6 +264,7 @@ fn sample_message(thread: &str, body: &str) -> Message { sender: Contact { id: uuid::Uuid::new_v4(), source: "fake".into(), + provider_instance: None, source_id: "sender-1".into(), display_name: Some("Sender".into()), avatar_url: None, diff --git a/generated/cli.rs b/generated/cli.rs index 7d06058..d845ae6 100644 --- a/generated/cli.rs +++ b/generated/cli.rs @@ -8,9 +8,9 @@ use serde::{Deserialize, Serialize}; pub enum GeneratedCommand { /// List normalized messages in a thread. ListMessages(ListMessagesArgs), - /// List conversation threads across providers. + /// List conversation threads across configured provider instances. Each result includes `provider_instance`, the exact configured instance that owns it. ListThreads(ListThreadsArgs), - /// List contacts across providers. + /// List contacts across configured provider instances. Each result includes `provider_instance`, the exact configured instance that owns it. ListContacts(ListContactsArgs), /// Send a message to a thread through the owning provider. SendMessage(SendMessageArgs), @@ -87,7 +87,7 @@ pub struct SendMessageArgs { /// Message body to send. #[arg(long)] pub body: String, - /// Provider ID to route the send through when ownership is ambiguous. + /// Configured provider instance ID to route the send through when ownership is ambiguous. Discover exact IDs via `GET /providers`; an explicit instance is authoritative. #[arg(long)] pub provider: Option, /// Attachments to send; each item is inline bytes (`mime_type` + `data_base64`, optional `filename`) or a stored `iris://attachment` reference (`stored_id`). diff --git a/generated/mcp.json b/generated/mcp.json index 958f43c..18484cc 100644 --- a/generated/mcp.json +++ b/generated/mcp.json @@ -58,7 +58,7 @@ "name": "list_messages" }, { - "description": "List conversation threads across providers.", + "description": "List conversation threads across configured provider instances. Each result includes `provider_instance`, the exact configured instance that owns it.", "inputSchema": { "additionalProperties": false, "properties": { @@ -77,7 +77,7 @@ "name": "list_threads" }, { - "description": "List contacts across providers.", + "description": "List contacts across configured provider instances. Each result includes `provider_instance`, the exact configured instance that owns it.", "inputSchema": { "additionalProperties": false, "properties": { @@ -144,7 +144,7 @@ "type": "string" }, "provider": { - "description": "Provider ID to route the send through when ownership is ambiguous.", + "description": "Configured provider instance ID to route the send through when ownership is ambiguous. Discover exact IDs via `GET /providers`; an explicit instance is authoritative.", "type": "string" }, "thread_id": {