cel_memory/offdevice_hook.rs
1//! The [`OffdeviceCallHook`] trait — the off-device call governance seam.
2//!
3//! Cloud-bound LLM calls made by the memory subsystem (summarizers,
4//! embedders, anything that ships a chunk's content over the network) are
5//! themselves governable events — shipping memory content off-device is a
6//! privacy footgun. Before such a call dispatches, the producer calls
7//! `OffdeviceCallHook::before_call(descriptor)`. The hook can:
8//!
9//! - return `Ok(OffdeviceDecision::Allow)` — the call proceeds.
10//! - return `Ok(OffdeviceDecision::Veto { reason })` — the producer skips
11//! the network round-trip and surfaces a structured error to the caller.
12//! Used to honor rules like "never call Anthropic during work hours" or
13//! "require confirmation on first off-device memory call per session."
14//! - return `Err(_)` — the call surfaces as a hard error to the caller.
15//!
16//! The daemon wires a hook backed by the rule matcher: it synthesises a
17//! `MemoryOffdeviceCallAttempted` event from the descriptor, runs the rule
18//! matcher over it, and returns the appropriate decision.
19//!
20//! Without a hook, producers always proceed — the test default and the
21//! correct behavior for daemons that haven't wired the rule matcher path
22//! yet.
23
24use async_trait::async_trait;
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27use std::collections::BTreeMap;
28
29/// Describes the off-device call a producer is about to make. Passed to
30/// [`OffdeviceCallHook::before_call`] so rules can match on
31/// kind / provider / model / subsystem.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct OffdeviceCallDescriptor {
34 /// What kind of call this is: `"summarizer"`, `"embedder"`,
35 /// `"agent"`, etc. Drives the synthetic event's `data.kind` field.
36 pub kind: String,
37 /// Provider name as understood by the LLM router (`"anthropic"`,
38 /// `"openai"`, `"voyage"`, …). Drives `data.provider`.
39 pub provider: String,
40 /// Model id (`"claude-haiku-4-5"`, `"voyage-3-large"`, …). Drives
41 /// `data.model`.
42 pub model: String,
43 /// Which daemon subsystem initiated the call (`"memory_summarizer"`,
44 /// `"memory_embedder"`, etc.). Drives `data.subsystem`.
45 pub subsystem: String,
46 /// Free-form metadata the producer wants to expose to the rule
47 /// matcher. Inserted under `data.metadata.*`. Keep small — the
48 /// matcher serialises every key/value pair on every match.
49 #[serde(default)]
50 pub metadata: BTreeMap<String, Value>,
51}
52
53impl OffdeviceCallDescriptor {
54 /// Convenience constructor. The most common shape: caller knows the
55 /// kind, provider, model, and subsystem up front and has nothing
56 /// extra to add.
57 pub fn new(
58 kind: impl Into<String>,
59 provider: impl Into<String>,
60 model: impl Into<String>,
61 subsystem: impl Into<String>,
62 ) -> Self {
63 Self {
64 kind: kind.into(),
65 provider: provider.into(),
66 model: model.into(),
67 subsystem: subsystem.into(),
68 metadata: BTreeMap::new(),
69 }
70 }
71
72 /// Add a metadata field via builder-style chaining.
73 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
74 self.metadata.insert(key.into(), value.into());
75 self
76 }
77}
78
79/// What an [`OffdeviceCallHook`] tells the producer to do.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81#[serde(rename_all = "snake_case")]
82pub enum OffdeviceDecision {
83 /// Proceed with the network call. The default outcome.
84 Allow,
85 /// Skip the network call. The producer surfaces a structured error
86 /// to its caller — for summarizers, this becomes
87 /// `SummarizerError::Provider("off-device call vetoed: <reason>")`.
88 Veto {
89 /// Human-readable cause (typically the matched rule's name).
90 reason: String,
91 },
92}
93
94/// The hook producers consult before dispatching an off-device call.
95///
96/// Implementations must be cheap: this fires on every cloud call made
97/// by the memory subsystem. The daemon's matcher-backed hook is
98/// `O(rules)` — that's the v1 budget.
99#[async_trait]
100pub trait OffdeviceCallHook: Send + Sync {
101 /// Decide what to do with the call. Default impl returns `Allow`,
102 /// which makes wiring a hook optional — most consumers can ignore
103 /// this trait entirely.
104 async fn before_call(&self, _descriptor: &OffdeviceCallDescriptor) -> OffdeviceDecision {
105 OffdeviceDecision::Allow
106 }
107}
108
109/// Convenience wrapper for callers that want to plug a closure in
110/// without declaring a struct. Useful for tests and for the daemon's
111/// adapter that wraps the rule matcher.
112pub struct ClosureOffdeviceHook<F>(pub F)
113where
114 F: Fn(&OffdeviceCallDescriptor) -> OffdeviceDecision + Send + Sync + 'static;
115
116#[async_trait]
117impl<F> OffdeviceCallHook for ClosureOffdeviceHook<F>
118where
119 F: Fn(&OffdeviceCallDescriptor) -> OffdeviceDecision + Send + Sync + 'static,
120{
121 async fn before_call(&self, descriptor: &OffdeviceCallDescriptor) -> OffdeviceDecision {
122 (self.0)(descriptor)
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[tokio::test]
131 async fn closure_hook_vetoes_anthropic_calls() {
132 let hook = ClosureOffdeviceHook(|d: &OffdeviceCallDescriptor| {
133 if d.provider == "anthropic" {
134 OffdeviceDecision::Veto {
135 reason: "no anthropic calls".into(),
136 }
137 } else {
138 OffdeviceDecision::Allow
139 }
140 });
141 let d = OffdeviceCallDescriptor::new(
142 "summarizer",
143 "anthropic",
144 "claude-haiku-4-5",
145 "memory_summarizer",
146 );
147 match hook.before_call(&d).await {
148 OffdeviceDecision::Veto { reason } => assert_eq!(reason, "no anthropic calls"),
149 other => panic!("expected Veto, got {other:?}"),
150 }
151 let d2 =
152 OffdeviceCallDescriptor::new("summarizer", "ollama", "llama3.2", "memory_summarizer");
153 assert_eq!(hook.before_call(&d2).await, OffdeviceDecision::Allow);
154 }
155
156 #[tokio::test]
157 async fn default_hook_allows_everything() {
158 struct Default;
159 #[async_trait]
160 impl OffdeviceCallHook for Default {}
161
162 let d = OffdeviceCallDescriptor::new("summarizer", "anthropic", "haiku", "memory");
163 assert_eq!(Default.before_call(&d).await, OffdeviceDecision::Allow);
164 }
165
166 #[tokio::test]
167 async fn descriptor_metadata_builder() {
168 let d = OffdeviceCallDescriptor::new("summarizer", "anthropic", "haiku", "memory")
169 .with_metadata("session_id", "s1")
170 .with_metadata("chunk_count", 5);
171 assert_eq!(d.metadata.len(), 2);
172 assert_eq!(
173 d.metadata.get("session_id").and_then(|v| v.as_str()),
174 Some("s1")
175 );
176 assert_eq!(
177 d.metadata.get("chunk_count").and_then(|v| v.as_i64()),
178 Some(5)
179 );
180 }
181}