use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::models::{Memory, MemoryLink, Tier};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
PreStore,
PostStore,
PreRecall,
PostRecall,
PreSearch,
PostSearch,
PreDelete,
PostDelete,
PrePromote,
PostPromote,
PreLink,
PostLink,
PreConsolidate,
PostConsolidate,
PreGovernanceDecision,
PostGovernanceDecision,
OnIndexEviction,
PreArchive,
PreTranscriptStore,
PostTranscriptStore,
PreRecallExpand,
PreReflect,
PostReflect,
PreCompaction,
OnCompactionRollback,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MemoryDelta {
#[serde(skip_serializing_if = "Option::is_none")]
pub tier: Option<Tier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecallQuery {
#[serde(skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tier: Option<Tier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget_tokens: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecallExpandQuery {
pub query: String,
pub namespace: String,
pub k: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecallResult {
pub query: String,
pub memories: Vec<Memory>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_used: Option<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SearchQuery {
#[serde(skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub query: String,
pub memories: Vec<Memory>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryRef {
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromoteDelta {
pub id: String,
pub from_tier: Tier,
pub to_tier: Tier,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromoteResult {
pub id: String,
pub from_tier: Tier,
pub to_tier: Tier,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkDelta {
pub source_id: String,
pub target_id: String,
pub relation: String,
}
pub type Link = MemoryLink;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsolidationDelta {
pub namespace: String,
pub candidate_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsolidationResult {
pub namespace: String,
pub merged_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GovernanceContext {
pub namespace: String,
pub action: String,
pub agent_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GovernanceOutcome {
Allow,
Deny,
Ask,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GovernanceDecision {
pub namespace: String,
pub action: String,
pub agent_id: String,
pub outcome: GovernanceOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pending_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvictionEvent {
pub memory_id: String,
#[serde(default)]
pub namespace: String,
#[serde(default)]
pub evicted_at: String,
#[serde(default)]
pub reason: String,
}
impl EvictionEvent {
#[must_use]
pub fn new(
memory_id: impl Into<String>,
namespace: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
memory_id: memory_id.into(),
namespace: namespace.into(),
evicted_at: rfc3339_now(),
reason: reason.into(),
}
}
}
fn rfc3339_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
chrono::DateTime::<chrono::Utc>::from_timestamp(secs as i64, 0)
.map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_default()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReflectDelta {
#[serde(skip_serializing_if = "Option::is_none")]
pub tier: Option<Tier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReflectResult {
pub id: String,
pub reflection_depth: i32,
pub reflects_on: Vec<String>,
pub namespace: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactionDelta {
pub pass_name: String,
pub candidate_ids: Vec<String>,
pub namespace: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactionRollbackEvent {
pub pass_name: String,
pub summary_id: String,
pub namespace: String,
pub reason: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TranscriptDelta {
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl_secs: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transcript {
pub id: String,
pub namespace: String,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
pub compressed_size: i64,
pub original_size: i64,
}
impl From<&crate::transcripts::Transcript> for Transcript {
fn from(t: &crate::transcripts::Transcript) -> Self {
Self {
id: t.id.clone(),
namespace: t.namespace.clone(),
created_at: t.created_at.clone(),
expires_at: t.expires_at.clone(),
compressed_size: t.compressed_size,
original_size: t.original_size,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hook_event_all_variants_round_trip() {
let table = [
(HookEvent::PreStore, "\"pre_store\""),
(HookEvent::PostStore, "\"post_store\""),
(HookEvent::PreRecall, "\"pre_recall\""),
(HookEvent::PostRecall, "\"post_recall\""),
(HookEvent::PreSearch, "\"pre_search\""),
(HookEvent::PostSearch, "\"post_search\""),
(HookEvent::PreDelete, "\"pre_delete\""),
(HookEvent::PostDelete, "\"post_delete\""),
(HookEvent::PrePromote, "\"pre_promote\""),
(HookEvent::PostPromote, "\"post_promote\""),
(HookEvent::PreLink, "\"pre_link\""),
(HookEvent::PostLink, "\"post_link\""),
(HookEvent::PreConsolidate, "\"pre_consolidate\""),
(HookEvent::PostConsolidate, "\"post_consolidate\""),
(
HookEvent::PreGovernanceDecision,
"\"pre_governance_decision\"",
),
(
HookEvent::PostGovernanceDecision,
"\"post_governance_decision\"",
),
(HookEvent::OnIndexEviction, "\"on_index_eviction\""),
(HookEvent::PreArchive, "\"pre_archive\""),
(HookEvent::PreTranscriptStore, "\"pre_transcript_store\""),
(HookEvent::PostTranscriptStore, "\"post_transcript_store\""),
(HookEvent::PreRecallExpand, "\"pre_recall_expand\""),
(HookEvent::PreReflect, "\"pre_reflect\""),
(HookEvent::PostReflect, "\"post_reflect\""),
(HookEvent::PreCompaction, "\"pre_compaction\""),
(
HookEvent::OnCompactionRollback,
"\"on_compaction_rollback\"",
),
];
assert_eq!(
table.len(),
25,
"L1-7 raises the count from 23 to 25 (adds pre_compaction + on_compaction_rollback)"
);
for (variant, expected_json) in table {
let encoded = serde_json::to_string(&variant).expect("variant encodes");
assert_eq!(encoded, expected_json, "variant {variant:?} mis-encoded");
let decoded: HookEvent = serde_json::from_str(&encoded).expect("variant decodes");
assert_eq!(decoded, variant, "variant {variant:?} did not round-trip");
}
}
#[test]
fn memory_delta_partial_serialization_omits_none_fields() {
let delta = MemoryDelta {
tags: Some(vec!["urgent".into(), "v0.7".into()]),
priority: Some(80),
..Default::default()
};
let v: Value = serde_json::to_value(&delta).expect("encode");
assert_eq!(v["tags"], serde_json::json!(["urgent", "v0.7"]));
assert_eq!(v["priority"], serde_json::json!(80));
assert!(v.get("title").is_none());
assert!(v.get("content").is_none());
assert!(v.get("metadata").is_none());
let back: MemoryDelta = serde_json::from_value(v).expect("decode");
assert_eq!(
back.tags.as_deref(),
Some(&["urgent".into(), "v0.7".into()][..])
);
assert_eq!(back.priority, Some(80));
assert!(back.title.is_none());
}
#[test]
fn recall_query_round_trips() {
let q = RecallQuery {
query: Some("auth tokens".into()),
namespace: Some("team/security".into()),
limit: Some(10),
tier: Some(Tier::Long),
tags: Some(vec!["secrets".into()]),
budget_tokens: Some(2_048),
};
let json = serde_json::to_string(&q).expect("encode");
let back: RecallQuery = serde_json::from_str(&json).expect("decode");
assert_eq!(back.query.as_deref(), Some("auth tokens"));
assert_eq!(back.namespace.as_deref(), Some("team/security"));
assert_eq!(back.limit, Some(10));
assert_eq!(back.tier, Some(Tier::Long));
assert_eq!(back.budget_tokens, Some(2_048));
}
#[test]
fn recall_expand_query_round_trips() {
let q = RecallExpandQuery {
query: "auht tokn".into(),
namespace: "team/security".into(),
k: 10,
};
let json = serde_json::to_string(&q).expect("encode");
let back: RecallExpandQuery = serde_json::from_str(&json).expect("decode");
assert_eq!(back.query, "auht tokn");
assert_eq!(back.namespace, "team/security");
assert_eq!(back.k, 10);
let v: Value = serde_json::from_str(&json).expect("parse");
let obj = v.as_object().expect("object");
assert_eq!(obj.len(), 3, "RecallExpandQuery is exactly 3 wire fields");
}
#[test]
fn search_query_and_result_round_trip() {
let sq = SearchQuery {
query: Some("postgres".into()),
namespace: Some("eng".into()),
limit: Some(5),
tags: None,
};
let json = serde_json::to_string(&sq).expect("encode SearchQuery");
let back: SearchQuery = serde_json::from_str(&json).expect("decode SearchQuery");
assert_eq!(back.query.as_deref(), Some("postgres"));
assert!(back.tags.is_none());
let sr = SearchResult {
query: "postgres".into(),
memories: vec![],
};
let json = serde_json::to_string(&sr).expect("encode SearchResult");
let back: SearchResult = serde_json::from_str(&json).expect("decode SearchResult");
assert_eq!(back.query, "postgres");
assert!(back.memories.is_empty());
}
#[test]
fn memory_ref_round_trips() {
let r = MemoryRef {
id: "01HZX0R5GZ8R3KJYV1Y3M9YW2T".into(),
};
let json = serde_json::to_string(&r).expect("encode");
let back: MemoryRef = serde_json::from_str(&json).expect("decode");
assert_eq!(back.id, r.id);
assert_eq!(
serde_json::to_string(&HookEvent::PreArchive).unwrap(),
"\"pre_archive\""
);
}
#[test]
fn promote_delta_and_result_round_trip() {
let d = PromoteDelta {
id: "abc".into(),
from_tier: Tier::Short,
to_tier: Tier::Long,
};
let json = serde_json::to_string(&d).expect("encode");
let back: PromoteDelta = serde_json::from_str(&json).expect("decode");
assert_eq!(back.from_tier, Tier::Short);
assert_eq!(back.to_tier, Tier::Long);
let r = PromoteResult {
id: "abc".into(),
from_tier: Tier::Short,
to_tier: Tier::Mid,
};
let back: PromoteResult =
serde_json::from_str(&serde_json::to_string(&r).unwrap()).expect("decode");
assert_eq!(back.to_tier, Tier::Mid);
}
#[test]
fn link_delta_and_post_link_round_trip() {
let d = LinkDelta {
source_id: "src".into(),
target_id: "tgt".into(),
relation: "related_to".into(),
};
let json = serde_json::to_string(&d).expect("encode");
let back: LinkDelta = serde_json::from_str(&json).expect("decode");
assert_eq!(back.relation, "related_to");
let post = Link {
source_id: "src".into(),
target_id: "tgt".into(),
relation: crate::models::MemoryLinkRelation::RelatedTo,
created_at: "2026-05-05T00:00:00Z".into(),
signature: None,
observed_by: None,
valid_from: None,
valid_until: None,
attest_level: None,
};
let json = serde_json::to_string(&post).expect("encode Link");
let back: Link = serde_json::from_str(&json).expect("decode Link");
assert_eq!(back.source_id, "src");
assert_eq!(back.created_at, "2026-05-05T00:00:00Z");
}
#[test]
fn consolidation_payloads_round_trip() {
let d = ConsolidationDelta {
namespace: "team/ops".into(),
candidate_ids: vec!["a".into(), "b".into(), "c".into()],
};
let back: ConsolidationDelta =
serde_json::from_str(&serde_json::to_string(&d).unwrap()).expect("decode");
assert_eq!(back.candidate_ids.len(), 3);
let r = ConsolidationResult {
namespace: "team/ops".into(),
merged_ids: vec!["a".into(), "b".into()],
result_id: Some("merged-1".into()),
};
let json = serde_json::to_string(&r).expect("encode");
let v: Value = serde_json::from_str(&json).expect("parse");
assert_eq!(v["result_id"], serde_json::json!("merged-1"));
let r_no_result = ConsolidationResult {
namespace: "team/ops".into(),
merged_ids: vec![],
result_id: None,
};
let v: Value = serde_json::to_value(&r_no_result).expect("encode");
assert!(v.get("result_id").is_none());
}
#[test]
fn governance_payloads_round_trip() {
let ctx = GovernanceContext {
namespace: "team/security".into(),
action: "memory_store".into(),
agent_id: "agent-1".into(),
memory_id: None,
};
let back: GovernanceContext =
serde_json::from_str(&serde_json::to_string(&ctx).unwrap()).expect("decode");
assert_eq!(back.action, "memory_store");
assert!(back.memory_id.is_none());
let dec = GovernanceDecision {
namespace: "team/security".into(),
action: "memory_store".into(),
agent_id: "agent-1".into(),
outcome: GovernanceOutcome::Ask,
reason: Some("requires human review".into()),
pending_id: Some("pending-1".into()),
};
let json = serde_json::to_string(&dec).expect("encode");
let v: Value = serde_json::from_str(&json).expect("parse");
assert_eq!(v["outcome"], serde_json::json!("ask"));
let back: GovernanceDecision = serde_json::from_value(v).expect("decode");
assert!(matches!(back.outcome, GovernanceOutcome::Ask));
assert_eq!(back.pending_id.as_deref(), Some("pending-1"));
}
#[test]
fn eviction_event_round_trips() {
let ev = EvictionEvent {
memory_id: "m-1".into(),
namespace: "team/ops".into(),
evicted_at: "2026-05-05T12:34:56Z".into(),
reason: "max_entries_reached".into(),
};
let json = serde_json::to_string(&ev).expect("encode");
let back: EvictionEvent = serde_json::from_str(&json).expect("decode");
assert_eq!(back.memory_id, "m-1");
assert_eq!(back.namespace, "team/ops");
assert_eq!(back.evicted_at, "2026-05-05T12:34:56Z");
assert_eq!(back.reason, "max_entries_reached");
}
#[test]
fn eviction_event_decodes_legacy_memory_id_only_payload() {
let legacy = r#"{"memory_id":"m-legacy"}"#;
let back: EvictionEvent = serde_json::from_str(legacy).expect("decode legacy");
assert_eq!(back.memory_id, "m-legacy");
assert!(back.namespace.is_empty());
assert!(back.evicted_at.is_empty());
assert!(back.reason.is_empty());
}
#[test]
fn eviction_event_new_stamps_rfc3339_timestamp() {
let ev = EvictionEvent::new("m-1", "team/ops", "max_entries_reached");
assert_eq!(ev.memory_id, "m-1");
assert_eq!(ev.namespace, "team/ops");
assert_eq!(ev.reason, "max_entries_reached");
assert_eq!(ev.evicted_at.len(), 20, "got {:?}", ev.evicted_at);
assert!(
ev.evicted_at.ends_with('Z'),
"expected trailing Z, got {:?}",
ev.evicted_at
);
}
#[test]
fn reflect_delta_partial_serialization_omits_none_fields() {
let delta = ReflectDelta {
tags: Some(vec!["rate-limited".into(), "policy".into()]),
priority: Some(2),
..Default::default()
};
let v: Value = serde_json::to_value(&delta).expect("encode");
assert_eq!(v["tags"], serde_json::json!(["rate-limited", "policy"]));
assert_eq!(v["priority"], serde_json::json!(2));
assert!(v.get("title").is_none());
assert!(v.get("content").is_none());
assert!(v.get("metadata").is_none());
let back: ReflectDelta = serde_json::from_value(v).expect("decode");
assert_eq!(back.priority, Some(2));
assert_eq!(
back.tags.as_deref(),
Some(&["rate-limited".to_string(), "policy".to_string()][..])
);
}
#[test]
fn reflect_result_round_trips() {
let r = ReflectResult {
id: "01HZX0R5GZ8R3KJYV1Y3M9YW2T".into(),
reflection_depth: 2,
reflects_on: vec!["src-a".into(), "src-b".into()],
namespace: "team/ops".into(),
};
let json = serde_json::to_string(&r).expect("encode");
let back: ReflectResult = serde_json::from_str(&json).expect("decode");
assert_eq!(back.id, r.id);
assert_eq!(back.reflection_depth, 2);
assert_eq!(back.reflects_on, vec!["src-a".to_string(), "src-b".into()]);
assert_eq!(back.namespace, "team/ops");
}
#[test]
fn transcript_payloads_round_trip_and_project_from_internal() {
let delta = TranscriptDelta {
namespace: Some("agent/claude".into()),
content: Some("hello world".into()),
ttl_secs: Some(crate::SECS_PER_HOUR),
};
let json = serde_json::to_string(&delta).expect("encode");
let back: TranscriptDelta = serde_json::from_str(&json).expect("decode");
assert_eq!(back.namespace.as_deref(), Some("agent/claude"));
assert_eq!(back.ttl_secs, Some(crate::SECS_PER_HOUR));
let internal = crate::transcripts::Transcript {
id: "tr-1".into(),
namespace: "agent/claude".into(),
created_at: "2026-05-05T00:00:00Z".into(),
expires_at: None,
compressed_size: 42,
original_size: 256,
};
let wire: Transcript = (&internal).into();
let json = serde_json::to_string(&wire).expect("encode wire");
let back: Transcript = serde_json::from_str(&json).expect("decode wire");
assert_eq!(back.id, "tr-1");
assert_eq!(back.compressed_size, 42);
assert_eq!(back.original_size, 256);
assert!(back.expires_at.is_none());
}
}