Skip to main content

agent_core/memory/
forum.rs

1//! Pure project-forum contract, shared by host and independently built Axel service.
2//! Digests are content identity, not authentication. Peer text is lower-authority data.
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6pub const MAX_BODY: usize = 8192;
7pub const MAX_TITLE: usize = 256;
8pub const MAX_QUERY: usize = 512;
9pub const MAX_LIMIT: usize = 16;
10pub const PAGE_BYTES: usize = 24 * 1024;
11pub const PAGE_BODY_BYTES: usize = 16 * 1024;
12pub const SNIPPET_BYTES: usize = 400;
13pub const NAMESPACE: &str = "forum";
14pub const META_KEY: &str = "_synaps_forum";
15
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct Author {
19    pub actor: String,
20    pub group: String,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub parent: Option<String>,
23}
24impl Author {
25    pub fn fresh() -> Self {
26        Self {
27            actor: format!("actor-{}", uuid::Uuid::new_v4().simple()),
28            group: format!("group-{}", uuid::Uuid::new_v4().simple()),
29            parent: None,
30        }
31    }
32    pub fn child(&self) -> Self {
33        Self {
34            actor: format!("actor-{}", uuid::Uuid::new_v4().simple()),
35            group: self.group.clone(),
36            parent: Some(self.actor.clone()),
37        }
38    }
39    pub fn validate(&self) -> Result<(), String> {
40        if !prefixed_hex(&self.actor, "actor-", 32)
41            || !prefixed_hex(&self.group, "group-", 32)
42            || self
43                .parent
44                .as_ref()
45                .is_some_and(|p| !prefixed_hex(p, "actor-", 32) || p == &self.actor)
46        {
47            return Err("invalid forum author".into());
48        }
49        Ok(())
50    }
51}
52impl Default for Author {
53    fn default() -> Self {
54        Self::fresh()
55    }
56}
57
58pub fn is_digest(s: &str) -> bool {
59    hex(s, 64)
60}
61fn hex(s: &str, length: usize) -> bool {
62    s.len() == length
63        && s.bytes()
64            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
65}
66fn prefixed_hex(s: &str, prefix: &str, length: usize) -> bool {
67    s.strip_prefix(prefix).is_some_and(|tail| hex(tail, length))
68}
69pub fn valid_id(s: &str) -> bool {
70    prefixed_hex(s, "msg-", 64)
71}
72pub fn valid_project(s: &str) -> bool {
73    prefixed_hex(s, "p", 16) && s != "p0000000000000000"
74}
75fn text(s: &str, max: usize, multiline: bool) -> bool {
76    s.len() <= max
77        && !s
78            .chars()
79            .any(|c| c.is_control() && !(multiline && matches!(c, '\n' | '\t')))
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct Post {
85    pub request_key: String,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub thread_id: Option<String>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub reply_to: Option<String>,
90    pub title: String,
91    pub body: String,
92    pub retention_days: u32,
93}
94impl Post {
95    pub fn validate(&self) -> Result<(), String> {
96        if self.request_key.is_empty()
97            || self.request_key.len() > 64
98            || !self
99                .request_key
100                .bytes()
101                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
102        {
103            return Err("forum request_key must be 1..64 ASCII letters/digits/._-".into());
104        }
105        if !(1..=365).contains(&self.retention_days)
106            || !text(&self.body, MAX_BODY, true)
107            || self.body.trim().is_empty()
108            || !text(&self.title, MAX_TITLE, false)
109        {
110            return Err("forum post exceeds text or retention bounds".into());
111        }
112        match &self.thread_id {
113            None if self.title.trim().is_empty() || self.reply_to.is_some() => {
114                return Err("forum root requires a title and no reply_to".into())
115            }
116            Some(id) if !valid_id(id) || !self.title.is_empty() => {
117                return Err("forum reply requires an exact thread ID and empty title".into())
118            }
119            _ => {}
120        }
121        if self.reply_to.as_ref().is_some_and(|id| !valid_id(id)) {
122            return Err("invalid forum reply_to ID".into());
123        }
124        Ok(())
125    }
126}
127fn hash_parts(parts: &[&[u8]]) -> String {
128    let mut h = Sha256::new();
129    for part in parts {
130        h.update((part.len() as u64).to_be_bytes());
131        h.update(part);
132    }
133    format!("{:x}", h.finalize())
134}
135
136#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct Envelope {
139    pub version: u32,
140    pub project: String,
141    pub author: Author,
142    pub request_key: String,
143    pub thread_id: String,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub reply_to: Option<String>,
146    pub title: String,
147    pub retention_days: u32,
148    pub digest: String,
149}
150impl Envelope {
151    pub fn new(project: &str, author: Author, post: &Post) -> Result<Self, String> {
152        post.validate()?;
153        author.validate()?;
154        if !valid_project(project) {
155            return Err("invalid forum repository scope".into());
156        }
157        let digest = hash_parts(&[
158            b"synaps-forum-post/1",
159            project.as_bytes(),
160            author.actor.as_bytes(),
161            author.group.as_bytes(),
162            author.parent.as_deref().unwrap_or("").as_bytes(),
163            post.request_key.as_bytes(),
164            post.thread_id.as_deref().unwrap_or("").as_bytes(),
165            post.reply_to.as_deref().unwrap_or("").as_bytes(),
166            post.title.as_bytes(),
167            post.body.as_bytes(),
168            &post.retention_days.to_be_bytes(),
169        ]);
170        Ok(Self {
171            version: 1,
172            project: project.into(),
173            author,
174            request_key: post.request_key.clone(),
175            thread_id: post
176                .thread_id
177                .clone()
178                .unwrap_or_else(|| format!("msg-{digest}")),
179            reply_to: post.reply_to.clone(),
180            title: post.title.clone(),
181            retention_days: post.retention_days,
182            digest,
183        })
184    }
185    pub fn id(&self) -> String {
186        format!("msg-{}", self.digest)
187    }
188    pub fn is_root(&self) -> bool {
189        self.thread_id == self.id()
190    }
191    pub fn source(&self) -> String {
192        format!("forum:{}", self.id())
193    }
194    pub fn validate_metadata(&self) -> Result<(), String> {
195        if self.version != 1
196            || !valid_project(&self.project)
197            || !is_digest(&self.digest)
198            || !valid_id(&self.thread_id)
199        {
200            return Err("invalid forum metadata".into());
201        }
202        self.author.validate()?;
203        let p = self.post("validation body".into());
204        p.validate()
205    }
206    pub fn post(&self, body: String) -> Post {
207        Post {
208            request_key: self.request_key.clone(),
209            thread_id: (!self.is_root()).then(|| self.thread_id.clone()),
210            reply_to: self.reply_to.clone(),
211            title: self.title.clone(),
212            body,
213            retention_days: self.retention_days,
214        }
215    }
216    pub fn validate(&self, body: &str) -> Result<(), String> {
217        self.validate_metadata()?;
218        let expected = Self::new(&self.project, self.author.clone(), &self.post(body.into()))?;
219        if *self != expected {
220            return Err("forum digest mismatch".into());
221        }
222        Ok(())
223    }
224}
225#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct PostRequest {
228    pub author: Author,
229    pub post: Post,
230}
231
232#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum Status {
235    Created,
236    Duplicate,
237    Tombstoned,
238}
239#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct Receipt {
242    pub id: String,
243    pub thread_id: String,
244    pub digest: String,
245    pub status: Status,
246    pub timestamp_ms: Option<u64>,
247}
248#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct Cursor {
251    pub timestamp_ms: u64,
252    pub id: String,
253}
254impl Cursor {
255    pub fn validate(&self) -> Result<(), String> {
256        if self.timestamp_ms > i64::MAX as u64 || !valid_id(&self.id) {
257            return Err("invalid forum cursor".into());
258        }
259        Ok(())
260    }
261}
262#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct Read {
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub thread_id: Option<String>,
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub query: Option<String>,
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub after: Option<Cursor>,
271    pub limit: usize,
272}
273impl Read {
274    pub fn validate(&self) -> Result<(), String> {
275        if self.limit == 0
276            || self.limit > MAX_LIMIT
277            || self.thread_id.as_ref().is_some_and(|s| !valid_id(s))
278            || self
279                .query
280                .as_ref()
281                .is_some_and(|s| !text(s, MAX_QUERY, false))
282        {
283            return Err("invalid forum read bounds".into());
284        }
285        if let Some(c) = &self.after {
286            c.validate()?;
287        }
288        Ok(())
289    }
290}
291impl Default for Read {
292    fn default() -> Self {
293        Self {
294            thread_id: None,
295            query: None,
296            after: None,
297            limit: 8,
298        }
299    }
300}
301#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(deny_unknown_fields)]
303pub struct Entry {
304    pub id: String,
305    pub timestamp_ms: u64,
306    pub envelope: Envelope,
307    pub body: String,
308    pub truncated: bool,
309}
310impl Entry {
311    pub fn cursor(&self) -> Cursor {
312        Cursor {
313            timestamp_ms: self.timestamp_ms,
314            id: self.id.clone(),
315        }
316    }
317    pub fn validate(&self) -> Result<(), String> {
318        self.cursor().validate()?;
319        self.envelope.validate_metadata()?;
320        if self.id != self.envelope.id() || !text(&self.body, MAX_BODY, true) {
321            return Err("invalid forum entry".into());
322        }
323        if self.truncated {
324            if self.body.len() > SNIPPET_BYTES || !self.envelope.is_root() {
325                return Err("invalid forum snippet".into());
326            }
327        } else {
328            self.envelope.validate(&self.body)?;
329        }
330        Ok(())
331    }
332}
333#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(deny_unknown_fields)]
335pub struct Page {
336    pub entries: Vec<Entry>,
337    pub next: Option<Cursor>,
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    fn post() -> Post {
344        Post {
345            request_key: "finding-1".into(),
346            thread_id: None,
347            reply_to: None,
348            title: "Finding".into(),
349            body: "Synthetic useful note".into(),
350            retention_days: 30,
351        }
352    }
353    fn author() -> Author {
354        Author {
355            actor: format!("actor-{}", "a".repeat(32)),
356            group: format!("group-{}", "b".repeat(32)),
357            parent: None,
358        }
359    }
360    #[test]
361    fn digest_roundtrip_and_domain_separation() {
362        let p = post();
363        let e = Envelope::new("p1111111111111111", author(), &p).unwrap();
364        e.validate(&p.body).unwrap();
365        assert_eq!(e, Envelope::new("p1111111111111111", author(), &p).unwrap());
366        assert_ne!(
367            e.id(),
368            Envelope::new("p2222222222222222", author(), &p)
369                .unwrap()
370                .id()
371        );
372        let mut changed = p.clone();
373        changed.request_key.push('2');
374        assert_ne!(
375            e.id(),
376            Envelope::new("p1111111111111111", author(), &changed)
377                .unwrap()
378                .id()
379        );
380        assert!(e.validate("tampered note").is_err());
381    }
382    #[test]
383    fn exact_digest_vector() {
384        let e = Envelope::new("p1111111111111111", author(), &post()).unwrap();
385        assert_eq!(
386            e.digest,
387            "79f1294388dea6ade99737365b1f0fb55e8afe447ad9c25f5c568e03476a7ccf"
388        );
389    }
390    #[test]
391    fn bounds_and_author_validation() {
392        let mut p = post();
393        p.body = "x".repeat(MAX_BODY + 1);
394        assert!(p.validate().is_err());
395        p = post();
396        p.body = "hidden\u{1b}[0m".into();
397        assert!(p.validate().is_err());
398        p = post();
399        p.retention_days = 0;
400        assert!(p.validate().is_err());
401        p = post();
402        p.title.clear();
403        assert!(p.validate().is_err());
404        let a = author();
405        let child = a.child();
406        assert_eq!(a.group, child.group);
407        assert_eq!(child.parent, Some(a.actor.clone()));
408        assert_ne!(a.actor, child.actor);
409        child.validate().unwrap();
410    }
411    #[test]
412    fn replies_require_exact_thread_and_empty_title() {
413        let mut p = post();
414        let root = Envelope::new("p1111111111111111", author(), &p).unwrap();
415        p.thread_id = Some(root.id());
416        assert!(p.validate().is_err());
417        p.title.clear();
418        let reply = Envelope::new("p1111111111111111", author(), &p).unwrap();
419        assert!(!reply.is_root());
420        reply.validate(&p.body).unwrap();
421        p.reply_to = Some("../../bad".into());
422        assert!(p.validate().is_err());
423    }
424}