Skip to main content

spreadsheet_kit/core/
events.rs

1//! Universal OpEvent envelope and opcode registry for event-sourced sessions.
2//!
3//! Every effectful action is expressed as a normalized [`OpEvent`], which records
4//! intent, provenance, preconditions, and calculated impact. Events are appended
5//! to a JSONL binlog and replayed to reconstruct workbook state.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::collections::BTreeMap;
11
12/// Current schema version for OpEvent serialization.
13pub const SCHEMA_VERSION: &str = "ops.v1";
14
15// ---------------------------------------------------------------------------
16// OpEvent envelope
17// ---------------------------------------------------------------------------
18
19/// Universal event envelope wrapping every effectful workbook operation.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct OpEvent {
22    /// Schema version tag for forward-compatible parsing.
23    pub schema_version: String,
24
25    /// Unique event identifier (ULID-style sorted string).
26    pub op_id: String,
27
28    /// Identifier of the previous event in this branch (forms a linked list).
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub parent_id: Option<String>,
31
32    /// Session this event belongs to.
33    pub session_id: String,
34
35    /// Wall-clock timestamp of event creation.
36    pub timestamp: DateTime<Utc>,
37
38    /// Who or what created this event.
39    pub actor: Actor,
40
41    /// Namespaced operation kind (e.g. `structure.clone_row`).
42    pub kind: OpKind,
43
44    /// Operation-specific payload (JSON object).
45    pub payload: serde_json::Value,
46
47    /// SHA-256 hash over the canonicalized payload JSON.
48    pub canonical_payload_hash: String,
49
50    /// Pre-apply validation constraints.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub preconditions: Option<Preconditions>,
53
54    /// Computed impact from dry-run staging (populated during stage, before apply).
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub dry_run_impact: Option<DryRunImpact>,
57
58    /// Result metadata after applying the event.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub apply_result: Option<ApplyResult>,
61
62    /// Hash of the previous event record (for tamper-detection chains).
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub prev_event_hash: Option<String>,
65
66    /// Hash of this entire event record (computed after serialization).
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub event_hash: Option<String>,
69
70    /// Forward-compatible catch-all for unknown fields.
71    #[serde(flatten)]
72    pub extra: BTreeMap<String, serde_json::Value>,
73}
74
75// ---------------------------------------------------------------------------
76// Actor
77// ---------------------------------------------------------------------------
78
79/// Identity of the entity that created an event.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Actor {
82    /// Actor identifier (e.g. `agent:fp_and_a_bot`, `user:jane`).
83    pub id: String,
84
85    /// Optional run/session identifier for the actor's execution context.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub run_id: Option<String>,
88
89    /// Source surface that produced the event (`cli`, `mcp`, `sdk`, `wasm`).
90    #[serde(default = "default_source")]
91    pub source: String,
92}
93
94fn default_source() -> String {
95    "cli".to_string()
96}
97
98// ---------------------------------------------------------------------------
99// OpKind — namespaced operation discriminator
100// ---------------------------------------------------------------------------
101
102/// Namespaced operation kind covering all write families.
103///
104/// The string representation uses dot-separated namespaces, e.g.
105/// `structure.insert_rows`, `transform.write_matrix`, `name.define`.
106#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
107#[serde(transparent)]
108pub struct OpKind(pub String);
109
110impl OpKind {
111    pub fn new(namespace: &str, action: &str) -> Self {
112        Self(format!("{}.{}", namespace, action))
113    }
114
115    pub fn namespace(&self) -> &str {
116        self.0.split('.').next().unwrap_or(&self.0)
117    }
118
119    pub fn action(&self) -> &str {
120        self.0.split('.').nth(1).unwrap_or("")
121    }
122
123    // -- Structure family --
124    pub fn structure_insert_rows() -> Self {
125        Self::new("structure", "insert_rows")
126    }
127    pub fn structure_delete_rows() -> Self {
128        Self::new("structure", "delete_rows")
129    }
130    pub fn structure_insert_cols() -> Self {
131        Self::new("structure", "insert_cols")
132    }
133    pub fn structure_delete_cols() -> Self {
134        Self::new("structure", "delete_cols")
135    }
136    pub fn structure_clone_row() -> Self {
137        Self::new("structure", "clone_row")
138    }
139    pub fn structure_merge_cells() -> Self {
140        Self::new("structure", "merge_cells")
141    }
142    pub fn structure_unmerge_cells() -> Self {
143        Self::new("structure", "unmerge_cells")
144    }
145    pub fn structure_rename_sheet() -> Self {
146        Self::new("structure", "rename_sheet")
147    }
148    pub fn structure_create_sheet() -> Self {
149        Self::new("structure", "create_sheet")
150    }
151    pub fn structure_delete_sheet() -> Self {
152        Self::new("structure", "delete_sheet")
153    }
154    pub fn structure_copy_range() -> Self {
155        Self::new("structure", "copy_range")
156    }
157    pub fn structure_move_range() -> Self {
158        Self::new("structure", "move_range")
159    }
160
161    // -- Transform family --
162    pub fn transform_clear_range() -> Self {
163        Self::new("transform", "clear_range")
164    }
165    pub fn transform_fill_range() -> Self {
166        Self::new("transform", "fill_range")
167    }
168    pub fn transform_replace_in_range() -> Self {
169        Self::new("transform", "replace_in_range")
170    }
171    pub fn transform_write_matrix() -> Self {
172        Self::new("transform", "write_matrix")
173    }
174
175    // -- Style family --
176    pub fn style_apply() -> Self {
177        Self::new("style", "apply")
178    }
179
180    // -- Formula pattern family --
181    pub fn formula_apply_pattern() -> Self {
182        Self::new("formula", "apply_pattern")
183    }
184    pub fn formula_replace_in_formulas() -> Self {
185        Self::new("formula", "replace_in_formulas")
186    }
187
188    // -- Column sizing family --
189    pub fn column_size() -> Self {
190        Self::new("column", "size")
191    }
192
193    // -- Sheet layout family --
194    pub fn layout_apply() -> Self {
195        Self::new("layout", "apply")
196    }
197
198    // -- Rules family --
199    pub fn rules_apply() -> Self {
200        Self::new("rules", "apply")
201    }
202
203    // -- Name family --
204    pub fn name_define() -> Self {
205        Self::new("name", "define")
206    }
207    pub fn name_update() -> Self {
208        Self::new("name", "update")
209    }
210    pub fn name_delete() -> Self {
211        Self::new("name", "delete")
212    }
213
214    // -- Edit family (shorthand cell edits) --
215    pub fn edit_batch() -> Self {
216        Self::new("edit", "batch")
217    }
218
219    // -- Import family --
220    pub fn import_range() -> Self {
221        Self::new("import", "range")
222    }
223    pub fn import_grid() -> Self {
224        Self::new("import", "grid")
225    }
226
227    // -- Session meta --
228    pub fn session_materialize() -> Self {
229        Self::new("session", "materialize")
230    }
231}
232
233impl std::fmt::Display for OpKind {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        f.write_str(&self.0)
236    }
237}
238
239// ---------------------------------------------------------------------------
240// Preconditions
241// ---------------------------------------------------------------------------
242
243/// Pre-apply validation constraints attached to an event.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct Preconditions {
246    /// Cell value assertions that must hold before applying.
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub cell_matches: Vec<CellMatch>,
249
250    /// Expected workbook content hash before applying.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub workbook_hash_before: Option<String>,
253
254    /// The HEAD op_id that this event was staged against.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub head_at_stage: Option<String>,
257}
258
259/// A single cell value assertion for precondition checks.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct CellMatch {
262    /// Cell address in `Sheet!A1` notation.
263    pub address: String,
264    /// Expected cell display value.
265    pub value: serde_json::Value,
266}
267
268// ---------------------------------------------------------------------------
269// DryRunImpact
270// ---------------------------------------------------------------------------
271
272/// Computed impact from staging an operation (dry-run analysis).
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct DryRunImpact {
275    pub cells_changed: u64,
276    pub formulas_rewritten: u64,
277
278    #[serde(default, skip_serializing_if = "Vec::is_empty")]
279    pub shifted_spans: Vec<ShiftedSpan>,
280
281    pub ref_errors_generated: u64,
282
283    #[serde(default, skip_serializing_if = "Vec::is_empty")]
284    pub warnings: Vec<String>,
285
286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
287    pub boundary_warnings: Vec<String>,
288}
289
290/// Describes a row/column shift caused by a structural operation.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct ShiftedSpan {
293    pub op_index: usize,
294    pub sheet_name: String,
295    /// `"row"` or `"col"`.
296    pub axis: String,
297    pub at: u32,
298    pub count: u32,
299    /// `"insert"` or `"delete"`.
300    pub direction: String,
301    pub description: String,
302}
303
304// ---------------------------------------------------------------------------
305// ApplyResult
306// ---------------------------------------------------------------------------
307
308/// Metadata recorded after applying an event to the workbook.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct ApplyResult {
311    /// `applied`, `rejected`, or `superseded`.
312    pub status: ApplyStatus,
313    pub duration_ms: u64,
314
315    #[serde(default, skip_serializing_if = "Vec::is_empty")]
316    pub warnings: Vec<String>,
317
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub workbook_hash_after: Option<String>,
320}
321
322/// Outcome status of applying an event.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(rename_all = "snake_case")]
325pub enum ApplyStatus {
326    Applied,
327    Rejected,
328    Superseded,
329}
330
331// ---------------------------------------------------------------------------
332// Canonical hashing
333// ---------------------------------------------------------------------------
334
335/// Compute SHA-256 hash over a canonicalized JSON payload.
336///
337/// Canonicalization: serialize with sorted keys and no extra whitespace.
338pub fn canonical_payload_hash(payload: &serde_json::Value) -> String {
339    let canonical = canonical_json(payload);
340    let hash = Sha256::digest(canonical.as_bytes());
341    format!("sha256:{:x}", hash)
342}
343
344/// Produce a canonical JSON string with sorted keys.
345fn canonical_json(value: &serde_json::Value) -> String {
346    match value {
347        serde_json::Value::Object(map) => {
348            let mut sorted: Vec<(&String, &serde_json::Value)> = map.iter().collect();
349            sorted.sort_by_key(|(k, _)| *k);
350            let entries: Vec<String> = sorted
351                .into_iter()
352                .map(|(k, v)| {
353                    format!(
354                        "{}:{}",
355                        serde_json::to_string(k).unwrap(),
356                        canonical_json(v)
357                    )
358                })
359                .collect();
360            format!("{{{}}}", entries.join(","))
361        }
362        serde_json::Value::Array(arr) => {
363            let entries: Vec<String> = arr.iter().map(canonical_json).collect();
364            format!("[{}]", entries.join(","))
365        }
366        _ => serde_json::to_string(value).unwrap_or_default(),
367    }
368}
369
370// ---------------------------------------------------------------------------
371// OpEvent builder
372// ---------------------------------------------------------------------------
373
374impl OpEvent {
375    /// Create a new event with the given parameters and compute the payload hash.
376    pub fn new(
377        session_id: String,
378        parent_id: Option<String>,
379        actor: Actor,
380        kind: OpKind,
381        payload: serde_json::Value,
382    ) -> Self {
383        let hash = canonical_payload_hash(&payload);
384        let op_id = make_op_id();
385        Self {
386            schema_version: SCHEMA_VERSION.to_string(),
387            op_id,
388            parent_id,
389            session_id,
390            timestamp: Utc::now(),
391            actor,
392            kind,
393            payload,
394            canonical_payload_hash: hash,
395            preconditions: None,
396            dry_run_impact: None,
397            apply_result: None,
398            prev_event_hash: None,
399            event_hash: None,
400            extra: BTreeMap::new(),
401        }
402    }
403
404    /// Attach preconditions to this event.
405    pub fn with_preconditions(mut self, preconditions: Preconditions) -> Self {
406        self.preconditions = Some(preconditions);
407        self
408    }
409
410    /// Attach dry-run impact to this event.
411    pub fn with_dry_run_impact(mut self, impact: DryRunImpact) -> Self {
412        self.dry_run_impact = Some(impact);
413        self
414    }
415
416    /// Record the apply result.
417    pub fn with_apply_result(mut self, result: ApplyResult) -> Self {
418        self.apply_result = Some(result);
419        self
420    }
421
422    /// Compute and set the event hash over the full serialized record.
423    pub fn seal(&mut self) {
424        let json = serde_json::to_string(self).unwrap_or_default();
425        let hash = Sha256::digest(json.as_bytes());
426        self.event_hash = Some(format!("sha256:{:x}", hash));
427    }
428
429    /// Validate schema version compatibility.
430    pub fn validate_version(&self) -> Result<(), String> {
431        if self.schema_version != SCHEMA_VERSION {
432            return Err(format!(
433                "unsupported schema version '{}' (expected '{}')",
434                self.schema_version, SCHEMA_VERSION
435            ));
436        }
437        Ok(())
438    }
439}
440
441/// Generate a time-sortable unique operation ID.
442fn make_op_id() -> String {
443    use std::time::{SystemTime, UNIX_EPOCH};
444    let ts = SystemTime::now()
445        .duration_since(UNIX_EPOCH)
446        .unwrap_or_default()
447        .as_millis();
448    let rand_suffix: u32 = rand::random();
449    format!("op_{:013x}_{:08x}", ts, rand_suffix)
450}
451
452// ---------------------------------------------------------------------------
453// Tests
454// ---------------------------------------------------------------------------
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use serde_json::json;
460
461    #[test]
462    fn op_event_roundtrip() {
463        let event = OpEvent::new(
464            "sess_test".to_string(),
465            None,
466            Actor {
467                id: "test:agent".to_string(),
468                run_id: None,
469                source: "cli".to_string(),
470            },
471            OpKind::structure_clone_row(),
472            json!({
473                "sheet_name": "Provider",
474                "source_row": 85,
475                "insert_at": 86
476            }),
477        );
478
479        let serialized = serde_json::to_string(&event).unwrap();
480        let deserialized: OpEvent = serde_json::from_str(&serialized).unwrap();
481
482        assert_eq!(deserialized.schema_version, SCHEMA_VERSION);
483        assert_eq!(deserialized.kind, OpKind::structure_clone_row());
484        assert_eq!(deserialized.session_id, "sess_test");
485        assert!(deserialized.canonical_payload_hash.starts_with("sha256:"));
486    }
487
488    #[test]
489    fn canonical_hash_is_deterministic() {
490        let payload_a = json!({"b": 2, "a": 1});
491        let payload_b = json!({"a": 1, "b": 2});
492        assert_eq!(
493            canonical_payload_hash(&payload_a),
494            canonical_payload_hash(&payload_b)
495        );
496    }
497
498    #[test]
499    fn op_kind_namespace_and_action() {
500        let kind = OpKind::structure_clone_row();
501        assert_eq!(kind.namespace(), "structure");
502        assert_eq!(kind.action(), "clone_row");
503        assert_eq!(kind.to_string(), "structure.clone_row");
504    }
505
506    #[test]
507    fn unknown_version_rejected() {
508        let mut event = OpEvent::new(
509            "sess_test".to_string(),
510            None,
511            Actor {
512                id: "test".to_string(),
513                run_id: None,
514                source: "cli".to_string(),
515            },
516            OpKind::edit_batch(),
517            json!({}),
518        );
519        event.schema_version = "ops.v999".to_string();
520        assert!(event.validate_version().is_err());
521    }
522
523    #[test]
524    fn unknown_fields_tolerated() {
525        let json_str = r#"{
526            "schema_version": "ops.v1",
527            "op_id": "op_test",
528            "session_id": "sess_test",
529            "timestamp": "2024-10-24T10:00:00Z",
530            "actor": {"id": "test", "source": "cli"},
531            "kind": "structure.clone_row",
532            "payload": {},
533            "canonical_payload_hash": "sha256:abc",
534            "future_field": "should be preserved"
535        }"#;
536
537        let event: OpEvent = serde_json::from_str(json_str).unwrap();
538        assert_eq!(
539            event.extra.get("future_field").unwrap(),
540            "should be preserved"
541        );
542    }
543
544    #[test]
545    fn seal_produces_event_hash() {
546        let mut event = OpEvent::new(
547            "sess_test".to_string(),
548            None,
549            Actor {
550                id: "test".to_string(),
551                run_id: None,
552                source: "cli".to_string(),
553            },
554            OpKind::edit_batch(),
555            json!({"cell": "A1", "value": 42}),
556        );
557        assert!(event.event_hash.is_none());
558        event.seal();
559        assert!(event.event_hash.is_some());
560        assert!(event.event_hash.as_ref().unwrap().starts_with("sha256:"));
561    }
562}