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//! 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}