Skip to main content

contextqa_core/
event.rs

1//! Event model — append-only mutation log for the context graph.
2//!
3//! Every mutation to the context is an event. The event log is the single
4//! source of truth — the graph is always rebuildable from events.
5//!
6//! Design: Datomic-inspired immutable datom model where each fact is
7//! (Entity, Attribute, Value, Transaction, Added/Retracted), mapped to
8//! our domain as typed context mutation events.
9//!
10//! Schema evolution uses additive-only changes plus upcasting.
11//! Reference: Event Sourcing best practices (Microsoft, Greg Young)
12
13use crate::edge::RelationType;
14use crate::entity::{DiscoverySource, EntityId, EntityKind};
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18
19/// A mutation event in the context graph's append-only log.
20///
21/// Every field change, relationship addition, or confidence update
22/// is captured as a typed event with full provenance.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct UcmEvent {
25    /// Unique event identifier (UUID v7 for time-ordering)
26    pub event_id: Uuid,
27    /// When this event occurred
28    pub timestamp: DateTime<Utc>,
29    /// Which event caused this one (for causation chains)
30    pub causation_id: Option<Uuid>,
31    /// Schema version for upcasting support
32    pub schema_version: u32,
33    /// The actual mutation
34    pub payload: EventPayload,
35}
36
37/// The typed payload of a context event.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub enum EventPayload {
40    /// A new entity was discovered in the codebase
41    EntityDiscovered {
42        entity_id: EntityId,
43        kind: EntityKind,
44        name: String,
45        file_path: String,
46        language: String,
47        source: DiscoverySource,
48        line_range: Option<(usize, usize)>,
49    },
50
51    /// An entity was removed (file deleted, function removed, etc.)
52    EntityRemoved { entity_id: EntityId, reason: String },
53
54    /// A relationship between entities was discovered
55    DependencyLinked {
56        source_entity: EntityId,
57        target_entity: EntityId,
58        relation_type: RelationType,
59        confidence: f64,
60        source: DiscoverySource,
61        description: String,
62    },
63
64    /// New evidence was added to an existing edge
65    ConfidenceUpdated {
66        source_entity: EntityId,
67        target_entity: EntityId,
68        new_evidence_confidence: f64,
69        source: DiscoverySource,
70        description: String,
71    },
72
73    /// A code change was detected
74    ChangeDetected {
75        file_path: String,
76        change_type: ChangeType,
77        affected_entities: Vec<EntityId>,
78        before_snapshot: Option<String>,
79        after_snapshot: Option<String>,
80    },
81
82    /// A conflict or ambiguity was flagged
83    ConflictFlagged {
84        entity_id: EntityId,
85        conflict_type: ConflictType,
86        sources: Vec<ConflictSource>,
87        description: String,
88    },
89
90    /// An edge was verified (resets decay timer)
91    EdgeVerified {
92        source_entity: EntityId,
93        target_entity: EntityId,
94    },
95
96    /// Batch ingestion completed
97    IngestionCompleted {
98        source: DiscoverySource,
99        entities_count: usize,
100        edges_count: usize,
101        duration_ms: u64,
102    },
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub enum ChangeType {
107    /// Function/method signature changed
108    SignatureChange,
109    /// Function/method body changed
110    BodyChange,
111    /// New entity added
112    EntityAdded,
113    /// Entity deleted
114    EntityDeleted,
115    /// File renamed/moved
116    FileRenamed { old_path: String },
117    /// Import added/removed
118    ImportChange,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub enum ConflictType {
123    /// Requirements say X but code does Y
124    RequirementDrift,
125    /// Two sources provide contradictory information
126    SourceConflict,
127    /// Expected data is missing
128    MissingData,
129    /// Coverage gap — code exists but no test covers it
130    CoverageGap,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ConflictSource {
135    pub source_type: String,
136    pub claimed_value: String,
137    pub confidence: f64,
138}
139
140impl UcmEvent {
141    /// Create a new event with auto-generated UUID v7 and current timestamp.
142    pub fn new(payload: EventPayload) -> Self {
143        Self {
144            event_id: Uuid::now_v7(),
145            timestamp: Utc::now(),
146            causation_id: None,
147            schema_version: 1,
148            payload,
149        }
150    }
151
152    /// Create a new event with a causation chain link.
153    pub fn caused_by(payload: EventPayload, parent: Uuid) -> Self {
154        Self {
155            event_id: Uuid::now_v7(),
156            timestamp: Utc::now(),
157            causation_id: Some(parent),
158            schema_version: 1,
159            payload,
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_event_creation() {
170        let event = UcmEvent::new(EventPayload::EntityDiscovered {
171            entity_id: EntityId::local("src/auth/service.ts", "validateToken"),
172            kind: EntityKind::Function {
173                is_async: true,
174                parameter_count: 1,
175                return_type: Some("boolean".into()),
176            },
177            name: "validateToken".into(),
178            file_path: "src/auth/service.ts".into(),
179            language: "typescript".into(),
180            source: DiscoverySource::StaticAnalysis,
181            line_range: Some((10, 25)),
182        });
183        assert_eq!(event.schema_version, 1);
184        assert!(event.causation_id.is_none());
185    }
186
187    #[test]
188    fn test_causation_chain() {
189        let parent = UcmEvent::new(EventPayload::ChangeDetected {
190            file_path: "src/auth/service.ts".into(),
191            change_type: ChangeType::SignatureChange,
192            affected_entities: vec![EntityId::local("src/auth/service.ts", "validateToken")],
193            before_snapshot: None,
194            after_snapshot: None,
195        });
196
197        let child = UcmEvent::caused_by(
198            EventPayload::ConfidenceUpdated {
199                source_entity: EntityId::local("src/auth/service.ts", "validateToken"),
200                target_entity: EntityId::local("src/api/middleware.ts", "authMiddleware"),
201                new_evidence_confidence: 0.95,
202                source: DiscoverySource::StaticAnalysis,
203                description: "re-analyzed after change".into(),
204            },
205            parent.event_id,
206        );
207
208        assert_eq!(child.causation_id, Some(parent.event_id));
209    }
210
211    #[test]
212    fn test_event_serialization() {
213        let event = UcmEvent::new(EventPayload::DependencyLinked {
214            source_entity: EntityId::local("src/auth/service.ts", "AuthService"),
215            target_entity: EntityId::local("src/db/client.ts", "DatabaseClient"),
216            relation_type: RelationType::DependsOn,
217            confidence: 0.92,
218            source: DiscoverySource::StaticAnalysis,
219            description: "import statement found".into(),
220        });
221
222        let json = serde_json::to_string_pretty(&event).unwrap();
223        assert!(json.contains("DependencyLinked"));
224        assert!(json.contains("0.92"));
225
226        // Round-trip
227        let deserialized: UcmEvent = serde_json::from_str(&json).unwrap();
228        assert_eq!(deserialized.event_id, event.event_id);
229    }
230}