Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions api/operations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion crates/iris-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
1 change: 1 addition & 0 deletions crates/iris-cli/src/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions crates/iris-core/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// The provider-specific identifier (e.g. phone number, username, user ID).
pub source_id: String,
/// Human-readable display name, if available.
Expand Down Expand Up @@ -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<String>,
/// The provider-specific thread ID.
pub source_id: String,
/// Human-readable thread title (contact name, group name, etc.).
Expand Down
3 changes: 3 additions & 0 deletions crates/iris-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down
54 changes: 50 additions & 4 deletions crates/iris-providers/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,14 @@ impl MessageProvider for InstanceProvider {
}

async fn list_threads(&self, limit: Option<u32>) -> IrisResult<Vec<Thread>> {
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(
Expand All @@ -503,7 +510,11 @@ impl MessageProvider for InstanceProvider {
}

async fn list_contacts(&self, limit: Option<u32>) -> IrisResult<Vec<Contact>> {
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(
Expand Down Expand Up @@ -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<iris_core::AuditEntry> {
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,
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions crates/iris-providers/src/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/iris-providers/src/herdr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions crates/iris-providers/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/iris-providers/src/sms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/iris-providers/src/telegram/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions crates/iris-providers/src/telegram/realtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -742,7 +742,7 @@ enum Classified {
/// A normalizable message plus source-normalized query data and fixed audit metadata.
Message {
message: Box<Message>,
thread: Thread,
thread: Box<Thread>,
contacts: Vec<Contact>,
metadata: RealtimeAuditMetadata,
},
Expand Down Expand Up @@ -958,7 +958,7 @@ impl TelegramProvider {
);
Ok(Classified::Message {
message: Box::new(normalized),
thread,
thread: Box::new(thread),
contacts,
metadata,
})
Expand Down
28 changes: 13 additions & 15 deletions crates/iris-server/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
}
Expand Down Expand Up @@ -201,7 +204,8 @@ async fn list_providers(State(state): State<AppState>) -> Json<Vec<ProviderRespo
.map(|p| {
let m = p.metadata();
ProviderResponse {
id: m.id,
id: p.id().to_owned(),
provider_type: m.id,
name: m.name,
capabilities: m
.capabilities
Expand Down Expand Up @@ -403,19 +407,11 @@ async fn send_message(
attachments,
};
let provider = match provider_id.as_deref() {
Some(provider_id) => {
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
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/iris-server/tests/sse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions generated/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<String>,
/// Attachments to send; each item is inline bytes (`mime_type` + `data_base64`, optional `filename`) or a stored `iris://attachment` reference (`stored_id`).
Expand Down
Loading
Loading