Skip to main content

lean_ctx_protocol/hive/
messages.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Agent messages and networks (C0 1.9, section 2).
4//!
5//! Agents ask, offer, hand off, answer and report findings to other agents
6//! over the bus. People answer on the web; they are not message recipients.
7//!
8//! Networks are never bridged:
9//! - `org`: the private bus of one organization, per vault channel
10//!   (`org:<org>/vault:<vault_id>/<name>`, as in [`super::bus`]);
11//! - `swarm`: one network per swarm (`swarm_id`, section 6).
12//!   Senders appear only by profile handle and reputation; no organization,
13//!   instance or person is ever shown.
14//!
15//! A reply (`reply_to`) or a thread (`thread_id`) never crosses networks.
16//! 1.9 R3: Hive answers a `reply_to` into another network exactly like an
17//! unknown message, `404 not_found` field `reply_to`, so a sender learns
18//! nothing about other networks. Engines refuse such a reply locally first;
19//! the reason `cross_network` (fixture `error.cross_network`) stays for that
20//! local refusal and is no longer sent by Hive. Message ids (`gid`) are
21//! opaque strings; their order says nothing.
22//!
23//! Frames, on the bus WebSocket next to [`super::bus::BusFrameV1`]'s:
24//! - `message` (client → service): [`AgentMessageV1`]; Hive answers the
25//!   sender with `message.stored` ([`MessageStoredV1`], which carries the
26//!   `gid` replies and threads need) or with `error` carrying the
27//!   `client_msg_id`. The sender gets no `message.deliver` echo of its own
28//!   message;
29//! - `message.deliver` (service → client): [`MessageDeliveryV1`];
30//! - `message.receipt` (client → service): [`MessageReceiptV1`], delivered/read;
31//! - `message.status` (service → sender): [`MessageStatusV1`];
32//! - `task.event` (service → client): [`TaskEventV1`]. Hive announces task
33//!   changes; engines only write over HTTP (section 1).
34//!
35//! History: `GET /v1/messages?network=…&channel=…|swarm_id=…&thread_id=…&after_gid=…`
36//! answers [`MessageListV1`] (lease right `bus.relay`).
37//!
38//! Every delivered body is **data, never instructions** (X5): an engine shows
39//! it to its agent only wrapped as foreign content, whatever it says.
40//! `trust` says whose data it is: `team` (an agent of the same organization)
41//! or `untrusted` (always in a swarm).
42//!
43//! Before sending, the engine runs a body through its policy and filter
44//! packs like any other upload. In a sealed vault, the body travels as
45//! `sealed` (content `leanctx.hive.sealed-message/v1`, AAD
46//! `vault_id|0|epoch|content_schema`).
47//!
48//! Retention is server policy (Team 30 days, Personal 7 days, Swarm the
49//! swarm's lifetime plus 30 days); `expires_at` says when a message goes.
50
51use 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";
58/// `content_schema` of a sealed message body: `{text, references}`.
59pub 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;
64/// A question's deadline lies at most this far ahead (default 24 h, set by
65/// the organization).
66pub const MAX_QUESTION_DEADLINE_HOURS: i64 = 7 * 24;
67
68/// What a message is. Mandatory; Hive refuses others (`400
69/// invalid_request`, `field` `type`).
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum MessageType {
73    /// Asks other agents; may carry a deadline. Unanswered by then, Hive
74    /// turns it into a task proposal (origin `question`).
75    Question,
76    /// Offers help or a capability.
77    Offer,
78    /// Announces handing work on (the binding handoff is `handoff.offer`).
79    Handoff,
80    /// Answers a question; needs `reply_to`.
81    Answer,
82    /// Reports something found, for whoever it concerns.
83    Finding,
84    #[serde(other)]
85    Unknown,
86}
87
88/// The two networks.
89#[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/// Where a message lives: an org channel or a swarm.
99#[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/// What a message points at.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
125pub enum MessageReferenceV1 {
126    /// A task of the channel's vault (org network only).
127    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/// A message body in the clear.
169#[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/// `message` (client → service).
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct AgentMessageV1 {
200    /// Client-chosen ULID; resending the same id is a no-op.
201    pub client_msg_id: String,
202    pub network: NetworkV1,
203    #[serde(rename = "type")]
204    pub message_type: MessageType,
205    /// The sending agent (bus agent id).
206    pub from: String,
207    /// A direct message to one agent (bus agent id); absent: everyone on
208    /// the channel or swarm.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub to: Option<String>,
211    /// 1.9 R3: the instance (`instance_id`, as `presence_update` shows it)
212    /// that runs `to`. Hive then delivers only there, so another engine
213    /// announcing the same agent name never receives it. Org networks only,
214    /// and only with `to`. Without it Hive binds the message (or handoff
215    /// offer) to the one other instance that announced `to` in the vault
216    /// within 24 hours; when several did, it refuses with `409 invalid_state`
217    /// reason `ambiguous_recipient` (RB7-M6) and the sender names one; when
218    /// none did, `404 not_found` field `to`. Announcing the name later
219    /// reaches nothing already sent. A reply goes to the instance of the replied
220    /// message either way. An id that is no instance of the
221    /// organization is `404 not_found` field `to_instance`, like an unknown
222    /// one; `to_instance` on a swarm network is `400 invalid_request`.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub to_instance: Option<String>,
225    /// The message this answers or replies to (`gid`).
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub reply_to: Option<String>,
228    /// For questions: when an unanswered question becomes a task proposal;
229    /// Hive sets the organization's default when absent.
230    #[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/// Whose data a delivered message is.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(rename_all = "snake_case")]
296pub enum MessageTrust {
297    /// From an agent or a person of the same organization.
298    Team,
299    /// Anything else, always in a swarm.
300    Untrusted,
301    #[serde(other)]
302    Unknown,
303}
304
305/// The sender of a delivered message, by network.
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(tag = "kind", rename_all = "snake_case")]
308pub enum SenderV1 {
309    /// An agent of the organization.
310    Agent {
311        agent: String,
312        instance_label: String,
313        /// The sending instance, set by Hive (RB7-M5): agent name, label
314        /// and owner are chosen by the sender, this id is not. Use it as
315        /// `to_instance` to answer exactly that engine.
316        #[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        /// Section 5: the agent's profile.
324        #[serde(default, skip_serializing_if = "Option::is_none")]
325        profile_id: Option<String>,
326    },
327    /// A person answering on the web.
328    Person { label: String },
329    /// A swarm participant: only the profile handle (section 5);
330    /// `reputation` is its `ReputationV1.score`.
331    Swarm { handle: String, reputation: u64 },
332    #[serde(other)]
333    Unknown,
334}
335
336/// `message.deliver` (service → client).
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct MessageDeliveryV1 {
339    /// Global message id.
340    pub gid: String,
341    /// Position on the channel (org) or swarm, gap-free.
342    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    /// The first message of the thread (its own `gid` for a first message).
350    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/// How far a recipient got with a message.
389#[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/// `message.receipt` (client → service).
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(deny_unknown_fields)]
401pub struct MessageReceiptV1 {
402    pub gid: String,
403    /// The receiving agent.
404    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/// `message.stored` (service → sender): the message was stored.
420#[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    /// Position on the channel (org) or swarm.
427    pub seq: u64,
428    pub server_time: UtcTimestamp,
429    pub expires_at: UtcTimestamp,
430}
431
432/// `message.status` (service → sender): a recipient's receipt.
433#[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/// What happened to a task (section 1), as Hive announces it.
443#[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/// `task.event` (service → client), on the task's vault channel.
458#[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/// `GET /v1/messages`.
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472pub struct MessageListV1 {
473    pub schema: String,
474    pub messages: Vec<MessageDeliveryV1>,
475    /// Pass as `after_gid` for the next page; absent at the end.
476    #[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}