Skip to main content

maw/oplog/
types.rs

1//! Operation struct and `OpPayload` enum — canonical JSON for deterministic hashing (§5.3).
2//!
3//! Operations are the fundamental unit of the op log. Each operation records
4//! a single workspace mutation (create, destroy, snapshot, merge, compensate,
5//! describe, annotate) with enough metadata to replay or derive state.
6//!
7//! Canonical JSON rules:
8//! - Sorted keys (guaranteed by `serde_json` with `BTreeMap` / `#[serde(sort_keys)]`)
9//! - No trailing whitespace
10//! - Deterministic: serialize twice → identical bytes
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::model::types::{EpochId, GitOid, WorkspaceId};
17
18// ---------------------------------------------------------------------------
19// Operation
20// ---------------------------------------------------------------------------
21
22/// A single operation in a workspace's op log (§5.3).
23///
24/// Operations form a chain: each operation points to its parent(s) by git OID.
25/// For single-workspace operations there is one parent; merge operations may
26/// have multiple parents (one per source workspace).
27///
28/// The entire struct serializes to canonical JSON, which is then stored as a
29/// git blob. The blob's OID becomes the operation's identity.
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Operation {
32    /// OIDs of parent operations (empty for the first op in a workspace).
33    pub parent_ids: Vec<GitOid>,
34
35    /// The workspace that performed this operation.
36    pub workspace_id: WorkspaceId,
37
38    /// ISO 8601 timestamp (UTC) of when the operation was created.
39    ///
40    /// Stored as a string for canonical JSON (avoids platform-specific
41    /// floating-point or integer timestamp representations).
42    pub timestamp: String,
43
44    /// The mutation that this operation represents.
45    pub payload: OpPayload,
46}
47
48// ---------------------------------------------------------------------------
49// OpPayload
50// ---------------------------------------------------------------------------
51
52/// The kind of mutation recorded by an [`Operation`] (§5.3).
53///
54/// Each variant captures the minimal data needed to replay or undo the
55/// operation. Serialized with a `"type"` tag for canonical JSON:
56/// `{"type":"create","epoch":"…"}` etc.
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum OpPayload {
60    /// Workspace was created, anchored to a base epoch.
61    Create {
62        /// The epoch this workspace is based on.
63        epoch: EpochId,
64    },
65
66    /// Workspace was destroyed.
67    Destroy,
68
69    /// Working directory was snapshotted — records the files that changed
70    /// relative to the base epoch as a patch-set blob.
71    Snapshot {
72        /// Git blob OID of the serialized [`PatchSet`] (stored separately).
73        patch_set_oid: GitOid,
74    },
75
76    /// One or more workspaces were merged into a new epoch.
77    Merge {
78        /// Source workspace IDs that were merged.
79        sources: Vec<WorkspaceId>,
80        /// The epoch before the merge.
81        epoch_before: EpochId,
82        /// The new epoch produced by the merge.
83        epoch_after: EpochId,
84    },
85
86    /// A compensation (undo) operation that reverses a prior operation.
87    Compensate {
88        /// The OID of the operation being undone.
89        target_op: GitOid,
90        /// Human-readable reason for the compensation.
91        reason: String,
92    },
93
94    /// The workspace description was updated (human-readable label).
95    Describe {
96        /// The new description text.
97        message: String,
98    },
99
100    /// An arbitrary annotation attached to the op log (e.g., validation
101    /// result, review status, CI outcome).
102    Annotate {
103        /// Annotation key (e.g., "validation", "review").
104        key: String,
105        /// Annotation value — arbitrary JSON-safe data.
106        ///
107        /// Uses `BTreeMap` for deterministic key ordering in canonical JSON.
108        data: BTreeMap<String, serde_json::Value>,
109    },
110}
111
112// ---------------------------------------------------------------------------
113// Canonical JSON helpers
114// ---------------------------------------------------------------------------
115
116impl Operation {
117    /// Serialize this operation to canonical JSON bytes.
118    ///
119    /// Canonical JSON: sorted keys, no trailing whitespace, deterministic.
120    /// Two calls with the same `Operation` always produce identical bytes.
121    ///
122    /// # Errors
123    /// Returns an error if serialization fails (shouldn't happen for valid ops).
124    pub fn to_canonical_json(&self) -> Result<Vec<u8>, serde_json::Error> {
125        // serde_json serializes struct fields in declaration order.
126        // For BTreeMap keys inside Annotate, serde_json sorts them.
127        // This gives us canonical output without a custom serializer.
128        serde_json::to_vec(self)
129    }
130
131    /// Deserialize an operation from JSON bytes.
132    ///
133    /// # Errors
134    /// Returns an error if the bytes are not valid JSON or don't match the schema.
135    pub fn from_json(bytes: &[u8]) -> Result<Self, serde_json::Error> {
136        serde_json::from_slice(bytes)
137    }
138}
139
140// ---------------------------------------------------------------------------
141// Tests
142// ---------------------------------------------------------------------------
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    // Helper: build a valid 40-char hex OID string.
149    fn oid(c: char) -> String {
150        c.to_string().repeat(40)
151    }
152
153    fn git_oid(c: char) -> GitOid {
154        GitOid::new(&oid(c)).unwrap()
155    }
156
157    fn epoch(c: char) -> EpochId {
158        EpochId::new(&oid(c)).unwrap()
159    }
160
161    fn ws(name: &str) -> WorkspaceId {
162        WorkspaceId::new(name).unwrap()
163    }
164
165    fn timestamp() -> String {
166        "2026-02-19T12:00:00Z".to_owned()
167    }
168
169    // -----------------------------------------------------------------------
170    // OpPayload variant serialization round-trips
171    // -----------------------------------------------------------------------
172
173    #[test]
174    fn create_round_trip() {
175        let op = Operation {
176            parent_ids: vec![],
177            workspace_id: ws("agent-1"),
178            timestamp: timestamp(),
179            payload: OpPayload::Create { epoch: epoch('a') },
180        };
181        let json = op.to_canonical_json().unwrap();
182        let parsed = Operation::from_json(&json).unwrap();
183        assert_eq!(op, parsed);
184    }
185
186    #[test]
187    fn destroy_round_trip() {
188        let op = Operation {
189            parent_ids: vec![git_oid('b')],
190            workspace_id: ws("agent-2"),
191            timestamp: timestamp(),
192            payload: OpPayload::Destroy,
193        };
194        let json = op.to_canonical_json().unwrap();
195        let parsed = Operation::from_json(&json).unwrap();
196        assert_eq!(op, parsed);
197    }
198
199    #[test]
200    fn snapshot_round_trip() {
201        let op = Operation {
202            parent_ids: vec![git_oid('c')],
203            workspace_id: ws("feature-x"),
204            timestamp: timestamp(),
205            payload: OpPayload::Snapshot {
206                patch_set_oid: git_oid('d'),
207            },
208        };
209        let json = op.to_canonical_json().unwrap();
210        let parsed = Operation::from_json(&json).unwrap();
211        assert_eq!(op, parsed);
212    }
213
214    #[test]
215    fn merge_round_trip() {
216        let op = Operation {
217            parent_ids: vec![git_oid('e'), git_oid('f')],
218            workspace_id: ws("default"),
219            timestamp: timestamp(),
220            payload: OpPayload::Merge {
221                sources: vec![ws("agent-1"), ws("agent-2")],
222                epoch_before: epoch('a'),
223                epoch_after: epoch('b'),
224            },
225        };
226        let json = op.to_canonical_json().unwrap();
227        let parsed = Operation::from_json(&json).unwrap();
228        assert_eq!(op, parsed);
229    }
230
231    #[test]
232    fn compensate_round_trip() {
233        let op = Operation {
234            parent_ids: vec![git_oid('c')],
235            workspace_id: ws("agent-1"),
236            timestamp: timestamp(),
237            payload: OpPayload::Compensate {
238                target_op: git_oid('a'),
239                reason: "reverted broken snapshot".to_owned(),
240            },
241        };
242        let json = op.to_canonical_json().unwrap();
243        let parsed = Operation::from_json(&json).unwrap();
244        assert_eq!(op, parsed);
245    }
246
247    #[test]
248    fn describe_round_trip() {
249        let op = Operation {
250            parent_ids: vec![git_oid('d')],
251            workspace_id: ws("agent-1"),
252            timestamp: timestamp(),
253            payload: OpPayload::Describe {
254                message: "implementing auth module".to_owned(),
255            },
256        };
257        let json = op.to_canonical_json().unwrap();
258        let parsed = Operation::from_json(&json).unwrap();
259        assert_eq!(op, parsed);
260    }
261
262    #[test]
263    fn annotate_round_trip() {
264        let mut data = BTreeMap::new();
265        data.insert("passed".to_owned(), serde_json::Value::Bool(true));
266        data.insert(
267            "duration_ms".to_owned(),
268            serde_json::Value::Number(1234.into()),
269        );
270        data.insert(
271            "command".to_owned(),
272            serde_json::Value::String("cargo test".to_owned()),
273        );
274
275        let op = Operation {
276            parent_ids: vec![git_oid('e')],
277            workspace_id: ws("default"),
278            timestamp: timestamp(),
279            payload: OpPayload::Annotate {
280                key: "validation".to_owned(),
281                data,
282            },
283        };
284        let json = op.to_canonical_json().unwrap();
285        let parsed = Operation::from_json(&json).unwrap();
286        assert_eq!(op, parsed);
287    }
288
289    // -----------------------------------------------------------------------
290    // Canonical JSON determinism
291    // -----------------------------------------------------------------------
292
293    #[test]
294    fn canonical_json_is_deterministic() {
295        let mut data = BTreeMap::new();
296        data.insert("z_key".to_owned(), serde_json::Value::Bool(false));
297        data.insert("a_key".to_owned(), serde_json::Value::Bool(true));
298        data.insert(
299            "m_key".to_owned(),
300            serde_json::Value::String("hello".to_owned()),
301        );
302
303        let op = Operation {
304            parent_ids: vec![git_oid('a'), git_oid('b')],
305            workspace_id: ws("agent-1"),
306            timestamp: timestamp(),
307            payload: OpPayload::Annotate {
308                key: "test".to_owned(),
309                data,
310            },
311        };
312
313        let json1 = op.to_canonical_json().unwrap();
314        let json2 = op.to_canonical_json().unwrap();
315        assert_eq!(json1, json2, "canonical JSON must be deterministic");
316
317        // Verify BTreeMap keys are sorted in output
318        let json_str = String::from_utf8(json1).unwrap();
319        let a_pos = json_str.find("\"a_key\"").unwrap();
320        let m_pos = json_str.find("\"m_key\"").unwrap();
321        let z_pos = json_str.find("\"z_key\"").unwrap();
322        assert!(a_pos < m_pos, "a_key should come before m_key");
323        assert!(m_pos < z_pos, "m_key should come before z_key");
324    }
325
326    #[test]
327    fn canonical_json_sorted_keys_in_annotate() {
328        let mut data = BTreeMap::new();
329        data.insert("zebra".to_owned(), serde_json::Value::Null);
330        data.insert("apple".to_owned(), serde_json::Value::Null);
331
332        let op = Operation {
333            parent_ids: vec![],
334            workspace_id: ws("w"),
335            timestamp: timestamp(),
336            payload: OpPayload::Annotate {
337                key: "test".to_owned(),
338                data,
339            },
340        };
341
342        let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
343        let apple_pos = json.find("\"apple\"").unwrap();
344        let zebra_pos = json.find("\"zebra\"").unwrap();
345        assert!(
346            apple_pos < zebra_pos,
347            "BTreeMap keys must be sorted: apple < zebra"
348        );
349    }
350
351    // -----------------------------------------------------------------------
352    // Payload type tag verification
353    // -----------------------------------------------------------------------
354
355    #[test]
356    fn payload_type_tag_create() {
357        let op = Operation {
358            parent_ids: vec![],
359            workspace_id: ws("w"),
360            timestamp: timestamp(),
361            payload: OpPayload::Create { epoch: epoch('a') },
362        };
363        let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
364        assert!(
365            json.contains("\"type\":\"create\""),
366            "Create variant should have type:create tag"
367        );
368    }
369
370    #[test]
371    fn payload_type_tag_destroy() {
372        let op = Operation {
373            parent_ids: vec![],
374            workspace_id: ws("w"),
375            timestamp: timestamp(),
376            payload: OpPayload::Destroy,
377        };
378        let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
379        assert!(
380            json.contains("\"type\":\"destroy\""),
381            "Destroy variant should have type:destroy tag"
382        );
383    }
384
385    #[test]
386    fn payload_type_tag_snapshot() {
387        let op = Operation {
388            parent_ids: vec![],
389            workspace_id: ws("w"),
390            timestamp: timestamp(),
391            payload: OpPayload::Snapshot {
392                patch_set_oid: git_oid('a'),
393            },
394        };
395        let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
396        assert!(
397            json.contains("\"type\":\"snapshot\""),
398            "Snapshot variant should have type:snapshot tag"
399        );
400    }
401
402    #[test]
403    fn payload_type_tag_merge() {
404        let op = Operation {
405            parent_ids: vec![],
406            workspace_id: ws("w"),
407            timestamp: timestamp(),
408            payload: OpPayload::Merge {
409                sources: vec![],
410                epoch_before: epoch('a'),
411                epoch_after: epoch('b'),
412            },
413        };
414        let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
415        assert!(
416            json.contains("\"type\":\"merge\""),
417            "Merge variant should have type:merge tag"
418        );
419    }
420
421    #[test]
422    fn payload_type_tag_compensate() {
423        let op = Operation {
424            parent_ids: vec![],
425            workspace_id: ws("w"),
426            timestamp: timestamp(),
427            payload: OpPayload::Compensate {
428                target_op: git_oid('a'),
429                reason: "test".to_owned(),
430            },
431        };
432        let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
433        assert!(
434            json.contains("\"type\":\"compensate\""),
435            "Compensate variant should have type:compensate tag"
436        );
437    }
438
439    #[test]
440    fn payload_type_tag_describe() {
441        let op = Operation {
442            parent_ids: vec![],
443            workspace_id: ws("w"),
444            timestamp: timestamp(),
445            payload: OpPayload::Describe {
446                message: "hello".to_owned(),
447            },
448        };
449        let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
450        assert!(
451            json.contains("\"type\":\"describe\""),
452            "Describe variant should have type:describe tag"
453        );
454    }
455
456    #[test]
457    fn payload_type_tag_annotate() {
458        let op = Operation {
459            parent_ids: vec![],
460            workspace_id: ws("w"),
461            timestamp: timestamp(),
462            payload: OpPayload::Annotate {
463                key: "k".to_owned(),
464                data: BTreeMap::new(),
465            },
466        };
467        let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
468        assert!(
469            json.contains("\"type\":\"annotate\""),
470            "Annotate variant should have type:annotate tag"
471        );
472    }
473
474    // -----------------------------------------------------------------------
475    // Edge cases
476    // -----------------------------------------------------------------------
477
478    #[test]
479    fn empty_parent_ids() {
480        let op = Operation {
481            parent_ids: vec![],
482            workspace_id: ws("first"),
483            timestamp: timestamp(),
484            payload: OpPayload::Create { epoch: epoch('a') },
485        };
486        let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
487        assert!(json.contains("\"parent_ids\":[]"));
488    }
489
490    #[test]
491    fn multiple_parent_ids() {
492        let op = Operation {
493            parent_ids: vec![git_oid('a'), git_oid('b'), git_oid('c')],
494            workspace_id: ws("merged"),
495            timestamp: timestamp(),
496            payload: OpPayload::Merge {
497                sources: vec![ws("w1"), ws("w2"), ws("w3")],
498                epoch_before: epoch('d'),
499                epoch_after: epoch('e'),
500            },
501        };
502        let json = op.to_canonical_json().unwrap();
503        let parsed = Operation::from_json(&json).unwrap();
504        assert_eq!(parsed.parent_ids.len(), 3);
505        assert_eq!(parsed.payload, op.payload);
506    }
507
508    #[test]
509    fn describe_with_newlines_and_unicode() {
510        let op = Operation {
511            parent_ids: vec![],
512            workspace_id: ws("w"),
513            timestamp: timestamp(),
514            payload: OpPayload::Describe {
515                message: "line 1\nline 2\n日本語".to_owned(),
516            },
517        };
518        let json = op.to_canonical_json().unwrap();
519        let parsed = Operation::from_json(&json).unwrap();
520        assert_eq!(op, parsed);
521    }
522}