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}