Skip to main content

wm_core/
episodic.rs

1//! V6 typed contracts for lossless episodic memory.
2//!
3//! These types describe source records and their lifecycle without changing
4//! the v5 `Memory` representation. Derived memories can reference episodic
5//! records through `EvidenceRef` instead of replacing source evidence.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use thiserror::Error;
11use uuid::Uuid;
12
13/// Stable identifier for a raw episodic record.
14pub type EpisodicId = Uuid;
15
16/// Wire/schema version for persisted episodic records.
17pub const EPISODIC_SCHEMA_VERSION: u16 = 1;
18
19/// Capture policy for the episodic lane.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub struct EpisodicCapturePolicy {
22    /// Automatic observation capture is opt-in.
23    pub capture_observations: bool,
24    /// Redact obvious key/value secret tokens in the episodic copy.
25    pub redact_sensitive: bool,
26}
27
28impl Default for EpisodicCapturePolicy {
29    fn default() -> Self {
30        Self {
31            capture_observations: false,
32            redact_sensitive: true,
33        }
34    }
35}
36
37impl EpisodicCapturePolicy {
38    /// The conservative default: explicit writes only, with redaction enabled.
39    #[must_use]
40    pub const fn explicit_only() -> Self {
41        Self {
42            capture_observations: false,
43            redact_sensitive: true,
44        }
45    }
46
47    /// Prepare content for the episodic copy without changing the v5 memory.
48    #[must_use]
49    pub fn prepare_content(self, content: &str) -> String {
50        if self.redact_sensitive {
51            redact_sensitive_tokens(content)
52        } else {
53            content.to_string()
54        }
55    }
56}
57
58/// Broad class of an event in the agent experience stream.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum EpisodicKind {
62    Observation,
63    UserStatement,
64    AssistantResponse,
65    ToolCall,
66    ToolResult,
67    Decision,
68    Error,
69    SystemEvent,
70}
71
72/// Origin of a source record.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum ProvenanceSource {
76    User,
77    Tool,
78    Agent,
79    External,
80    System,
81}
82
83/// Source and authority metadata for an episodic record.
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct Provenance {
86    pub source: ProvenanceSource,
87    pub actor: Option<String>,
88    pub source_id: Option<Uuid>,
89    /// Confidence in source attribution, not truth of the content.
90    pub confidence: f32,
91}
92
93impl Provenance {
94    /// Create provenance with full source attribution confidence.
95    #[must_use]
96    pub const fn new(source: ProvenanceSource) -> Self {
97        Self {
98            source,
99            actor: None,
100            source_id: None,
101            confidence: 1.0,
102        }
103    }
104
105    /// Clamp source attribution confidence to the valid range.
106    #[must_use]
107    pub const fn with_confidence(mut self, confidence: f32) -> Self {
108        self.confidence = confidence.clamp(0.0, 1.0);
109        self
110    }
111
112    /// Attach an actor or process identity.
113    #[must_use]
114    pub fn with_actor(mut self, actor: impl Into<String>) -> Self {
115        self.actor = Some(actor.into());
116        self
117    }
118}
119
120/// Relation between a derived item and source evidence.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum EvidenceRelation {
124    Supports,
125    Contradicts,
126    DerivedFrom,
127    Supersedes,
128}
129
130/// A reference to source evidence, optionally narrowed to a character span.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct EvidenceRef {
133    pub record_id: EpisodicId,
134    pub relation: EvidenceRelation,
135    pub start: Option<u32>,
136    pub end: Option<u32>,
137}
138
139impl EvidenceRef {
140    /// Reference a complete source record.
141    #[must_use]
142    pub const fn whole(record_id: EpisodicId, relation: EvidenceRelation) -> Self {
143        Self {
144            record_id,
145            relation,
146            start: None,
147            end: None,
148        }
149    }
150
151    /// Reference a bounded character span.
152    #[must_use]
153    pub const fn span(
154        record_id: EpisodicId,
155        relation: EvidenceRelation,
156        start: u32,
157        end: u32,
158    ) -> Self {
159        Self {
160            record_id,
161            relation,
162            start: Some(start),
163            end: Some(end),
164        }
165    }
166}
167
168/// Current lifecycle state of a source record.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
170#[serde(rename_all = "snake_case", tag = "state")]
171pub enum ValidityState {
172    #[default]
173    Active,
174    Superseded {
175        by: EpisodicId,
176    },
177    Revoked {
178        reason: String,
179    },
180    Archived,
181    Erased,
182}
183
184impl ValidityState {
185    /// Whether this record may support a current answer.
186    #[must_use]
187    pub const fn is_current(&self) -> bool {
188        matches!(self, Self::Active)
189    }
190
191    /// Whether this record remains available as historical evidence.
192    #[must_use]
193    pub const fn is_historical(&self) -> bool {
194        matches!(self, Self::Superseded { .. } | Self::Archived)
195    }
196}
197
198/// Explicit lifecycle operation for a source record.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum MemoryTransition {
201    Supersede { replacement: EpisodicId },
202    Revoke { reason: String },
203    Archive,
204    Erase,
205}
206
207/// Rejected lifecycle transition.
208#[derive(Debug, Clone, PartialEq, Eq, Error)]
209pub enum ValidityTransitionError {
210    #[error("erased records cannot transition")]
211    Erased,
212    #[error("revoked records cannot transition except erase")]
213    Revoked,
214    #[error("invalid transition '{transition}' from state '{state}'")]
215    Invalid {
216        state: &'static str,
217        transition: &'static str,
218    },
219    #[error("a record cannot supersede itself")]
220    SelfSupersession,
221}
222
223impl ValidityState {
224    /// Apply a lifecycle operation without permitting revival of old evidence.
225    pub fn transition(
226        &mut self,
227        record_id: EpisodicId,
228        transition: MemoryTransition,
229    ) -> Result<(), ValidityTransitionError> {
230        if matches!(self, Self::Erased) {
231            return Err(ValidityTransitionError::Erased);
232        }
233        if matches!(self, Self::Revoked { .. }) && !matches!(transition, MemoryTransition::Erase) {
234            return Err(ValidityTransitionError::Revoked);
235        }
236
237        let transition_name = match &transition {
238            MemoryTransition::Supersede { .. } => "supersede",
239            MemoryTransition::Revoke { .. } => "revoke",
240            MemoryTransition::Archive => "archive",
241            MemoryTransition::Erase => "erase",
242        };
243
244        match transition {
245            MemoryTransition::Supersede { replacement } => {
246                if replacement == record_id {
247                    return Err(ValidityTransitionError::SelfSupersession);
248                }
249                if !matches!(self, Self::Active | Self::Archived) {
250                    return Err(ValidityTransitionError::Invalid {
251                        state: self.name(),
252                        transition: transition_name,
253                    });
254                }
255                *self = Self::Superseded { by: replacement };
256            }
257            MemoryTransition::Revoke { reason } => {
258                if !matches!(
259                    self,
260                    Self::Active | Self::Superseded { .. } | Self::Archived
261                ) {
262                    return Err(ValidityTransitionError::Invalid {
263                        state: self.name(),
264                        transition: transition_name,
265                    });
266                }
267                *self = Self::Revoked { reason };
268            }
269            MemoryTransition::Archive => {
270                if !matches!(self, Self::Active | Self::Superseded { .. }) {
271                    return Err(ValidityTransitionError::Invalid {
272                        state: self.name(),
273                        transition: transition_name,
274                    });
275                }
276                *self = Self::Archived;
277            }
278            MemoryTransition::Erase => *self = Self::Erased,
279        }
280        Ok(())
281    }
282
283    const fn name(&self) -> &'static str {
284        match self {
285            Self::Active => "active",
286            Self::Superseded { .. } => "superseded",
287            Self::Revoked { .. } => "revoked",
288            Self::Archived => "archived",
289            Self::Erased => "erased",
290        }
291    }
292}
293
294/// Lossless source record for the v6 episodic memory lane.
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
296pub struct EpisodicRecord {
297    pub schema_version: u16,
298    pub id: EpisodicId,
299    pub session_id: Option<Uuid>,
300    pub sequence: u64,
301    pub kind: EpisodicKind,
302    pub content: String,
303    pub content_hash: String,
304    pub provenance: Provenance,
305    pub validity: ValidityState,
306    #[serde(default)]
307    pub is_private: bool,
308    #[serde(default)]
309    pub model_exclude: bool,
310    pub evidence: Vec<EvidenceRef>,
311    pub created_at: DateTime<Utc>,
312}
313
314impl EpisodicRecord {
315    /// Create a new active source record.
316    #[must_use]
317    pub fn new(
318        session_id: Option<Uuid>,
319        sequence: u64,
320        kind: EpisodicKind,
321        content: impl Into<String>,
322        provenance: Provenance,
323    ) -> Self {
324        let content = content.into();
325        Self {
326            schema_version: EPISODIC_SCHEMA_VERSION,
327            id: Uuid::new_v4(),
328            session_id,
329            sequence,
330            kind,
331            content_hash: hash_content(&content),
332            content,
333            provenance,
334            validity: ValidityState::Active,
335            is_private: false,
336            model_exclude: false,
337            evidence: Vec::new(),
338            created_at: Utc::now(),
339        }
340    }
341
342    /// Add source references to a derived record.
343    #[must_use]
344    pub fn with_evidence(mut self, evidence: Vec<EvidenceRef>) -> Self {
345        self.evidence = evidence;
346        self
347    }
348
349    /// Set the canonical source ID while preserving the content hash.
350    #[must_use]
351    pub const fn with_id(mut self, id: EpisodicId) -> Self {
352        self.id = id;
353        self
354    }
355
356    /// Replace content and recompute its source hash.
357    #[must_use]
358    pub fn with_content(mut self, content: impl Into<String>) -> Self {
359        self.content = content.into();
360        self.content_hash = hash_content(&self.content);
361        self
362    }
363
364    /// Apply the source memory visibility policy to the episodic copy.
365    #[must_use]
366    pub const fn with_visibility(mut self, is_private: bool, model_exclude: bool) -> Self {
367        self.is_private = is_private;
368        self.model_exclude = model_exclude;
369        self
370    }
371
372    /// Apply an explicit lifecycle transition.
373    pub fn transition(
374        &mut self,
375        transition: MemoryTransition,
376    ) -> Result<(), ValidityTransitionError> {
377        self.validity.transition(self.id, transition)
378    }
379}
380
381fn hash_content(content: &str) -> String {
382    let digest = Sha256::digest(content.as_bytes());
383    let mut result = String::with_capacity(digest.len() * 2);
384    use std::fmt::Write as _;
385    for byte in digest {
386        write!(&mut result, "{byte:02x}").expect("writing to a String cannot fail");
387    }
388    result
389}
390
391fn redact_sensitive_tokens(content: &str) -> String {
392    const SENSITIVE_KEYS: &[&str] = &[
393        "password",
394        "passwd",
395        "api_key",
396        "apikey",
397        "secret",
398        "token",
399        "authorization",
400        "credential",
401    ];
402
403    content
404        .split_whitespace()
405        .map(|token| {
406            for delimiter in ['=', ':'] {
407                let Some(index) = token.find(delimiter) else {
408                    continue;
409                };
410                let prefix =
411                    token[..index].trim_matches(|c: char| matches!(c, '"' | '\'' | '{' | '['));
412                if SENSITIVE_KEYS
413                    .iter()
414                    .any(|key| prefix.eq_ignore_ascii_case(key))
415                {
416                    return format!("{}<REDACTED>", &token[..=index]);
417                }
418            }
419            token.to_string()
420        })
421        .collect::<Vec<_>>()
422        .join(" ")
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn record_hashes_source_content() {
431        let record = EpisodicRecord::new(
432            None,
433            1,
434            EpisodicKind::Observation,
435            "hello",
436            Provenance::new(ProvenanceSource::User),
437        );
438        assert_eq!(record.content_hash.len(), 64);
439        assert!(record.validity.is_current());
440    }
441
442    #[test]
443    fn supersession_preserves_historical_state_without_revival() {
444        let id = Uuid::new_v4();
445        let replacement = Uuid::new_v4();
446        let mut state = ValidityState::Active;
447        state
448            .transition(id, MemoryTransition::Supersede { replacement })
449            .unwrap();
450        assert_eq!(state, ValidityState::Superseded { by: replacement });
451        assert!(state.is_historical());
452
453        state
454            .transition(
455                id,
456                MemoryTransition::Revoke {
457                    reason: "invalidated".into(),
458                },
459            )
460            .unwrap();
461        assert!(matches!(state, ValidityState::Revoked { .. }));
462        assert_eq!(
463            state.transition(id, MemoryTransition::Archive),
464            Err(ValidityTransitionError::Revoked)
465        );
466    }
467
468    #[test]
469    fn erased_records_are_terminal() {
470        let id = Uuid::new_v4();
471        let mut state = ValidityState::Active;
472        state.transition(id, MemoryTransition::Erase).unwrap();
473        assert_eq!(
474            state.transition(id, MemoryTransition::Archive),
475            Err(ValidityTransitionError::Erased)
476        );
477    }
478
479    #[test]
480    fn self_supersession_is_rejected() {
481        let id = Uuid::new_v4();
482        let mut state = ValidityState::Active;
483        assert_eq!(
484            state.transition(id, MemoryTransition::Supersede { replacement: id }),
485            Err(ValidityTransitionError::SelfSupersession)
486        );
487    }
488
489    #[test]
490    fn explicit_capture_redacts_obvious_secret_tokens() {
491        let policy = EpisodicCapturePolicy::explicit_only();
492        let content = policy.prepare_content("api_key=abc123 note password:secret");
493        assert_eq!(content, "api_key=<REDACTED> note password:<REDACTED>");
494    }
495
496    #[test]
497    fn observation_capture_is_opt_in() {
498        assert!(!EpisodicCapturePolicy::default().capture_observations);
499    }
500}