Skip to main content

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}