1use serde_json::{Value, json};
25
26#[derive(Debug, Clone, PartialEq)]
29pub enum MemoryTimelineEvent {
30 DreamStarted { realm: String, run_id: String },
32 DreamCompleted {
35 realm: String,
36 run_id: String,
37 ops_committed: usize,
38 detail: Value,
39 },
40 DreamSkipped { realm: String, reason: String },
42 RecordPromoted {
45 realm: String,
46 record_id: String,
47 source_record_id: Option<String>,
48 scope_kind: String,
49 scope_key: String,
50 proposal_id: Option<String>,
51 gated: bool,
52 },
53 QuarantineVerdict {
55 realm: String,
56 record_id: String,
57 verdict: String,
58 rationale: Option<String>,
59 },
60 QuarantineReleaseBlocked {
65 realm: String,
66 record_id: String,
67 verdict: String,
68 class: String,
69 },
70 ConflictSignal {
73 realm: String,
74 entity: String,
75 topic: String,
76 reason: String,
77 },
78 QuarantinedWrite {
80 realm: String,
81 author: String,
82 reason: String,
83 },
84 TaintTransition {
87 identity: Option<String>,
88 session_key: String,
89 kind: String,
90 source: String,
91 },
92 BudgetDenied {
94 realm: String,
95 stage: String,
96 reason: String,
97 },
98 PromotionPendingGate {
101 realm: String,
102 pending_id: String,
103 record_id: String,
104 scope_kind: String,
105 scope_key: String,
106 },
107 HarvestCompleted {
109 realm: String,
110 identity: String,
111 promoted: usize,
112 tombstoned: usize,
113 },
114 DistillationTimedOut {
117 identity: String,
118 session_key: String,
119 cause: String,
120 },
121 HygieneProposed {
125 identity: String,
126 session_key: String,
127 cause: String,
128 ops: usize,
129 flagged_active_records: Vec<String>,
132 },
133 HygieneApplied {
135 identity: String,
136 session_key: String,
137 cause: String,
138 parent_revision: String,
139 revision: String,
140 ops: usize,
141 flagged_active_records: Vec<String>,
142 },
143 HygieneBlocked {
146 identity: String,
147 session_key: String,
148 cause: String,
149 reason: String,
150 },
151 HygieneSkipped {
153 identity: String,
154 session_key: String,
155 cause: String,
156 reason: String,
157 },
158}
159
160impl MemoryTimelineEvent {
161 pub fn event_type(&self) -> &'static str {
163 match self {
164 Self::DreamStarted { .. } => "memory.dream.started",
165 Self::DreamCompleted { .. } => "memory.dream.completed",
166 Self::DreamSkipped { .. } => "memory.dream.skipped",
167 Self::RecordPromoted { .. } => "memory.record.promoted",
168 Self::QuarantineVerdict { .. } => "memory.quarantine.verdict",
169 Self::QuarantineReleaseBlocked { .. } => "memory.quarantine.release_blocked",
170 Self::ConflictSignal { .. } => "memory.conflict.signal",
171 Self::QuarantinedWrite { .. } => "memory.write.quarantined",
172 Self::TaintTransition { .. } => "memory.taint.transition",
173 Self::BudgetDenied { .. } => "memory.budget.denied",
174 Self::PromotionPendingGate { .. } => "memory.promotion.pending_gate",
175 Self::HarvestCompleted { .. } => "memory.harvest.completed",
176 Self::DistillationTimedOut { .. } => "memory.distill.timed_out",
177 Self::HygieneProposed { .. } => "memory.hygiene.proposed",
178 Self::HygieneApplied { .. } => "memory.hygiene.applied",
179 Self::HygieneBlocked { .. } => "memory.hygiene.blocked",
180 Self::HygieneSkipped { .. } => "memory.hygiene.skipped",
181 }
182 }
183
184 pub fn identity(&self) -> Option<&str> {
188 match self {
189 Self::TaintTransition { identity, .. } => identity.as_deref(),
190 Self::HarvestCompleted { identity, .. }
191 | Self::DistillationTimedOut { identity, .. }
192 | Self::HygieneProposed { identity, .. }
193 | Self::HygieneApplied { identity, .. }
194 | Self::HygieneBlocked { identity, .. }
195 | Self::HygieneSkipped { identity, .. } => Some(identity),
196 _ => None,
197 }
198 }
199
200 pub fn data(&self) -> Value {
202 match self {
203 Self::DreamStarted { realm, run_id } => json!({
204 "realm": realm,
205 "run_id": run_id,
206 }),
207 Self::DreamCompleted {
208 realm,
209 run_id,
210 ops_committed,
211 detail,
212 } => json!({
213 "realm": realm,
214 "run_id": run_id,
215 "ops_committed": ops_committed,
216 "detail": detail,
217 }),
218 Self::DreamSkipped { realm, reason } => json!({
219 "realm": realm,
220 "reason": reason,
221 }),
222 Self::RecordPromoted {
223 realm,
224 record_id,
225 source_record_id,
226 scope_kind,
227 scope_key,
228 proposal_id,
229 gated,
230 } => json!({
231 "realm": realm,
232 "record_id": record_id,
233 "source_record_id": source_record_id,
234 "scope_kind": scope_kind,
235 "scope_key": scope_key,
236 "proposal_id": proposal_id,
237 "gated": gated,
238 }),
239 Self::QuarantineVerdict {
240 realm,
241 record_id,
242 verdict,
243 rationale,
244 } => json!({
245 "realm": realm,
246 "record_id": record_id,
247 "verdict": verdict,
248 "rationale": rationale,
249 }),
250 Self::QuarantineReleaseBlocked {
251 realm,
252 record_id,
253 verdict,
254 class,
255 } => json!({
256 "realm": realm,
257 "record_id": record_id,
258 "verdict": verdict,
259 "class": class,
260 }),
261 Self::ConflictSignal {
262 realm,
263 entity,
264 topic,
265 reason,
266 } => json!({
267 "realm": realm,
268 "entity": entity,
269 "topic": topic,
270 "reason": reason,
271 }),
272 Self::QuarantinedWrite {
273 realm,
274 author,
275 reason,
276 } => json!({
277 "realm": realm,
278 "author": author,
279 "reason": reason,
280 }),
281 Self::TaintTransition {
282 identity,
283 session_key,
284 kind,
285 source,
286 } => json!({
287 "identity": identity,
288 "session_key": session_key,
289 "kind": kind,
290 "source": source,
291 }),
292 Self::BudgetDenied {
293 realm,
294 stage,
295 reason,
296 } => json!({
297 "realm": realm,
298 "stage": stage,
299 "reason": reason,
300 }),
301 Self::PromotionPendingGate {
302 realm,
303 pending_id,
304 record_id,
305 scope_kind,
306 scope_key,
307 } => json!({
308 "realm": realm,
309 "pending_id": pending_id,
310 "record_id": record_id,
311 "scope_kind": scope_kind,
312 "scope_key": scope_key,
313 }),
314 Self::HarvestCompleted {
315 realm,
316 identity,
317 promoted,
318 tombstoned,
319 } => json!({
320 "realm": realm,
321 "identity": identity,
322 "promoted": promoted,
323 "tombstoned": tombstoned,
324 }),
325 Self::DistillationTimedOut {
326 identity,
327 session_key,
328 cause,
329 } => json!({
330 "identity": identity,
331 "session_key": session_key,
332 "cause": cause,
333 }),
334 Self::HygieneProposed {
335 identity,
336 session_key,
337 cause,
338 ops,
339 flagged_active_records,
340 } => json!({
341 "identity": identity,
342 "session_key": session_key,
343 "cause": cause,
344 "ops": ops,
345 "flagged_active_records": flagged_active_records,
346 }),
347 Self::HygieneApplied {
348 identity,
349 session_key,
350 cause,
351 parent_revision,
352 revision,
353 ops,
354 flagged_active_records,
355 } => json!({
356 "identity": identity,
357 "session_key": session_key,
358 "cause": cause,
359 "parent_revision": parent_revision,
360 "revision": revision,
361 "ops": ops,
362 "flagged_active_records": flagged_active_records,
363 }),
364 Self::HygieneBlocked {
365 identity,
366 session_key,
367 cause,
368 reason,
369 } => json!({
370 "identity": identity,
371 "session_key": session_key,
372 "cause": cause,
373 "reason": reason,
374 }),
375 Self::HygieneSkipped {
376 identity,
377 session_key,
378 cause,
379 reason,
380 } => json!({
381 "identity": identity,
382 "session_key": session_key,
383 "cause": cause,
384 "reason": reason,
385 }),
386 }
387 }
388}
389
390pub trait MemoryEventSink: Send + Sync {
393 fn emit(&self, event: MemoryTimelineEvent);
394}
395
396#[cfg(test)]
398pub(crate) struct CollectingEventSink {
399 pub events: std::sync::Mutex<Vec<MemoryTimelineEvent>>,
400}
401
402#[cfg(test)]
403impl CollectingEventSink {
404 pub(crate) fn new() -> Self {
405 Self {
406 events: std::sync::Mutex::new(Vec::new()),
407 }
408 }
409
410 pub(crate) fn types(&self) -> Vec<&'static str> {
411 self.events
412 .lock()
413 .unwrap_or_else(std::sync::PoisonError::into_inner)
414 .iter()
415 .map(MemoryTimelineEvent::event_type)
416 .collect()
417 }
418}
419
420#[cfg(test)]
421impl MemoryEventSink for CollectingEventSink {
422 fn emit(&self, event: MemoryTimelineEvent) {
423 self.events
424 .lock()
425 .unwrap_or_else(std::sync::PoisonError::into_inner)
426 .push(event);
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn event_types_are_stable_wire_contract() {
436 let event = MemoryTimelineEvent::DreamSkipped {
437 realm: "family".to_string(),
438 reason: "budget".to_string(),
439 };
440 assert_eq!(event.event_type(), "memory.dream.skipped");
441 assert_eq!(event.data(), json!({"realm": "family", "reason": "budget"}));
442 assert_eq!(event.identity(), None);
443
444 let event = MemoryTimelineEvent::TaintTransition {
445 identity: Some("identity:luka".to_string()),
446 session_key: "sess-1".to_string(),
447 kind: "tainted".to_string(),
448 source: "mcp:web".to_string(),
449 };
450 assert_eq!(event.event_type(), "memory.taint.transition");
451 assert_eq!(event.identity(), Some("identity:luka"));
452 }
453}