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