Skip to main content

diavlos_core/
message.rs

1//! What a message looks like.
2//!
3//! Every message is a small JSON object. Agents get it as-is. Humans see
4//! just the text. The chain is over envelopes; content (text, action, data)
5//! sits beside it, so a delete leaves a tombstone and the chain still proves
6//! nothing else changed.
7
8use std::fmt;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::canonical::{canonical_json, sha256_json};
15use crate::error::{Error, Result};
16use crate::keys::{PublicKey, Signer};
17
18/// Schema version. Lets the format grow without breaking old helpers.
19pub const SCHEMA_VERSION: u32 = 1;
20
21/// Biggest message we store or relay, in bytes of JSON. Big things go by
22/// reference: hash, size, where. Never inline.
23pub const MAX_MESSAGE_BYTES: usize = 64 * 1024;
24
25/// An approve stops counting after this many seconds. Timeout means no.
26pub const APPROVE_TTL_SECS: i64 = 600;
27
28/// `prev` of the first message in a room.
29pub const GENESIS_PREV: &str =
30    "sha256:0000000000000000000000000000000000000000000000000000000000000000";
31
32/// The type of a message. An agent knows what it got without parsing prose.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum MessageType {
36    /// Just talking. Anyone.
37    Chat,
38    /// Please do this. Agent or human.
39    Task,
40    /// I need an answer before I go on. Agent. (`diavlos ask` sends this.)
41    #[serde(alias = "ask")]
42    Question,
43    /// Answer to a question or task (reply_to set). Agent.
44    Reply,
45    /// Task finished, here's the result. Agent.
46    Done,
47    /// I'm taking this task. Agent.
48    Claim,
49    /// I'm giving this task back. Agent.
50    Release,
51    /// A human says yes to a risky action. Human key only.
52    Approve,
53    /// A human says no, with a reason. Human key only.
54    Deny,
55    /// pause, mute, revoke, grant. Owner key only.
56    Control,
57    /// Joined, left, name taken. Helper.
58    System,
59}
60
61impl MessageType {
62    pub const ALL: [MessageType; 11] = [
63        MessageType::Chat,
64        MessageType::Task,
65        MessageType::Question,
66        MessageType::Reply,
67        MessageType::Done,
68        MessageType::Claim,
69        MessageType::Release,
70        MessageType::Approve,
71        MessageType::Deny,
72        MessageType::Control,
73        MessageType::System,
74    ];
75
76    pub fn as_str(&self) -> &'static str {
77        match self {
78            MessageType::Chat => "chat",
79            MessageType::Task => "task",
80            MessageType::Question => "question",
81            MessageType::Reply => "reply",
82            MessageType::Done => "done",
83            MessageType::Claim => "claim",
84            MessageType::Release => "release",
85            MessageType::Approve => "approve",
86            MessageType::Deny => "deny",
87            MessageType::Control => "control",
88            MessageType::System => "system",
89        }
90    }
91}
92
93impl fmt::Display for MessageType {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        f.write_str(self.as_str())
96    }
97}
98
99impl FromStr for MessageType {
100    type Err = Error;
101    fn from_str(s: &str) -> Result<Self> {
102        match s {
103            "chat" => Ok(MessageType::Chat),
104            "task" => Ok(MessageType::Task),
105            "question" | "ask" => Ok(MessageType::Question),
106            "reply" => Ok(MessageType::Reply),
107            "done" => Ok(MessageType::Done),
108            "claim" => Ok(MessageType::Claim),
109            "release" => Ok(MessageType::Release),
110            "approve" => Ok(MessageType::Approve),
111            "deny" => Ok(MessageType::Deny),
112            "control" => Ok(MessageType::Control),
113            "system" => Ok(MessageType::System),
114            other => Err(Error::Invalid(format!("unknown message type: {other}"))),
115        }
116    }
117}
118
119/// Data class. The helper can refuse to store or relay based on it.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
121#[serde(rename_all = "lowercase")]
122pub enum DataClass {
123    Public,
124    #[default]
125    Internal,
126    Confidential,
127    Pii,
128}
129
130impl DataClass {
131    pub fn as_str(&self) -> &'static str {
132        match self {
133            DataClass::Public => "public",
134            DataClass::Internal => "internal",
135            DataClass::Confidential => "confidential",
136            DataClass::Pii => "pii",
137        }
138    }
139}
140
141impl fmt::Display for DataClass {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        f.write_str(self.as_str())
144    }
145}
146
147impl FromStr for DataClass {
148    type Err = Error;
149    fn from_str(s: &str) -> Result<Self> {
150        match s {
151            "public" => Ok(DataClass::Public),
152            "internal" => Ok(DataClass::Internal),
153            "confidential" => Ok(DataClass::Confidential),
154            "pii" => Ok(DataClass::Pii),
155            other => Err(Error::Invalid(format!("unknown data class: {other}"))),
156        }
157    }
158}
159
160/// Which vendor, model, and owning human. "Which LLM touched this?"
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
162pub struct AgentInfo {
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub vendor: Option<String>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub model: Option<String>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub owner: Option<String>,
169}
170
171/// Structured intent for a question that needs an approve. The approve signs
172/// its hash, so the agent can't ask for v1.2 and ship v1.3.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct Action {
175    pub verb: String,
176    pub target: String,
177    #[serde(default)]
178    pub params: Value,
179}
180
181impl Action {
182    /// `sha256:<hex>` of the canonical action.
183    pub fn hash(&self) -> String {
184        sha256_json(&serde_json::to_value(self).expect("action serializes"))
185    }
186}
187
188/// The content that sits beside the envelope. This is what a GDPR delete
189/// removes. The envelope keeps its hash.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
191pub struct Content {
192    #[serde(default)]
193    pub text: String,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub action: Option<Action>,
196    #[serde(default)]
197    pub data: Value,
198}
199
200impl Content {
201    pub fn hash(&self) -> String {
202        sha256_json(&serde_json::to_value(self).expect("content serializes"))
203    }
204}
205
206/// A message. Exactly the JSON an agent sees.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct Message {
209    pub v: u32,
210    pub id: String,
211    pub room: String,
212    /// Sequence number in the room. Assigned by the room's home helper.
213    #[serde(default)]
214    pub seq: u64,
215    /// Hash of the message before it. Assigned with `seq`.
216    #[serde(default)]
217    pub prev: String,
218    #[serde(default)]
219    pub trace: Option<String>,
220    pub from: String,
221    #[serde(default)]
222    pub agent: Option<AgentInfo>,
223    #[serde(rename = "type")]
224    pub kind: MessageType,
225    #[serde(default)]
226    pub text: String,
227    #[serde(default)]
228    pub action: Option<Action>,
229    #[serde(default)]
230    pub data: Value,
231    #[serde(default)]
232    pub reply_to: Option<String>,
233    #[serde(default)]
234    pub to: Option<String>,
235    #[serde(default)]
236    pub class: DataClass,
237    pub ts: String,
238    /// On an approve: hash of the action being approved.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub action_hash: Option<String>,
241    /// On an approve: when it stops counting.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub expires: Option<String>,
244    /// On an approve: it works once.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub once: Option<bool>,
247    #[serde(default)]
248    pub sig: String,
249    /// Hash of the content as it was when signed. Only present when the
250    /// content is gone (a tombstone) or in an audit bundle, so the chain
251    /// and the signature can still be checked.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub content_hash: Option<String>,
254    /// True when the content was deleted and only the envelope remains.
255    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
256    pub tombstone: bool,
257}
258
259/// Everything you need to build a new message. `seq`, `prev`, `sig`, `id`
260/// and `ts` are filled in by [`Message::new`].
261#[derive(Debug, Clone, Default)]
262pub struct Draft {
263    pub room: String,
264    pub from: String,
265    pub kind: Option<MessageType>,
266    pub text: String,
267    pub action: Option<Action>,
268    pub data: Value,
269    pub reply_to: Option<String>,
270    pub to: Option<String>,
271    pub trace: Option<String>,
272    pub class: Option<DataClass>,
273    pub agent: Option<AgentInfo>,
274    pub action_hash: Option<String>,
275    pub expires: Option<String>,
276    pub once: Option<bool>,
277}
278
279/// A fresh, unique, sortable message id.
280pub fn new_id() -> String {
281    format!("m_{}", ulid::Ulid::generate())
282}
283
284/// Now, as RFC 3339 in UTC with second precision.
285/// How far ahead of the home's clock a message's `ts` may be.
286pub const MAX_CLOCK_SKEW_SECS: i64 = 300;
287
288pub fn now_ts() -> String {
289    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
290}
291
292impl Message {
293    /// Build and sign a message. It has no `seq` or `prev` yet; the room's
294    /// home helper assigns those with [`Message::sequence`].
295    pub fn new(draft: Draft, signer: &dyn Signer) -> Result<Self> {
296        let mut m = Message {
297            v: SCHEMA_VERSION,
298            id: new_id(),
299            room: draft.room,
300            seq: 0,
301            prev: String::new(),
302            trace: draft.trace,
303            from: draft.from,
304            agent: draft.agent,
305            kind: draft.kind.unwrap_or(MessageType::Chat),
306            text: draft.text,
307            action: draft.action,
308            data: if draft.data.is_null() {
309                Value::Null
310            } else {
311                draft.data
312            },
313            reply_to: draft.reply_to,
314            to: draft.to,
315            class: draft.class.unwrap_or_default(),
316            ts: now_ts(),
317            action_hash: draft.action_hash,
318            expires: draft.expires,
319            once: draft.once,
320            sig: String::new(),
321            content_hash: None,
322            tombstone: false,
323        };
324        m.sig = signer.sign(&m.signing_bytes());
325        m.check_size()?;
326        Ok(m)
327    }
328
329    /// The content beside the envelope.
330    pub fn content(&self) -> Content {
331        Content {
332            text: self.text.clone(),
333            action: self.action.clone(),
334            data: self.data.clone(),
335        }
336    }
337
338    /// `sha256:<hex>` of the content as signed. Uses the stored hash when
339    /// the content is gone.
340    pub fn content_hash(&self) -> String {
341        match &self.content_hash {
342            Some(h) => h.clone(),
343            None => self.content().hash(),
344        }
345    }
346
347    /// A copy that carries its content hash explicitly, for bundles.
348    pub fn with_explicit_hash(&self) -> Message {
349        let mut m = self.clone();
350        m.content_hash = Some(self.content_hash());
351        m
352    }
353
354    /// The bytes the sender signs. Everything except `seq`, `prev` and
355    /// `sig`, with the content replaced by its hash.
356    pub fn signing_bytes(&self) -> Vec<u8> {
357        let v = serde_json::json!({
358            "v": self.v,
359            "id": self.id,
360            "room": self.room,
361            "trace": self.trace,
362            "from": self.from,
363            "agent": self.agent,
364            "type": self.kind,
365            "content_hash": self.content_hash(),
366            "reply_to": self.reply_to,
367            "to": self.to,
368            "class": self.class,
369            "ts": self.ts,
370            "action_hash": self.action_hash,
371            "expires": self.expires,
372            "once": self.once,
373        });
374        canonical_json(&v).into_bytes()
375    }
376
377    /// Check the sender's signature against a key.
378    pub fn verify(&self, key: &PublicKey) -> Result<()> {
379        key.verify(&self.signing_bytes(), &self.sig)
380    }
381
382    /// Check `ts` as the home sees it on arrival: the exact format the spec
383    /// asks for (RFC 3339 UTC, whole seconds, `Z`), which is what makes the
384    /// string comparisons in expiry and retention sound, and not more than
385    /// `MAX_CLOCK_SKEW_SECS` ahead of `now`, so a sender cannot date a
386    /// message into the future to dodge retention or stretch an approve.
387    /// A past `ts` is fine: a message can wait days in an outbox.
388    pub fn check_ts(&self, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
389        let t = chrono::DateTime::parse_from_rfc3339(&self.ts)
390            .map_err(|_| Error::Invalid(format!("ts {:?} is not RFC 3339", self.ts)))?
391            .with_timezone(&chrono::Utc);
392        if t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) != self.ts {
393            return Err(Error::Invalid(format!(
394                "ts {:?} must be UTC, whole seconds, ending in Z",
395                self.ts
396            )));
397        }
398        if t > now + chrono::Duration::seconds(MAX_CLOCK_SKEW_SECS) {
399            return Err(Error::Invalid(format!(
400                "ts {} is in the future; check this computer's clock",
401                self.ts
402            )));
403        }
404        Ok(())
405    }
406
407    /// The envelope: everything except the content, plus the content hash.
408    /// This is what the chain is over.
409    pub fn envelope(&self) -> Value {
410        serde_json::json!({
411            "v": self.v,
412            "id": self.id,
413            "room": self.room,
414            "seq": self.seq,
415            "prev": self.prev,
416            "trace": self.trace,
417            "from": self.from,
418            "agent": self.agent,
419            "type": self.kind,
420            "content_hash": self.content_hash(),
421            "reply_to": self.reply_to,
422            "to": self.to,
423            "class": self.class,
424            "ts": self.ts,
425            "action_hash": self.action_hash,
426            "expires": self.expires,
427            "once": self.once,
428            "sig": self.sig,
429        })
430    }
431
432    /// `sha256:<hex>` of the envelope. The next message's `prev`.
433    pub fn chain_hash(&self) -> String {
434        sha256_json(&self.envelope())
435    }
436
437    /// Assign a place in the room's chain.
438    pub fn sequence(&mut self, seq: u64, prev: &str) {
439        self.seq = seq;
440        self.prev = prev.to_string();
441    }
442
443    /// True once `sequence` has been called.
444    pub fn is_sequenced(&self) -> bool {
445        self.seq > 0 && !self.prev.is_empty()
446    }
447
448    /// Reject messages over the size cap.
449    pub fn check_size(&self) -> Result<()> {
450        let len = serde_json::to_string(self)?.len();
451        if len > MAX_MESSAGE_BYTES {
452            return Err(Error::Invalid(format!(
453                "message is {len} bytes, cap is {MAX_MESSAGE_BYTES}. Send big things by reference."
454            )));
455        }
456        Ok(())
457    }
458
459    /// The message with content removed: a tombstone. The envelope, and so
460    /// the chain and the signature, are unchanged.
461    pub fn tombstone(&self) -> Message {
462        let mut t = self.clone();
463        t.content_hash = Some(self.content_hash());
464        t.tombstone = true;
465        t.text = String::new();
466        t.action = None;
467        t.data = Value::Null;
468        t
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::keys::{Identity, Kind};
476
477    fn draft(room: &str, from: &str, text: &str) -> Draft {
478        Draft {
479            room: room.into(),
480            from: from.into(),
481            text: text.into(),
482            kind: Some(MessageType::Task),
483            ..Default::default()
484        }
485    }
486
487    #[test]
488    fn new_message_is_signed_and_verifies() {
489        let alice = Identity::generate("alice", Kind::Agent);
490        let m = Message::new(draft("ops", "alice", "deploy?"), &alice).unwrap();
491        assert_eq!(m.v, 1);
492        assert!(m.id.starts_with("m_"));
493        m.verify(&alice.public()).unwrap();
494        let bob = Identity::generate("bob", Kind::Agent);
495        assert!(m.verify(&bob.public()).is_err());
496    }
497
498    #[test]
499    fn ts_must_be_well_formed_and_not_in_the_future() {
500        let alice = Identity::generate("alice", Kind::Agent);
501        let mut m = Message::new(draft("ops", "alice", "hi"), &alice).unwrap();
502        let now = chrono::Utc::now();
503        m.check_ts(now).unwrap();
504        // Old is fine: it may have waited in an outbox.
505        m.ts = "2020-01-01T00:00:00Z".into();
506        m.check_ts(now).unwrap();
507        // A little clock skew is fine; far ahead is not.
508        let soon = now + chrono::Duration::seconds(60);
509        m.ts = soon.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
510        m.check_ts(now).unwrap();
511        m.ts = "2999-01-01T00:00:00Z".into();
512        assert!(m.check_ts(now).is_err());
513        // Anything but the one format would break string comparisons.
514        for bad in [
515            "2020-01-01T00:00:00+00:00",
516            "2020-01-01T00:00:00.5Z",
517            "2020-01-01 00:00:00Z",
518            "yesterday",
519        ] {
520            m.ts = bad.into();
521            assert!(m.check_ts(now).is_err(), "{bad}");
522        }
523    }
524
525    #[test]
526    fn sequencing_does_not_break_signature() {
527        let alice = Identity::generate("alice", Kind::Agent);
528        let mut m = Message::new(draft("ops", "alice", "hi"), &alice).unwrap();
529        m.sequence(1, GENESIS_PREV);
530        m.verify(&alice.public()).unwrap();
531        assert!(m.is_sequenced());
532    }
533
534    #[test]
535    fn editing_content_breaks_signature() {
536        let alice = Identity::generate("alice", Kind::Agent);
537        let mut m = Message::new(draft("ops", "alice", "ship v1.2"), &alice).unwrap();
538        m.text = "ship v1.3".into();
539        assert!(m.verify(&alice.public()).is_err());
540    }
541
542    #[test]
543    fn chain_links_and_survives_tombstone() {
544        let alice = Identity::generate("alice", Kind::Agent);
545        let mut a = Message::new(draft("ops", "alice", "first"), &alice).unwrap();
546        a.sequence(1, GENESIS_PREV);
547        let mut b = Message::new(draft("ops", "alice", "second"), &alice).unwrap();
548        b.sequence(2, &a.chain_hash());
549        assert_eq!(b.prev, a.chain_hash());
550        // Delete the content of `a`: the chain hash and the signature must
551        // not change, because the tombstone keeps the content hash.
552        let t = a.tombstone();
553        assert_eq!(t.text, "");
554        assert!(t.tombstone);
555        assert_eq!(t.content_hash(), a.content_hash());
556        assert_eq!(t.chain_hash(), b.prev);
557        t.verify(&alice.public()).unwrap();
558        let v = serde_json::to_value(&t).unwrap();
559        assert!(v.get("content_hash").is_some());
560        let v = serde_json::to_value(&a).unwrap();
561        assert!(v.get("content_hash").is_none());
562        assert!(v.get("tombstone").is_none());
563    }
564
565    #[test]
566    fn json_shape_matches_plan() {
567        let alice = Identity::generate("alice", Kind::Agent);
568        let mut d = draft("ops", "alice", "Deploy api-service v1.2 to prod?");
569        d.kind = Some(MessageType::Question);
570        d.trace = Some("ticket-4711".into());
571        d.action = Some(Action {
572            verb: "deploy".into(),
573            target: "api-service".into(),
574            params: serde_json::json!({"version": "1.2", "env": "prod"}),
575        });
576        let m = Message::new(d, &alice).unwrap();
577        let v = serde_json::to_value(&m).unwrap();
578        for key in [
579            "v", "id", "room", "seq", "prev", "trace", "from", "agent", "type", "text", "action",
580            "data", "reply_to", "to", "class", "ts", "sig",
581        ] {
582            assert!(v.get(key).is_some(), "missing {key}");
583        }
584        assert_eq!(v["type"], "question");
585        assert_eq!(v["class"], "internal");
586        assert!(v.get("action_hash").is_none());
587        // Round trip.
588        let back: Message = serde_json::from_value(v).unwrap();
589        assert_eq!(back, m);
590        // "ask" is accepted as an alias for question.
591        let asked: MessageType = serde_json::from_str("\"ask\"").unwrap();
592        assert_eq!(asked, MessageType::Question);
593    }
594
595    #[test]
596    fn size_cap() {
597        let alice = Identity::generate("alice", Kind::Agent);
598        let big = "x".repeat(MAX_MESSAGE_BYTES + 1);
599        assert!(Message::new(draft("ops", "alice", &big), &alice).is_err());
600    }
601}