1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use thiserror::Error;
11use uuid::Uuid;
12
13pub type EpisodicId = Uuid;
15
16pub const EPISODIC_SCHEMA_VERSION: u16 = 1;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub struct EpisodicCapturePolicy {
22 pub capture_observations: bool,
24 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 #[must_use]
40 pub const fn explicit_only() -> Self {
41 Self {
42 capture_observations: false,
43 redact_sensitive: true,
44 }
45 }
46
47 #[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#[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#[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#[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 pub confidence: f32,
91}
92
93impl Provenance {
94 #[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 #[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 #[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#[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#[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 #[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 #[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#[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 #[must_use]
187 pub const fn is_current(&self) -> bool {
188 matches!(self, Self::Active)
189 }
190
191 #[must_use]
193 pub const fn is_historical(&self) -> bool {
194 matches!(self, Self::Superseded { .. } | Self::Archived)
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum MemoryTransition {
201 Supersede { replacement: EpisodicId },
202 Revoke { reason: String },
203 Archive,
204 Erase,
205}
206
207#[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 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#[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 #[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 #[must_use]
344 pub fn with_evidence(mut self, evidence: Vec<EvidenceRef>) -> Self {
345 self.evidence = evidence;
346 self
347 }
348
349 #[must_use]
351 pub const fn with_id(mut self, id: EpisodicId) -> Self {
352 self.id = id;
353 self
354 }
355
356 #[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 #[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 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}