Skip to main content

mnem_core/objects/
operation.rs

1//! Operation object (SPEC §4.5) - the unit of the op-log.
2//!
3//! Every repository-mutating command writes exactly one Operation whose
4//! `parents` point at the op-heads observed at command start and whose
5//! `view` is the CID of a [`View`] snapshotting heads / refs / working-
6//! copy after the mutation.
7//!
8//! [`View`]: crate::objects::View
9
10use std::collections::BTreeMap;
11
12use ipld_core::ipld::Ipld;
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15use crate::id::{ChangeId, Cid};
16use crate::objects::Signature;
17
18/// A single mutation of repository state.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct Operation {
21    /// Parent operations (the op-heads observed when this op started).
22    /// 0 for the root op; ≥2 after a concurrent-write merge.
23    pub parents: Vec<Cid>,
24    /// Snapshot of the post-mutation state. A `Link<View>`.
25    pub view: Cid,
26    /// For each rewritten commit, the `ChangeIds` of its predecessors.
27    /// Keys are UUID-string representations of `ChangeIds` (DAG-CBOR map
28    /// keys MUST be strings; SPEC §4.5).
29    pub predecessors: Option<BTreeMap<String, Vec<ChangeId>>>,
30    /// Free-form author identifier.
31    pub author: String,
32    /// AI-agent identifier (when machine-generated).
33    pub agent_id: Option<String>,
34    /// Task / tool-call identifier for provenance.
35    pub task_id: Option<String>,
36    /// Host identifier.
37    pub host: Option<String>,
38    /// Microseconds since Unix epoch.
39    pub time: u64,
40    /// Short human description (e.g. `"commit: feat(auth): add OAuth"`).
41    pub description: String,
42    /// Optional cryptographic signature (SPEC §9.1). Attached via
43    /// [`crate::sign::Signer::sign_operation`]; verified via
44    /// [`crate::sign::Verifier::verify_operation`].
45    pub signature: Option<Signature>,
46    /// Forward-compat extension map (SPEC §3.2).
47    pub extra: BTreeMap<String, Ipld>,
48}
49
50impl Operation {
51    /// The `_kind` discriminator on the wire.
52    pub const KIND: &'static str = "operation";
53
54    /// Construct an operation with required fields and no parents / optionals.
55    #[must_use]
56    pub fn new(
57        view: Cid,
58        author: impl Into<String>,
59        time: u64,
60        description: impl Into<String>,
61    ) -> Self {
62        Self {
63            parents: Vec::new(),
64            view,
65            predecessors: None,
66            author: author.into(),
67            agent_id: None,
68            task_id: None,
69            host: None,
70            time,
71            description: description.into(),
72            signature: None,
73            extra: BTreeMap::new(),
74        }
75    }
76
77    /// Append a parent operation. Returns `self` for chaining.
78    #[must_use]
79    pub fn with_parent(mut self, parent: Cid) -> Self {
80        self.parents.push(parent);
81        self
82    }
83
84    /// Attach an agent identifier.
85    #[must_use]
86    pub fn with_agent(mut self, agent_id: impl Into<String>) -> Self {
87        self.agent_id = Some(agent_id.into());
88        self
89    }
90
91    /// Attach a task identifier.
92    #[must_use]
93    pub fn with_task(mut self, task_id: impl Into<String>) -> Self {
94        self.task_id = Some(task_id.into());
95        self
96    }
97
98    /// Attach a host identifier.
99    #[must_use]
100    pub fn with_host(mut self, host: impl Into<String>) -> Self {
101        self.host = Some(host.into());
102        self
103    }
104}
105
106// ---------------- Serde ----------------
107
108#[derive(Serialize, Deserialize)]
109struct OperationWire {
110    #[serde(rename = "_kind")]
111    kind: String,
112    parents: Vec<Cid>,
113    view: Cid,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    predecessors: Option<BTreeMap<String, Vec<ChangeId>>>,
116    author: String,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    agent_id: Option<String>,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    task_id: Option<String>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    host: Option<String>,
123    time: u64,
124    description: String,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    signature: Option<Signature>,
127    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
128    extra: BTreeMap<String, Ipld>,
129}
130
131impl Serialize for Operation {
132    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
133        OperationWire {
134            kind: Self::KIND.into(),
135            parents: self.parents.clone(),
136            view: self.view.clone(),
137            predecessors: self.predecessors.clone(),
138            author: self.author.clone(),
139            agent_id: self.agent_id.clone(),
140            task_id: self.task_id.clone(),
141            host: self.host.clone(),
142            time: self.time,
143            description: self.description.clone(),
144            signature: self.signature.clone(),
145            extra: self.extra.clone(),
146        }
147        .serialize(serializer)
148    }
149}
150
151impl<'de> Deserialize<'de> for Operation {
152    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
153        let w = OperationWire::deserialize(deserializer)?;
154        if w.kind != Self::KIND {
155            return Err(serde::de::Error::custom(format!(
156                "expected _kind='{}', got '{}'",
157                Self::KIND,
158                w.kind
159            )));
160        }
161        Ok(Self {
162            parents: w.parents,
163            view: w.view,
164            predecessors: w.predecessors,
165            author: w.author,
166            agent_id: w.agent_id,
167            task_id: w.task_id,
168            host: w.host,
169            time: w.time,
170            description: w.description,
171            signature: w.signature,
172            extra: w.extra,
173        })
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::codec::{from_canonical_bytes, to_canonical_bytes};
181    use crate::id::{CODEC_RAW, Multihash};
182
183    fn raw(n: u32) -> Cid {
184        Cid::new(CODEC_RAW, Multihash::sha2_256(&n.to_be_bytes()))
185    }
186
187    fn sample() -> Operation {
188        Operation::new(
189            raw(1),
190            "alice@example.org",
191            1_700_000_000_000_000,
192            "commit: init",
193        )
194        .with_agent("agent:claude")
195        .with_task("task:001")
196        .with_host("workstation-1")
197    }
198
199    #[test]
200    fn operation_round_trip_byte_identity() {
201        let original = sample();
202        let bytes = to_canonical_bytes(&original).unwrap();
203        let decoded: Operation = from_canonical_bytes(&bytes).unwrap();
204        assert_eq!(original, decoded);
205        let bytes2 = to_canonical_bytes(&decoded).unwrap();
206        assert_eq!(bytes, bytes2);
207    }
208
209    #[test]
210    fn operation_with_predecessors_round_trip() {
211        let mut op = sample();
212        let mut preds = BTreeMap::new();
213        let key = ChangeId::from_bytes_raw([1u8; 16]).to_uuid_string();
214        preds.insert(key, vec![ChangeId::from_bytes_raw([2u8; 16])]);
215        op.predecessors = Some(preds);
216        let bytes = to_canonical_bytes(&op).unwrap();
217        let decoded: Operation = from_canonical_bytes(&bytes).unwrap();
218        assert_eq!(op, decoded);
219    }
220
221    #[test]
222    fn operation_kind_rejection() {
223        let w = OperationWire {
224            kind: "commit".into(),
225            parents: vec![],
226            view: raw(1),
227            predecessors: None,
228            author: "x".into(),
229            agent_id: None,
230            task_id: None,
231            host: None,
232            time: 0,
233            description: String::new(),
234            signature: None,
235            extra: BTreeMap::new(),
236        };
237        let bytes = serde_ipld_dagcbor::to_vec(&w).unwrap();
238        let err = serde_ipld_dagcbor::from_slice::<Operation>(&bytes).unwrap_err();
239        assert!(err.to_string().contains("_kind"));
240    }
241
242    #[test]
243    fn operation_with_multiple_parents_round_trip() {
244        let op = sample().with_parent(raw(10)).with_parent(raw(11));
245        let bytes = to_canonical_bytes(&op).unwrap();
246        let decoded: Operation = from_canonical_bytes(&bytes).unwrap();
247        assert_eq!(decoded.parents.len(), 2);
248        assert_eq!(op, decoded);
249    }
250}