Skip to main content

hirn_engine/observability/
event.rs

1//! Event system for memory operations.
2//!
3//! Two layers:
4//! - [`MemoryEvent`] — the structured event enum covering all mutation types.
5//! - [`EventEnvelope`] — wraps a `MemoryEvent` with monotonic seq, wall-clock
6//!   timestamp, realm/namespace/agent metadata. Serializable via bincode + JSON.
7//!
8//! Subscribers receive [`MemoryEvent`] values through `mpsc` channels for
9//! real-time in-process push. The [`EventLog`](super::event_log::EventLog)
10//! persists [`EventEnvelope`] to the `events` LanceDB dataset for durable
11//! history, replay, and audit.
12
13use hirn_core::id::MemoryId;
14use hirn_core::revision::{LogicalMemoryId, RevisionId};
15use hirn_core::types::{EdgeRelation, Layer};
16
17/// An event emitted when the database state changes.
18///
19/// Covers all mutation types for event sourcing.
20/// New variants can be added without breaking old readers thanks to
21/// `#[serde(other)]` on the `Unknown` fallback.
22///
23/// Externally tagged (default) for bincode compatibility. JSON uses
24/// `{"EpisodeCreated": {...}}` style.
25#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
26pub enum MemoryEvent {
27    /// A new episodic memory was created.
28    EpisodeCreated {
29        id: MemoryId,
30        content_preview: String,
31    },
32    /// A new semantic record was created.
33    SemanticCreated { id: MemoryId, concept_name: String },
34    /// A new procedural record was created.
35    ProceduralCreated {
36        id: MemoryId,
37        procedure_name: String,
38    },
39    /// A semantic memory was corrected with a new head revision.
40    MemoryCorrected {
41        logical_memory_id: LogicalMemoryId,
42        old_revision_id: RevisionId,
43        new_revision_id: RevisionId,
44        #[serde(default)]
45        reason: Option<String>,
46    },
47    /// A semantic memory was explicitly superseded by a new head revision.
48    MemorySuperseded {
49        logical_memory_id: LogicalMemoryId,
50        prior_revision_id: RevisionId,
51        new_revision_id: RevisionId,
52        #[serde(default)]
53        reason: Option<String>,
54    },
55    /// A semantic memory head was explicitly overridden by a human/admin revision.
56    MemoryOverridden {
57        logical_memory_id: LogicalMemoryId,
58        prior_revision_id: RevisionId,
59        override_revision_id: RevisionId,
60        #[serde(default)]
61        reason: Option<String>,
62    },
63    /// One or more semantic logical memories were merged into an active target chain.
64    MemoryMerged {
65        target_logical_memory_id: LogicalMemoryId,
66        prior_target_revision_id: RevisionId,
67        new_target_revision_id: RevisionId,
68        source_logical_memory_ids: Vec<LogicalMemoryId>,
69        source_revision_ids: Vec<RevisionId>,
70        #[serde(default)]
71        reason: Option<String>,
72    },
73    /// A semantic memory was retracted via a tombstone revision.
74    MemoryRetracted {
75        logical_memory_id: LogicalMemoryId,
76        prior_revision_id: RevisionId,
77        tombstone_revision_id: RevisionId,
78        #[serde(default)]
79        reason: Option<String>,
80    },
81    /// A working memory entry was pushed.
82    WorkingPushed { id: MemoryId },
83    /// Importance score was updated.
84    ImportanceUpdated {
85        id: MemoryId,
86        old_value: f32,
87        new_value: f32,
88    },
89    /// A memory was reconsolidated (modified during labile window).
90    Reconsolidated { id: MemoryId, reason: String },
91    /// A graph edge was created.
92    EdgeCreated {
93        source: MemoryId,
94        target: MemoryId,
95        relation: EdgeRelation,
96        weight: f32,
97    },
98    /// A graph edge weight was updated.
99    EdgeWeightUpdated {
100        source: MemoryId,
101        target: MemoryId,
102        relation: EdgeRelation,
103        old_weight: f32,
104        new_weight: f32,
105    },
106    /// A memory was archived (soft-deleted).
107    Archived { id: MemoryId },
108    /// A memory was permanently forgotten (hard-deleted).
109    Forgotten { id: MemoryId },
110    /// Consolidation completed.
111    Consolidated { records_processed: usize },
112    /// A snapshot was taken.
113    SnapshotTaken { seq: u64, tag: String },
114    /// Compaction completed.
115    CompactionCompleted {
116        before_seq: u64,
117        events_removed: u64,
118    },
119    /// An admission control decision was made.
120    AdmissionEvaluated {
121        candidate_id: MemoryId,
122        decision: String,
123        controllers_consulted: Vec<String>,
124    },
125    /// A dream hypothesis was generated.
126    HypothesisGenerated {
127        id: MemoryId,
128        source_a: MemoryId,
129        source_b: MemoryId,
130        batch_id: String,
131    },
132    /// A dream hypothesis was validated and promoted.
133    HypothesisValidated {
134        id: MemoryId,
135        new_confidence: f32,
136        evidence_count: u32,
137        batch_id: String,
138    },
139    /// A dream hypothesis was discarded after validation.
140    HypothesisDiscarded {
141        id: MemoryId,
142        reason: String,
143        batch_id: String,
144    },
145    /// An authorization request was granted.
146    AccessGranted {
147        action: String,
148        realm: String,
149        namespace: String,
150        policy_ids: Vec<String>,
151    },
152    /// An authorization request was denied.
153    AccessDenied {
154        action: String,
155        realm: String,
156        namespace: String,
157        reasons: Vec<String>,
158        policy_ids: Vec<String>,
159    },
160    /// A Cedar policy was added, removed, or modified.
161    PolicyChanged {
162        policy_name: String,
163        change_type: String,
164        #[serde(default)]
165        policy_content: String,
166    },
167    /// A memory was recalled (query executed).
168    MemoryRecalled {
169        query_preview: String,
170        results_count: usize,
171    },
172    /// A contradiction was detected between two memories.
173    ContradictionDetected {
174        memory_a: MemoryId,
175        memory_b: MemoryId,
176        confidence: f32,
177    },
178    /// A causal edge was discovered during consolidation.
179    CausalEdgeDiscovered {
180        cause: MemoryId,
181        effect: MemoryId,
182        strength: f32,
183    },
184    /// An error occurred during a database operation.
185    Error { operation: String, message: String },
186    /// Unknown event variant for forward compatibility.
187    #[serde(other)]
188    Unknown,
189}
190
191impl MemoryEvent {
192    /// Event type as a short string.
193    pub fn event_type(&self) -> &'static str {
194        match self {
195            Self::EpisodeCreated { .. } => "episode_created",
196            Self::SemanticCreated { .. } => "semantic_created",
197            Self::ProceduralCreated { .. } => "procedural_created",
198            Self::MemoryCorrected { .. } => "memory_corrected",
199            Self::MemorySuperseded { .. } => "memory_superseded",
200            Self::MemoryOverridden { .. } => "memory_overridden",
201            Self::MemoryMerged { .. } => "memory_merged",
202            Self::MemoryRetracted { .. } => "memory_retracted",
203            Self::WorkingPushed { .. } => "working_pushed",
204            Self::ImportanceUpdated { .. } => "importance_updated",
205            Self::Reconsolidated { .. } => "reconsolidated",
206            Self::EdgeCreated { .. } => "edge_created",
207            Self::EdgeWeightUpdated { .. } => "edge_weight_updated",
208            Self::Archived { .. } => "archived",
209            Self::Forgotten { .. } => "forgotten",
210            Self::Consolidated { .. } => "consolidated",
211            Self::SnapshotTaken { .. } => "snapshot_taken",
212            Self::CompactionCompleted { .. } => "compaction_completed",
213            Self::AdmissionEvaluated { .. } => "admission_evaluated",
214            Self::HypothesisGenerated { .. } => "hypothesis_generated",
215            Self::HypothesisValidated { .. } => "hypothesis_validated",
216            Self::HypothesisDiscarded { .. } => "hypothesis_discarded",
217            Self::AccessGranted { .. } => "access_granted",
218            Self::AccessDenied { .. } => "access_denied",
219            Self::PolicyChanged { .. } => "policy_changed",
220            Self::MemoryRecalled { .. } => "memory_recalled",
221            Self::ContradictionDetected { .. } => "contradiction_detected",
222            Self::CausalEdgeDiscovered { .. } => "causal_edge_discovered",
223            Self::Error { .. } => "error",
224            Self::Unknown => "unknown",
225        }
226    }
227
228    /// Whether this event should be appended to the durable event log.
229    pub fn should_persist(&self) -> bool {
230        !matches!(self, Self::MemoryRecalled { .. })
231    }
232
233    /// The layer this event affects, if applicable.
234    pub fn layer(&self) -> Option<Layer> {
235        match self {
236            Self::EpisodeCreated { .. } => Some(Layer::Episodic),
237            Self::SemanticCreated { .. } => Some(Layer::Semantic),
238            Self::ProceduralCreated { .. } => Some(Layer::Procedural),
239            Self::MemoryCorrected { .. } => Some(Layer::Semantic),
240            Self::MemorySuperseded { .. } => Some(Layer::Semantic),
241            Self::MemoryOverridden { .. } => Some(Layer::Semantic),
242            Self::MemoryMerged { .. } => Some(Layer::Semantic),
243            Self::MemoryRetracted { .. } => Some(Layer::Semantic),
244            Self::WorkingPushed { .. } => Some(Layer::Working),
245            _ => None,
246        }
247    }
248}
249
250// ── Event Envelope ───────────────────────────────────────────────────────
251
252/// A durable event wrapper with monotonic sequence number and metadata.
253///
254/// Envelopes are stored in the `events` LanceDB dataset for replay, audit,
255/// and streaming.
256///
257/// When a realm secret is configured, each envelope carries an HMAC-SHA256
258/// tag computed over the bincode-serialized payload. This enables tamper
259/// detection on audit events.
260#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
261pub struct EventEnvelope {
262    /// Monotonically increasing sequence number (gap-free per writer).
263    pub seq: u64,
264    /// Wall-clock microsecond timestamp.
265    pub timestamp_us: i64,
266    /// Realm (tenant) isolation.
267    pub realm: String,
268    /// Namespace within the realm.
269    pub namespace: String,
270    /// Agent that triggered the mutation.
271    pub agent_id: String,
272    /// The actual event payload.
273    pub event: MemoryEvent,
274    /// HMAC-SHA256 tag for tamper detection (hex-encoded).
275    /// `None` when no realm secret is configured.
276    #[serde(default)]
277    pub hmac: Option<String>,
278}
279
280impl EventEnvelope {
281    /// Create a new envelope with the given metadata.
282    pub fn new(
283        seq: u64,
284        realm: impl Into<String>,
285        namespace: impl Into<String>,
286        agent_id: impl Into<String>,
287        event: MemoryEvent,
288    ) -> Self {
289        let now = chrono::Utc::now();
290        Self {
291            seq,
292            timestamp_us: now.timestamp_micros(),
293            realm: realm.into(),
294            namespace: namespace.into(),
295            agent_id: agent_id.into(),
296            event,
297            hmac: None,
298        }
299    }
300
301    /// Compute and attach an HMAC-SHA256 tag using the given secret.
302    ///
303    /// The HMAC is computed over the bincode-serialized event payload
304    /// concatenated with the envelope metadata (seq, timestamp, realm,
305    /// namespace, agent_id). This ensures any tampering with the envelope
306    /// is detectable.
307    pub fn sign(&mut self, secret: &[u8]) {
308        let bytes = self.signable_bytes();
309        let tag = Self::compute_hmac(secret, &bytes);
310        self.hmac = Some(tag);
311    }
312
313    /// Verify the HMAC tag against the given secret.
314    ///
315    /// Returns `true` if the HMAC matches, `false` if tampered or missing.
316    pub fn verify_hmac(&self, secret: &[u8]) -> bool {
317        let Some(ref stored_hmac) = self.hmac else {
318            return false;
319        };
320        let bytes = self.signable_bytes();
321        let expected = Self::compute_hmac(secret, &bytes);
322        // Constant-time comparison to prevent timing attacks.
323        constant_time_eq(stored_hmac.as_bytes(), expected.as_bytes())
324    }
325
326    /// The bytes that are signed/verified by HMAC.
327    fn signable_bytes(&self) -> Vec<u8> {
328        let mut buf = Vec::with_capacity(256);
329        buf.extend_from_slice(&self.seq.to_le_bytes());
330        buf.extend_from_slice(&self.timestamp_us.to_le_bytes());
331        buf.extend_from_slice(self.realm.as_bytes());
332        buf.push(0); // separator
333        buf.extend_from_slice(self.namespace.as_bytes());
334        buf.push(0);
335        buf.extend_from_slice(self.agent_id.as_bytes());
336        buf.push(0);
337        if let Ok(payload) = bincode::serialize(&self.event) {
338            buf.extend_from_slice(&payload);
339        }
340        buf
341    }
342
343    /// Compute HMAC-SHA256 using blake3 keyed hash (256-bit, faster than
344    /// HMAC-SHA256, same security guarantees) and return as hex string.
345    fn compute_hmac(secret: &[u8], data: &[u8]) -> String {
346        // Derive a 32-byte key from the secret using blake3.
347        let key = blake3::derive_key("hirn event hmac v1", secret);
348        let hash = blake3::keyed_hash(&key, data);
349        hash.to_hex().to_string()
350    }
351
352    /// Event type string (delegates to inner event).
353    pub fn event_type(&self) -> &'static str {
354        self.event.event_type()
355    }
356
357    /// Byte size of the bincode-serialized envelope (for size budgeting).
358    pub fn bincode_size(&self) -> usize {
359        bincode::serialized_size(self).unwrap_or(0) as usize
360    }
361}
362
363/// Constant-time byte comparison to prevent timing side-channels on HMAC verification.
364fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
365    if a.len() != b.len() {
366        return false;
367    }
368    let mut diff = 0u8;
369    for (x, y) in a.iter().zip(b.iter()) {
370        diff |= x ^ y;
371    }
372    diff == 0
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn sample_id() -> MemoryId {
380        MemoryId::new()
381    }
382
383    // ── Serialization round-trips ──
384
385    #[test]
386    fn bincode_round_trip_all_variants() {
387        let variants = vec![
388            MemoryEvent::EpisodeCreated {
389                id: sample_id(),
390                content_preview: "hello world".into(),
391            },
392            MemoryEvent::SemanticCreated {
393                id: sample_id(),
394                concept_name: "Rust".into(),
395            },
396            MemoryEvent::ProceduralCreated {
397                id: sample_id(),
398                procedure_name: "deploy-to-staging".into(),
399            },
400            MemoryEvent::WorkingPushed { id: sample_id() },
401            MemoryEvent::ImportanceUpdated {
402                id: sample_id(),
403                old_value: 0.3,
404                new_value: 0.7,
405            },
406            MemoryEvent::Reconsolidated {
407                id: sample_id(),
408                reason: "new evidence".into(),
409            },
410            MemoryEvent::EdgeCreated {
411                source: sample_id(),
412                target: sample_id(),
413                relation: EdgeRelation::Causes,
414                weight: 0.8,
415            },
416            MemoryEvent::EdgeWeightUpdated {
417                source: sample_id(),
418                target: sample_id(),
419                relation: EdgeRelation::SimilarTo,
420                old_weight: 0.5,
421                new_weight: 0.9,
422            },
423            MemoryEvent::Archived { id: sample_id() },
424            MemoryEvent::Forgotten { id: sample_id() },
425            MemoryEvent::Consolidated {
426                records_processed: 42,
427            },
428            MemoryEvent::SnapshotTaken {
429                seq: 100,
430                tag: "snapshot-100".into(),
431            },
432            MemoryEvent::CompactionCompleted {
433                before_seq: 50,
434                events_removed: 50,
435            },
436            MemoryEvent::MemoryRecalled {
437                query_preview: "test query".into(),
438                results_count: 5,
439            },
440            MemoryEvent::ContradictionDetected {
441                memory_a: sample_id(),
442                memory_b: sample_id(),
443                confidence: 0.92,
444            },
445            MemoryEvent::CausalEdgeDiscovered {
446                cause: sample_id(),
447                effect: sample_id(),
448                strength: 0.75,
449            },
450            MemoryEvent::Error {
451                operation: "remember".into(),
452                message: "embedding failed".into(),
453            },
454        ];
455
456        for event in &variants {
457            let bytes = bincode::serialize(event).expect("serialize");
458            let decoded: MemoryEvent = bincode::deserialize(&bytes).expect("deserialize");
459            assert_eq!(event.event_type(), decoded.event_type());
460        }
461    }
462
463    #[test]
464    fn json_round_trip_all_variants() {
465        let variants = vec![
466            MemoryEvent::EpisodeCreated {
467                id: sample_id(),
468                content_preview: "test".into(),
469            },
470            MemoryEvent::SemanticCreated {
471                id: sample_id(),
472                concept_name: "concept".into(),
473            },
474            MemoryEvent::ProceduralCreated {
475                id: sample_id(),
476                procedure_name: "deploy-to-staging".into(),
477            },
478            MemoryEvent::WorkingPushed { id: sample_id() },
479            MemoryEvent::ImportanceUpdated {
480                id: sample_id(),
481                old_value: 0.1,
482                new_value: 0.9,
483            },
484            MemoryEvent::Reconsolidated {
485                id: sample_id(),
486                reason: "updated".into(),
487            },
488            MemoryEvent::EdgeCreated {
489                source: sample_id(),
490                target: sample_id(),
491                relation: EdgeRelation::DerivedFrom,
492                weight: 0.5,
493            },
494            MemoryEvent::EdgeWeightUpdated {
495                source: sample_id(),
496                target: sample_id(),
497                relation: EdgeRelation::Contradicts,
498                old_weight: 0.2,
499                new_weight: 0.8,
500            },
501            MemoryEvent::Archived { id: sample_id() },
502            MemoryEvent::Forgotten { id: sample_id() },
503            MemoryEvent::Consolidated {
504                records_processed: 10,
505            },
506            MemoryEvent::SnapshotTaken {
507                seq: 200,
508                tag: "snap-200".into(),
509            },
510            MemoryEvent::CompactionCompleted {
511                before_seq: 100,
512                events_removed: 100,
513            },
514            MemoryEvent::MemoryRecalled {
515                query_preview: "recall test".into(),
516                results_count: 3,
517            },
518            MemoryEvent::ContradictionDetected {
519                memory_a: sample_id(),
520                memory_b: sample_id(),
521                confidence: 0.85,
522            },
523            MemoryEvent::CausalEdgeDiscovered {
524                cause: sample_id(),
525                effect: sample_id(),
526                strength: 0.6,
527            },
528            MemoryEvent::Error {
529                operation: "consolidation".into(),
530                message: "timeout".into(),
531            },
532        ];
533
534        for event in &variants {
535            let json = serde_json::to_string(event).expect("to json");
536            let decoded: MemoryEvent = serde_json::from_str(&json).expect("from json");
537            assert_eq!(event.event_type(), decoded.event_type());
538        }
539    }
540
541    #[test]
542    fn envelope_seq_monotonic() {
543        let envelopes: Vec<EventEnvelope> = (0..100)
544            .map(|seq| {
545                EventEnvelope::new(
546                    seq,
547                    "default",
548                    "shared",
549                    "agent-1",
550                    MemoryEvent::WorkingPushed { id: sample_id() },
551                )
552            })
553            .collect();
554
555        for pair in envelopes.windows(2) {
556            assert!(
557                pair[1].seq > pair[0].seq,
558                "seq must be monotonically increasing"
559            );
560        }
561    }
562
563    #[test]
564    fn unknown_variant_forward_compatibility() {
565        // Externally-tagged enums with #[serde(other)] capture unknown
566        // unit variants. For JSON, we test via bincode with an Unknown value.
567        let event = MemoryEvent::Unknown;
568        let bytes = bincode::serialize(&event).expect("serialize");
569        let decoded: MemoryEvent = bincode::deserialize(&bytes).expect("deserialize");
570        assert_eq!(decoded.event_type(), "unknown");
571
572        // Also verify JSON round-trip of the Unknown variant itself works.
573        let json = serde_json::to_string(&event).expect("to json");
574        let decoded: MemoryEvent = serde_json::from_str(&json).expect("from json");
575        assert_eq!(decoded.event_type(), "unknown");
576    }
577
578    #[test]
579    fn envelope_bincode_round_trip() {
580        let envelope = EventEnvelope::new(
581            42,
582            "prod",
583            "default",
584            "agent-x",
585            MemoryEvent::EpisodeCreated {
586                id: sample_id(),
587                content_preview: "test episode".into(),
588            },
589        );
590
591        let bytes = bincode::serialize(&envelope).expect("serialize");
592        let decoded: EventEnvelope = bincode::deserialize(&bytes).expect("deserialize");
593        assert_eq!(decoded.seq, 42);
594        assert_eq!(decoded.realm, "prod");
595        assert_eq!(decoded.namespace, "default");
596        assert_eq!(decoded.agent_id, "agent-x");
597        assert_eq!(decoded.event.event_type(), "episode_created");
598    }
599
600    #[test]
601    fn envelope_json_round_trip() {
602        let envelope = EventEnvelope::new(
603            7,
604            "staging",
605            "team-a",
606            "agent-y",
607            MemoryEvent::Consolidated {
608                records_processed: 99,
609            },
610        );
611
612        let json = serde_json::to_string(&envelope).expect("to json");
613        let decoded: EventEnvelope = serde_json::from_str(&json).expect("from json");
614        assert_eq!(decoded.seq, 7);
615        assert_eq!(decoded.realm, "staging");
616    }
617
618    #[test]
619    fn typical_episode_created_envelope_under_2kb() {
620        let envelope = EventEnvelope::new(
621            1,
622            "default",
623            "shared",
624            "test-agent",
625            MemoryEvent::EpisodeCreated {
626                id: sample_id(),
627                content_preview: "A moderately long preview of an episodic memory entry that contains enough text to be representative of real-world usage".into(),
628            },
629        );
630
631        let size = envelope.bincode_size();
632        assert!(
633            size < 2048,
634            "EpisodeCreated envelope should be < 2KB, got {size}"
635        );
636    }
637
638    // ── Authorization event variants ──
639
640    #[test]
641    fn access_granted_event_serde() {
642        let event = MemoryEvent::AccessGranted {
643            action: "remember".into(),
644            realm: "production".into(),
645            namespace: "shared".into(),
646            policy_ids: vec!["policy0".into()],
647        };
648        assert_eq!(event.event_type(), "access_granted");
649
650        let bytes = bincode::serialize(&event).unwrap();
651        let decoded: MemoryEvent = bincode::deserialize(&bytes).unwrap();
652        assert_eq!(decoded.event_type(), "access_granted");
653
654        let json = serde_json::to_string(&event).unwrap();
655        let decoded: MemoryEvent = serde_json::from_str(&json).unwrap();
656        assert_eq!(decoded.event_type(), "access_granted");
657    }
658
659    #[test]
660    fn access_denied_event_serde() {
661        let event = MemoryEvent::AccessDenied {
662            action: "consolidate".into(),
663            realm: "production".into(),
664            namespace: "restricted".into(),
665            reasons: vec!["denied: agent cannot consolidate".into()],
666            policy_ids: vec!["forbid0".into()],
667        };
668        assert_eq!(event.event_type(), "access_denied");
669
670        let json = serde_json::to_string(&event).unwrap();
671        let decoded: MemoryEvent = serde_json::from_str(&json).unwrap();
672        if let MemoryEvent::AccessDenied { reasons, .. } = decoded {
673            assert_eq!(reasons.len(), 1);
674            assert!(reasons[0].contains("cannot consolidate"));
675        } else {
676            panic!("expected AccessDenied");
677        }
678    }
679
680    #[test]
681    fn policy_changed_event_serde() {
682        let event = MemoryEvent::PolicyChanged {
683            policy_name: "acl.cedar".into(),
684            change_type: "added".into(),
685            policy_content: "permit(principal, action, resource);".into(),
686        };
687        assert_eq!(event.event_type(), "policy_changed");
688
689        let bytes = bincode::serialize(&event).unwrap();
690        let decoded: MemoryEvent = bincode::deserialize(&bytes).unwrap();
691        assert_eq!(decoded.event_type(), "policy_changed");
692    }
693
694    // ── HMAC tamper detection ──
695
696    #[test]
697    fn hmac_sign_and_verify() {
698        let secret = b"realm-secret-key-for-testing";
699        let mut envelope = EventEnvelope::new(
700            1,
701            "production",
702            "shared",
703            "agent-007",
704            MemoryEvent::EpisodeCreated {
705                id: sample_id(),
706                content_preview: "classified intel".into(),
707            },
708        );
709
710        assert!(envelope.hmac.is_none());
711
712        envelope.sign(secret);
713        assert!(envelope.hmac.is_some());
714        assert!(envelope.verify_hmac(secret));
715    }
716
717    #[test]
718    fn hmac_detects_tampered_payload() {
719        let secret = b"realm-secret";
720        let mut envelope = EventEnvelope::new(
721            1,
722            "prod",
723            "shared",
724            "agent-007",
725            MemoryEvent::EpisodeCreated {
726                id: sample_id(),
727                content_preview: "original content".into(),
728            },
729        );
730
731        envelope.sign(secret);
732        assert!(envelope.verify_hmac(secret));
733
734        // Tamper with the event payload.
735        envelope.event = MemoryEvent::EpisodeCreated {
736            id: sample_id(),
737            content_preview: "TAMPERED content".into(),
738        };
739
740        assert!(
741            !envelope.verify_hmac(secret),
742            "tampered payload should fail HMAC"
743        );
744    }
745
746    #[test]
747    fn hmac_detects_tampered_metadata() {
748        let secret = b"realm-secret";
749        let mut envelope = EventEnvelope::new(
750            1,
751            "production",
752            "shared",
753            "agent-007",
754            MemoryEvent::Consolidated {
755                records_processed: 10,
756            },
757        );
758
759        envelope.sign(secret);
760        assert!(envelope.verify_hmac(secret));
761
762        // Tamper with agent_id.
763        envelope.agent_id = "impersonator".into();
764        assert!(
765            !envelope.verify_hmac(secret),
766            "tampered agent_id should fail HMAC"
767        );
768    }
769
770    #[test]
771    fn hmac_wrong_secret_fails() {
772        let secret = b"correct-secret";
773        let wrong = b"wrong-secret";
774        let mut envelope = EventEnvelope::new(
775            1,
776            "prod",
777            "shared",
778            "agent",
779            MemoryEvent::Forgotten { id: sample_id() },
780        );
781
782        envelope.sign(secret);
783        assert!(envelope.verify_hmac(secret));
784        assert!(!envelope.verify_hmac(wrong), "wrong secret should fail");
785    }
786
787    #[test]
788    fn hmac_missing_returns_false() {
789        let envelope = EventEnvelope::new(
790            1,
791            "prod",
792            "shared",
793            "agent",
794            MemoryEvent::WorkingPushed { id: sample_id() },
795        );
796
797        assert!(
798            !envelope.verify_hmac(b"any-secret"),
799            "missing HMAC should return false"
800        );
801    }
802
803    #[test]
804    fn hmac_on_authorization_events() {
805        let secret = b"audit-secret";
806
807        let mut granted = EventEnvelope::new(
808            10,
809            "production",
810            "shared",
811            "agent-007",
812            MemoryEvent::AccessGranted {
813                action: "recall".into(),
814                realm: "production".into(),
815                namespace: "shared".into(),
816                policy_ids: vec!["policy0".into()],
817            },
818        );
819        granted.sign(secret);
820        assert!(granted.verify_hmac(secret));
821
822        let mut denied = EventEnvelope::new(
823            11,
824            "production",
825            "restricted",
826            "intern-bot",
827            MemoryEvent::AccessDenied {
828                action: "remember".into(),
829                realm: "production".into(),
830                namespace: "restricted".into(),
831                reasons: vec!["denied by policy".into()],
832                policy_ids: vec!["forbid0".into()],
833            },
834        );
835        denied.sign(secret);
836        assert!(denied.verify_hmac(secret));
837    }
838
839    #[test]
840    fn hmac_envelope_serde_preserves_tag() {
841        let secret = b"serde-test-secret";
842        let mut envelope = EventEnvelope::new(
843            42,
844            "realm-a",
845            "ns-1",
846            "agent-x",
847            MemoryEvent::Consolidated {
848                records_processed: 5,
849            },
850        );
851        envelope.sign(secret);
852
853        // JSON round-trip preserves HMAC.
854        let json = serde_json::to_string(&envelope).unwrap();
855        let decoded: EventEnvelope = serde_json::from_str(&json).unwrap();
856        assert!(decoded.verify_hmac(secret));
857
858        // Bincode round-trip preserves HMAC.
859        let bytes = bincode::serialize(&envelope).unwrap();
860        let decoded: EventEnvelope = bincode::deserialize(&bytes).unwrap();
861        assert!(decoded.verify_hmac(secret));
862    }
863}