Skip to main content

meerkat_mobkit/memory/
events.rs

1//! Typed memory timeline events (§9.3).
2//!
3//! The memory plane emits its lifecycle onto the console timeline through
4//! this seam: a sync, fire-and-forget sink so store/taint/guard code (which
5//! is often inside mutexes or blocking threads) never awaits the event
6//! surface. The wired implementation projects each event as a
7//! `ConsoleIdentityEventEnvelope` (the standard console envelope):
8//!
9//! ```text
10//! {
11//!   "event_id":   "console-evt-<seq>"      // minted by the store
12//!   "identity":   <affected identity, or "_system" for realm-level events>
13//!   "event_type": "memory.<kind>"          // `MemoryTimelineEvent::event_type`
14//!   "timestamp_ms": <now>,
15//!   "data":       <`MemoryTimelineEvent::data`, the typed payload below>
16//! }
17//! ```
18//!
19//! P3b's console Memory panel consumes exactly this envelope; the
20//! `event_type` strings and payload fields here are that contract. Sites
21//! that cannot reach a wired sink keep their tracing warns — skipped work
22//! stays loud either way (Principle 6).
23
24use serde_json::{Value, json};
25
26/// One typed memory-plane timeline event. Payload fields are stable wire
27/// contract for the console Memory panel (P3b).
28#[derive(Debug, Clone, PartialEq)]
29pub enum MemoryTimelineEvent {
30    /// A steward dream run started.
31    DreamStarted { realm: String, run_id: String },
32    /// A steward dream run completed; `detail` carries the run summary
33    /// (phase outcomes, verdict counts).
34    DreamCompleted {
35        realm: String,
36        run_id: String,
37        ops_committed: usize,
38        detail: Value,
39    },
40    /// A steward dream run was skipped (gates, budget, lock, errors).
41    DreamSkipped { realm: String, reason: String },
42    /// A record was promoted into a wider scope (identity→mob), either
43    /// directly by a dream commit or through an approved gate.
44    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    /// A dream reviewed a quarantined record.
54    QuarantineVerdict {
55        realm: String,
56        record_id: String,
57        verdict: String,
58        rationale: Option<String>,
59    },
60    /// A quarantine release/promotion verdict was blocked before staging:
61    /// the record's content matches a §10.4 secret pattern class, which
62    /// the staged chokepoint refuses. The record stays quarantined — this
63    /// event is why the queue never drains it (tombstone is the exit).
64    QuarantineReleaseBlocked {
65        realm: String,
66        record_id: String,
67        verdict: String,
68        class: String,
69    },
70    /// A dream contradiction finding with operational consequence was
71    /// bridged into the operational ledger (§8.5).
72    ConflictSignal {
73        realm: String,
74        entity: String,
75        topic: String,
76        reason: String,
77    },
78    /// An LLM-authored write landed quarantined at the store seam (§10.1).
79    QuarantinedWrite {
80        realm: String,
81        author: String,
82        reason: String,
83    },
84    /// A session-taint transition (§10.1): `kind` is one of `tainted`,
85    /// `reset_boundary`, `rotated_clean`.
86    TaintTransition {
87        identity: Option<String>,
88        session_key: String,
89        kind: String,
90        source: String,
91    },
92    /// A background-budget guard denied a run (§8.1).
93    BudgetDenied {
94        realm: String,
95        stage: String,
96        reason: String,
97    },
98    /// A quarantine-promotion was staged and now awaits operator approval
99    /// through the gating flow (§10.2).
100    PromotionPendingGate {
101        realm: String,
102        pending_id: String,
103        record_id: String,
104        scope_kind: String,
105        scope_key: String,
106    },
107    /// An exit-interview harvest of a retired identity's store completed.
108    HarvestCompleted {
109        realm: String,
110        identity: String,
111        promoted: usize,
112        tombstoned: usize,
113    },
114    /// A pre-rotation distillation timed out and rotation proceeded
115    /// without it (§8.4).
116    DistillationTimedOut {
117        identity: String,
118        session_key: String,
119        cause: String,
120    },
121    /// A hygiene pass produced a validated revision proposal (§8.6). The
122    /// audited apply follows as `HygieneApplied` unless the seam refuses
123    /// (e.g. the session is mid-turn).
124    HygieneProposed {
125        identity: String,
126        session_key: String,
127        cause: String,
128        ops: usize,
129        /// Active records whose evidence spans the revision touches — the
130        /// §8.6 audit flag, allowed but on the record.
131        flagged_active_records: Vec<String>,
132    },
133    /// An audited transcript revision committed (§8.6).
134    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    /// The §8.6 validator refused a revision (quarantine-referenced span,
144    /// ordering invariant unmet, malformed ranges).
145    HygieneBlocked {
146        identity: String,
147        session_key: String,
148        cause: String,
149        reason: String,
150    },
151    /// A hygiene pass was skipped (budget, no-op judgment, seam refusal).
152    HygieneSkipped {
153        identity: String,
154        session_key: String,
155        cause: String,
156        reason: String,
157    },
158}
159
160impl MemoryTimelineEvent {
161    /// Stable `event_type` for the console envelope.
162    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    /// Console identity attribution: the affected identity where one
185    /// exists, otherwise `None` (the sink attributes to the system
186    /// identity).
187    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    /// Typed payload for the console envelope's `data`.
201    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
390/// Fire-and-forget emission seam. Implementations must not block: the
391/// wired console sink spawns the async append onto the runtime.
392pub trait MemoryEventSink: Send + Sync {
393    fn emit(&self, event: MemoryTimelineEvent);
394}
395
396/// Test helper: collects emitted events behind a mutex.
397#[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}