1use serde::{Deserialize, Serialize};
52
53use super::sealed::SealedBlobV1;
54use super::{require_schema, validate_id, validate_text};
55use crate::{UtcTimestamp, ValidationError};
56
57pub const MESSAGE_LIST_SCHEMA: &str = "leanctx.hive.message-list/v1";
58pub const SEALED_MESSAGE_SCHEMA: &str = "leanctx.hive.sealed-message/v1";
60
61pub const MAX_MESSAGE_TEXT_CHARS: usize = 4000;
62pub const MAX_MESSAGE_REFERENCES: usize = 16;
63pub const MAX_MESSAGES_PAGE: usize = 200;
64pub const MAX_QUESTION_DEADLINE_HOURS: i64 = 7 * 24;
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum MessageType {
73 Question,
76 Offer,
78 Handoff,
80 Answer,
82 Finding,
84 #[serde(other)]
85 Unknown,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum NetworkKind {
92 Org,
93 Swarm,
94 #[serde(other)]
95 Unknown,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
101pub enum NetworkV1 {
102 Org { channel: String },
103 Swarm { swarm_id: String },
104}
105
106impl NetworkV1 {
107 pub fn kind(&self) -> NetworkKind {
108 match self {
109 Self::Org { .. } => NetworkKind::Org,
110 Self::Swarm { .. } => NetworkKind::Swarm,
111 }
112 }
113
114 pub fn validate(&self) -> Result<(), ValidationError> {
115 match self {
116 Self::Org { channel } => super::bus::validate_channel(channel),
117 Self::Swarm { swarm_id } => validate_id(swarm_id, "swarm_id"),
118 }
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
125pub enum MessageReferenceV1 {
126 Task {
128 number: u64,
129 },
130 Fact {
131 id: String,
132 },
133 Proposal {
134 number: u64,
135 },
136 Commit {
137 id: String,
138 },
139 Handoff {
140 handoff_id: String,
141 },
142}
143
144impl MessageReferenceV1 {
145 fn validate(&self) -> Result<(), ValidationError> {
146 match self {
147 Self::Task { number } | Self::Proposal { number } if *number == 0 => {
148 Err(ValidationError::new("numbers start at 1"))
149 }
150 Self::Task { .. } | Self::Proposal { .. } => Ok(()),
151 Self::Fact { id } => validate_id(id, "reference id"),
152 Self::Handoff { handoff_id } => validate_id(handoff_id, "handoff_id"),
153 Self::Commit { id } => {
154 if (7..=64).contains(&id.len())
155 && id
156 .bytes()
157 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
158 {
159 Ok(())
160 } else {
161 Err(ValidationError::new("commit references are 7-64 hex"))
162 }
163 }
164 }
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct MessageBodyV1 {
172 pub text: String,
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub references: Vec<MessageReferenceV1>,
175}
176
177impl MessageBodyV1 {
178 pub fn validate(&self, network: NetworkKind) -> Result<(), ValidationError> {
179 validate_text(&self.text, MAX_MESSAGE_TEXT_CHARS, "text", true)?;
180 if self.references.len() > MAX_MESSAGE_REFERENCES {
181 return Err(ValidationError::new("too many references"));
182 }
183 for reference in &self.references {
184 reference.validate()?;
185 if network == NetworkKind::Swarm && matches!(reference, MessageReferenceV1::Task { .. })
186 {
187 return Err(ValidationError::new(
188 "Swarm messages do not reference org tasks",
189 ));
190 }
191 }
192 Ok(())
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct AgentMessageV1 {
200 pub client_msg_id: String,
202 pub network: NetworkV1,
203 #[serde(rename = "type")]
204 pub message_type: MessageType,
205 pub from: String,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub to: Option<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub to_instance: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub reply_to: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub deadline: Option<UtcTimestamp>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub body: Option<MessageBodyV1>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub sealed: Option<SealedBlobV1>,
236}
237
238impl AgentMessageV1 {
239 pub fn validate(&self) -> Result<(), ValidationError> {
240 validate_id(&self.client_msg_id, "client_msg_id")?;
241 self.network.validate()?;
242 validate_id(&self.from, "from")?;
243 if let Some(to) = &self.to {
244 validate_id(to, "to")?;
245 }
246 if let Some(instance) = &self.to_instance {
247 validate_id(instance, "to_instance")?;
248 if self.to.is_none() || self.network.kind() != NetworkKind::Org {
249 return Err(ValidationError::new(
250 "to_instance names the instance of `to` on an org network",
251 ));
252 }
253 }
254 if let Some(reply_to) = &self.reply_to {
255 validate_id(reply_to, "reply_to")?;
256 }
257 match self.message_type {
258 MessageType::Unknown => return Err(ValidationError::new("unknown message type")),
259 MessageType::Answer if self.reply_to.is_none() => {
260 return Err(ValidationError::new("an answer replies to a question"));
261 }
262 MessageType::Question => {}
263 _ if self.deadline.is_some() => {
264 return Err(ValidationError::new("only questions carry a deadline"));
265 }
266 _ => {}
267 }
268 if let Some(deadline) = &self.deadline {
269 super::parse_time(deadline)?;
270 }
271 match (&self.body, &self.sealed) {
272 (Some(body), None) => body.validate(self.network.kind()),
273 (None, Some(sealed)) => {
274 if self.network.kind() == NetworkKind::Swarm {
275 return Err(ValidationError::new(
276 "swarm messages are public to the swarm's workers",
277 ));
278 }
279 sealed.validate()?;
280 if sealed.content_schema == SEALED_MESSAGE_SCHEMA {
281 Ok(())
282 } else {
283 Err(ValidationError::new(format!(
284 "sealed content must be {SEALED_MESSAGE_SCHEMA}"
285 )))
286 }
287 }
288 _ => Err(ValidationError::new("a message carries body or sealed")),
289 }
290 }
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(rename_all = "snake_case")]
296pub enum MessageTrust {
297 Team,
299 Untrusted,
301 #[serde(other)]
302 Unknown,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(tag = "kind", rename_all = "snake_case")]
308pub enum SenderV1 {
309 Agent {
311 agent: String,
312 instance_label: String,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
317 instance_id: Option<String>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 owner: Option<String>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 model: Option<String>,
322 mine: bool,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 profile_id: Option<String>,
326 },
327 Person { label: String },
329 Swarm { handle: String, reputation: u64 },
332 #[serde(other)]
333 Unknown,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct MessageDeliveryV1 {
339 pub gid: String,
341 pub seq: u64,
343 pub network: NetworkV1,
344 #[serde(rename = "type")]
345 pub message_type: MessageType,
346 pub from: SenderV1,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub to: Option<String>,
349 pub thread_id: String,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub reply_to: Option<String>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub deadline: Option<UtcTimestamp>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub body: Option<MessageBodyV1>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub sealed: Option<SealedBlobV1>,
359 pub trust: MessageTrust,
360 pub server_time: UtcTimestamp,
361 pub expires_at: UtcTimestamp,
362}
363
364impl MessageDeliveryV1 {
365 pub fn validate(&self) -> Result<(), ValidationError> {
366 validate_id(&self.gid, "gid")?;
367 validate_id(&self.thread_id, "thread_id")?;
368 self.network.validate()?;
369 if self.network.kind() == NetworkKind::Swarm {
370 if self.trust != MessageTrust::Untrusted {
371 return Err(ValidationError::new("Swarm content is always untrusted"));
372 }
373 if !matches!(self.from, SenderV1::Swarm { .. }) {
374 return Err(ValidationError::new(
375 "Swarm senders appear only as a handle",
376 ));
377 }
378 } else if matches!(self.from, SenderV1::Swarm { .. }) {
379 return Err(ValidationError::new("org messages have org senders"));
380 }
381 if self.body.is_some() == self.sealed.is_some() {
382 return Err(ValidationError::new("a message carries body or sealed"));
383 }
384 Ok(())
385 }
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(rename_all = "snake_case")]
391pub enum MessageReceiptState {
392 Delivered,
393 Read,
394 #[serde(other)]
395 Unknown,
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(deny_unknown_fields)]
401pub struct MessageReceiptV1 {
402 pub gid: String,
403 pub by: String,
405 pub state: MessageReceiptState,
406}
407
408impl MessageReceiptV1 {
409 pub fn validate(&self) -> Result<(), ValidationError> {
410 validate_id(&self.gid, "gid")?;
411 validate_id(&self.by, "by")?;
412 if self.state == MessageReceiptState::Unknown {
413 return Err(ValidationError::new("unknown receipt state"));
414 }
415 Ok(())
416 }
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421pub struct MessageStoredV1 {
422 pub client_msg_id: String,
423 pub gid: String,
424 pub thread_id: String,
425 pub network: NetworkV1,
426 pub seq: u64,
428 pub server_time: UtcTimestamp,
429 pub expires_at: UtcTimestamp,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434pub struct MessageStatusV1 {
435 pub gid: String,
436 pub client_msg_id: String,
437 pub by: String,
438 pub state: MessageReceiptState,
439 pub server_time: UtcTimestamp,
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(rename_all = "snake_case")]
445pub enum TaskEventKind {
446 Proposed,
447 Opened,
448 Claimed,
449 Released,
450 Submitted,
451 Decided,
452 Cancelled,
453 #[serde(other)]
454 Unknown,
455}
456
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
459pub struct TaskEventV1 {
460 pub channel: String,
461 pub seq: u64,
462 pub number: u64,
463 pub event: TaskEventKind,
464 pub state: super::tasks::TaskState,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub by: Option<super::tasks::TaskActorV1>,
467 pub server_time: UtcTimestamp,
468}
469
470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472pub struct MessageListV1 {
473 pub schema: String,
474 pub messages: Vec<MessageDeliveryV1>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub next: Option<String>,
478}
479
480impl MessageListV1 {
481 pub fn validate(&self) -> Result<(), ValidationError> {
482 require_schema(&self.schema, MESSAGE_LIST_SCHEMA)?;
483 if self.messages.len() > MAX_MESSAGES_PAGE {
484 return Err(ValidationError::new("too many messages in one page"));
485 }
486 self.messages
487 .iter()
488 .try_for_each(MessageDeliveryV1::validate)
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 fn question() -> AgentMessageV1 {
497 AgentMessageV1 {
498 client_msg_id: "01JBMSG".into(),
499 network: NetworkV1::Org {
500 channel: "org:org_acme/vault:3f6c1e9a0b7d4c2e8f5a1b6c9d0e7f42/general".into(),
501 },
502 message_type: MessageType::Question,
503 from: "claude-3e".into(),
504 to: None,
505 to_instance: None,
506 reply_to: None,
507 deadline: None,
508 body: Some(MessageBodyV1 {
509 text: "Which retry budget does payments use?".into(),
510 references: Vec::new(),
511 }),
512 sealed: None,
513 }
514 }
515
516 #[test]
517 fn answers_reply_and_only_questions_have_deadlines() {
518 assert!(question().validate().is_ok());
519 let mut answer = question();
520 answer.message_type = MessageType::Answer;
521 assert!(answer.validate().is_err(), "an answer needs reply_to");
522 answer.reply_to = Some("g-1".into());
523 assert!(answer.validate().is_ok());
524 answer.deadline = Some(UtcTimestamp::new("2026-10-21T12:00:00Z").unwrap());
525 assert!(answer.validate().is_err());
526 }
527
528 #[test]
529 fn swarm_messages_are_public_and_never_reference_org_tasks() {
530 let mut swarm = question();
531 swarm.network = NetworkV1::Swarm {
532 swarm_id: "sw-1".into(),
533 };
534 assert!(swarm.validate().is_ok());
535 swarm.body.as_mut().unwrap().references = vec![MessageReferenceV1::Task { number: 7 }];
536 assert!(swarm.validate().is_err());
537 }
538
539 #[test]
540 fn the_type_is_mandatory() {
541 let raw = serde_json::json!({
542 "client_msg_id": "01JBMSG", "network": {"kind": "org", "channel": "org:o/vault:3f6c1e9a0b7d4c2e8f5a1b6c9d0e7f42/g"},
543 "from": "a", "body": {"text": "hi"},
544 });
545 assert!(serde_json::from_value::<AgentMessageV1>(raw).is_err());
546 }
547
548 #[test]
549 fn swarm_deliveries_are_untrusted_and_show_only_a_handle() {
550 let delivery = |from: SenderV1, trust| MessageDeliveryV1 {
551 gid: "g-1".into(),
552 seq: 1,
553 network: NetworkV1::Swarm {
554 swarm_id: "sw-1".into(),
555 },
556 message_type: MessageType::Finding,
557 from,
558 to: None,
559 thread_id: "g-1".into(),
560 reply_to: None,
561 deadline: None,
562 body: Some(MessageBodyV1 {
563 text: "found".into(),
564 references: Vec::new(),
565 }),
566 sealed: None,
567 trust,
568 server_time: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
569 expires_at: UtcTimestamp::new("2026-11-20T12:00:00Z").unwrap(),
570 };
571 let handle = SenderV1::Swarm {
572 handle: "quiet-heron".into(),
573 reputation: 1,
574 };
575 assert!(
576 delivery(handle.clone(), MessageTrust::Untrusted)
577 .validate()
578 .is_ok()
579 );
580 assert!(delivery(handle, MessageTrust::Team).validate().is_err());
581 let agent = SenderV1::Agent {
582 agent: "codex-85".into(),
583 instance_label: "ci".into(),
584 instance_id: Some("inst_01jb8z9".into()),
585 owner: Some("Ben".into()),
586 model: None,
587 mine: false,
588 profile_id: None,
589 };
590 assert!(
591 delivery(agent, MessageTrust::Untrusted).validate().is_err(),
592 "no org identity in Swarm"
593 );
594 }
595}