Skip to main content

cel_memory/
write_hook.rs

1//! The [`MemoryWriteHook`] trait — the per-write governance seam.
2//!
3//! Memory writes are themselves governable events. Before a provider
4//! persists a chunk, it calls `MemoryWriteHook::before_write(chunk)`. The
5//! hook can:
6//!
7//! - return `Ok(WriteDecision::Allow)` — the provider persists the chunk
8//!   verbatim.
9//! - return `Ok(WriteDecision::Redact { reason })` — the provider drops
10//!   the chunk silently (the `before_write` event still gets matcher
11//!   coverage for audit; the chunk just doesn't land in `memory_chunks`).
12//!   Used to honor `redact_memory` rules.
13//! - return `Err(_)` — the write surfaces as a hard error to the caller.
14//!
15//! The daemon wires a hook backed by the rule matcher: it synthesises a
16//! `MemoryWriteAttempted` event from the chunk's kind, source, caller, and
17//! content prefix, runs the rule matcher over it, and returns the
18//! appropriate decision based on what fired (Veto → Redact; LogOnly → Allow
19//! but record the fire).
20//!
21//! Without a hook, providers persist every write — the v1 default for
22//! tests and for daemons that haven't wired the rule matcher path yet.
23
24use async_trait::async_trait;
25use serde::{Deserialize, Serialize};
26
27use crate::chunk::NewMemoryChunk;
28use crate::error::Result;
29
30/// What a [`MemoryWriteHook`] tells the provider to do with an incoming
31/// chunk.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "snake_case")]
34pub enum WriteDecision {
35    /// Persist the chunk verbatim. The default outcome.
36    Allow,
37    /// Drop the chunk before persistence. The provider returns success
38    /// to the caller (so write paths don't need to special-case redaction)
39    /// but the chunk never lands in `memory_chunks`. The reason string
40    /// surfaces in logs and in any `record_access`-style audit trail.
41    Redact {
42        /// Human-readable cause (typically the matched rule's name).
43        reason: String,
44    },
45}
46
47/// The hook providers consult before persisting a chunk.
48///
49/// Implementations must be cheap: this fires on every write through the
50/// memory subsystem. The daemon's matcher-backed hook is `O(rules)` — that's
51/// the v1 budget.
52#[async_trait]
53pub trait MemoryWriteHook: Send + Sync {
54    /// Decide what to do with the chunk. Default impl returns `Allow`,
55    /// which makes wiring a hook optional — most consumers can ignore
56    /// this trait entirely.
57    async fn before_write(&self, _chunk: &NewMemoryChunk) -> Result<WriteDecision> {
58        Ok(WriteDecision::Allow)
59    }
60}
61
62/// Convenience wrapper for callers that want to plug a closure in without
63/// declaring a struct. Useful for tests and for the daemon's adapter that
64/// wraps the rule matcher.
65pub struct ClosureHook<F>(pub F)
66where
67    F: Fn(&NewMemoryChunk) -> WriteDecision + Send + Sync + 'static;
68
69#[async_trait]
70impl<F> MemoryWriteHook for ClosureHook<F>
71where
72    F: Fn(&NewMemoryChunk) -> WriteDecision + Send + Sync + 'static,
73{
74    async fn before_write(&self, chunk: &NewMemoryChunk) -> Result<WriteDecision> {
75        Ok((self.0)(chunk))
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::chunk::{ChunkKind, ChunkSource};
83
84    fn nc(content: &str) -> NewMemoryChunk {
85        NewMemoryChunk {
86            kind: ChunkKind::Chat,
87            source: ChunkSource::Embedded,
88            session_id: None,
89            project_root: None,
90            caller_id: "test".into(),
91            content: content.into(),
92            metadata: serde_json::Value::Null,
93            importance: None,
94            shareable: false,
95            pinned: false,
96        }
97    }
98
99    #[tokio::test]
100    async fn closure_hook_redacts_matching_content() {
101        let hook = ClosureHook(|c: &NewMemoryChunk| {
102            if c.content.contains("secret") {
103                WriteDecision::Redact {
104                    reason: "secret keyword".into(),
105                }
106            } else {
107                WriteDecision::Allow
108            }
109        });
110        let chunk = nc("a totally innocent chat");
111        assert_eq!(
112            hook.before_write(&chunk).await.unwrap(),
113            WriteDecision::Allow
114        );
115        let chunk = nc("contains a secret");
116        match hook.before_write(&chunk).await.unwrap() {
117            WriteDecision::Redact { reason } => assert_eq!(reason, "secret keyword"),
118            other => panic!("expected Redact, got {other:?}"),
119        }
120    }
121
122    #[tokio::test]
123    async fn default_hook_allows_everything() {
124        struct Default;
125        #[async_trait]
126        impl MemoryWriteHook for Default {}
127
128        let chunk = nc("anything");
129        assert_eq!(
130            Default.before_write(&chunk).await.unwrap(),
131            WriteDecision::Allow
132        );
133    }
134}