use serde::{Deserialize, Serialize};
use super::sealed::SealedBlobV1;
use super::{require_schema, validate_id, validate_text};
use crate::{UtcTimestamp, ValidationError};
pub const MESSAGE_LIST_SCHEMA: &str = "leanctx.hive.message-list/v1";
pub const SEALED_MESSAGE_SCHEMA: &str = "leanctx.hive.sealed-message/v1";
pub const MAX_MESSAGE_TEXT_CHARS: usize = 4000;
pub const MAX_MESSAGE_REFERENCES: usize = 16;
pub const MAX_MESSAGES_PAGE: usize = 200;
pub const MAX_QUESTION_DEADLINE_HOURS: i64 = 7 * 24;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageType {
Question,
Offer,
Handoff,
Answer,
Finding,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetworkKind {
Org,
Swarm,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum NetworkV1 {
Org { channel: String },
Swarm { swarm_id: String },
}
impl NetworkV1 {
pub fn kind(&self) -> NetworkKind {
match self {
Self::Org { .. } => NetworkKind::Org,
Self::Swarm { .. } => NetworkKind::Swarm,
}
}
pub fn validate(&self) -> Result<(), ValidationError> {
match self {
Self::Org { channel } => super::bus::validate_channel(channel),
Self::Swarm { swarm_id } => validate_id(swarm_id, "swarm_id"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum MessageReferenceV1 {
Task {
number: u64,
},
Fact {
id: String,
},
Proposal {
number: u64,
},
Commit {
id: String,
},
Handoff {
handoff_id: String,
},
}
impl MessageReferenceV1 {
fn validate(&self) -> Result<(), ValidationError> {
match self {
Self::Task { number } | Self::Proposal { number } if *number == 0 => {
Err(ValidationError::new("numbers start at 1"))
}
Self::Task { .. } | Self::Proposal { .. } => Ok(()),
Self::Fact { id } => validate_id(id, "reference id"),
Self::Handoff { handoff_id } => validate_id(handoff_id, "handoff_id"),
Self::Commit { id } => {
if (7..=64).contains(&id.len())
&& id
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
Ok(())
} else {
Err(ValidationError::new("commit references are 7-64 hex"))
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MessageBodyV1 {
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub references: Vec<MessageReferenceV1>,
}
impl MessageBodyV1 {
pub fn validate(&self, network: NetworkKind) -> Result<(), ValidationError> {
validate_text(&self.text, MAX_MESSAGE_TEXT_CHARS, "text", true)?;
if self.references.len() > MAX_MESSAGE_REFERENCES {
return Err(ValidationError::new("too many references"));
}
for reference in &self.references {
reference.validate()?;
if network == NetworkKind::Swarm && matches!(reference, MessageReferenceV1::Task { .. })
{
return Err(ValidationError::new(
"Swarm messages do not reference org tasks",
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentMessageV1 {
pub client_msg_id: String,
pub network: NetworkV1,
#[serde(rename = "type")]
pub message_type: MessageType,
pub from: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to_instance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline: Option<UtcTimestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<MessageBodyV1>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sealed: Option<SealedBlobV1>,
}
impl AgentMessageV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
validate_id(&self.client_msg_id, "client_msg_id")?;
self.network.validate()?;
validate_id(&self.from, "from")?;
if let Some(to) = &self.to {
validate_id(to, "to")?;
}
if let Some(instance) = &self.to_instance {
validate_id(instance, "to_instance")?;
if self.to.is_none() || self.network.kind() != NetworkKind::Org {
return Err(ValidationError::new(
"to_instance names the instance of `to` on an org network",
));
}
}
if let Some(reply_to) = &self.reply_to {
validate_id(reply_to, "reply_to")?;
}
match self.message_type {
MessageType::Unknown => return Err(ValidationError::new("unknown message type")),
MessageType::Answer if self.reply_to.is_none() => {
return Err(ValidationError::new("an answer replies to a question"));
}
MessageType::Question => {}
_ if self.deadline.is_some() => {
return Err(ValidationError::new("only questions carry a deadline"));
}
_ => {}
}
if let Some(deadline) = &self.deadline {
super::parse_time(deadline)?;
}
match (&self.body, &self.sealed) {
(Some(body), None) => body.validate(self.network.kind()),
(None, Some(sealed)) => {
if self.network.kind() == NetworkKind::Swarm {
return Err(ValidationError::new(
"swarm messages are public to the swarm's workers",
));
}
sealed.validate()?;
if sealed.content_schema == SEALED_MESSAGE_SCHEMA {
Ok(())
} else {
Err(ValidationError::new(format!(
"sealed content must be {SEALED_MESSAGE_SCHEMA}"
)))
}
}
_ => Err(ValidationError::new("a message carries body or sealed")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageTrust {
Team,
Untrusted,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SenderV1 {
Agent {
agent: String,
instance_label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
instance_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
mine: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
profile_id: Option<String>,
},
Person { label: String },
Swarm { handle: String, reputation: u64 },
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageDeliveryV1 {
pub gid: String,
pub seq: u64,
pub network: NetworkV1,
#[serde(rename = "type")]
pub message_type: MessageType,
pub from: SenderV1,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
pub thread_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline: Option<UtcTimestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<MessageBodyV1>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sealed: Option<SealedBlobV1>,
pub trust: MessageTrust,
pub server_time: UtcTimestamp,
pub expires_at: UtcTimestamp,
}
impl MessageDeliveryV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
validate_id(&self.gid, "gid")?;
validate_id(&self.thread_id, "thread_id")?;
self.network.validate()?;
if self.network.kind() == NetworkKind::Swarm {
if self.trust != MessageTrust::Untrusted {
return Err(ValidationError::new("Swarm content is always untrusted"));
}
if !matches!(self.from, SenderV1::Swarm { .. }) {
return Err(ValidationError::new(
"Swarm senders appear only as a handle",
));
}
} else if matches!(self.from, SenderV1::Swarm { .. }) {
return Err(ValidationError::new("org messages have org senders"));
}
if self.body.is_some() == self.sealed.is_some() {
return Err(ValidationError::new("a message carries body or sealed"));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageReceiptState {
Delivered,
Read,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MessageReceiptV1 {
pub gid: String,
pub by: String,
pub state: MessageReceiptState,
}
impl MessageReceiptV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
validate_id(&self.gid, "gid")?;
validate_id(&self.by, "by")?;
if self.state == MessageReceiptState::Unknown {
return Err(ValidationError::new("unknown receipt state"));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageStoredV1 {
pub client_msg_id: String,
pub gid: String,
pub thread_id: String,
pub network: NetworkV1,
pub seq: u64,
pub server_time: UtcTimestamp,
pub expires_at: UtcTimestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageStatusV1 {
pub gid: String,
pub client_msg_id: String,
pub by: String,
pub state: MessageReceiptState,
pub server_time: UtcTimestamp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskEventKind {
Proposed,
Opened,
Claimed,
Released,
Submitted,
Decided,
Cancelled,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskEventV1 {
pub channel: String,
pub seq: u64,
pub number: u64,
pub event: TaskEventKind,
pub state: super::tasks::TaskState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<super::tasks::TaskActorV1>,
pub server_time: UtcTimestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageListV1 {
pub schema: String,
pub messages: Vec<MessageDeliveryV1>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next: Option<String>,
}
impl MessageListV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, MESSAGE_LIST_SCHEMA)?;
if self.messages.len() > MAX_MESSAGES_PAGE {
return Err(ValidationError::new("too many messages in one page"));
}
self.messages
.iter()
.try_for_each(MessageDeliveryV1::validate)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn question() -> AgentMessageV1 {
AgentMessageV1 {
client_msg_id: "01JBMSG".into(),
network: NetworkV1::Org {
channel: "org:org_acme/vault:3f6c1e9a0b7d4c2e8f5a1b6c9d0e7f42/general".into(),
},
message_type: MessageType::Question,
from: "claude-3e".into(),
to: None,
to_instance: None,
reply_to: None,
deadline: None,
body: Some(MessageBodyV1 {
text: "Which retry budget does payments use?".into(),
references: Vec::new(),
}),
sealed: None,
}
}
#[test]
fn answers_reply_and_only_questions_have_deadlines() {
assert!(question().validate().is_ok());
let mut answer = question();
answer.message_type = MessageType::Answer;
assert!(answer.validate().is_err(), "an answer needs reply_to");
answer.reply_to = Some("g-1".into());
assert!(answer.validate().is_ok());
answer.deadline = Some(UtcTimestamp::new("2026-10-21T12:00:00Z").unwrap());
assert!(answer.validate().is_err());
}
#[test]
fn swarm_messages_are_public_and_never_reference_org_tasks() {
let mut swarm = question();
swarm.network = NetworkV1::Swarm {
swarm_id: "sw-1".into(),
};
assert!(swarm.validate().is_ok());
swarm.body.as_mut().unwrap().references = vec![MessageReferenceV1::Task { number: 7 }];
assert!(swarm.validate().is_err());
}
#[test]
fn the_type_is_mandatory() {
let raw = serde_json::json!({
"client_msg_id": "01JBMSG", "network": {"kind": "org", "channel": "org:o/vault:3f6c1e9a0b7d4c2e8f5a1b6c9d0e7f42/g"},
"from": "a", "body": {"text": "hi"},
});
assert!(serde_json::from_value::<AgentMessageV1>(raw).is_err());
}
#[test]
fn swarm_deliveries_are_untrusted_and_show_only_a_handle() {
let delivery = |from: SenderV1, trust| MessageDeliveryV1 {
gid: "g-1".into(),
seq: 1,
network: NetworkV1::Swarm {
swarm_id: "sw-1".into(),
},
message_type: MessageType::Finding,
from,
to: None,
thread_id: "g-1".into(),
reply_to: None,
deadline: None,
body: Some(MessageBodyV1 {
text: "found".into(),
references: Vec::new(),
}),
sealed: None,
trust,
server_time: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
expires_at: UtcTimestamp::new("2026-11-20T12:00:00Z").unwrap(),
};
let handle = SenderV1::Swarm {
handle: "quiet-heron".into(),
reputation: 1,
};
assert!(
delivery(handle.clone(), MessageTrust::Untrusted)
.validate()
.is_ok()
);
assert!(delivery(handle, MessageTrust::Team).validate().is_err());
let agent = SenderV1::Agent {
agent: "codex-85".into(),
instance_label: "ci".into(),
instance_id: Some("inst_01jb8z9".into()),
owner: Some("Ben".into()),
model: None,
mine: false,
profile_id: None,
};
assert!(
delivery(agent, MessageTrust::Untrusted).validate().is_err(),
"no org identity in Swarm"
);
}
}