Skip to main content

mnemo_amp/
approval.rs

1//! Human-in-the-loop (HITL) diff-and-approve hook.
2//!
3//! AMP gates **long-term writes** (`semantic` / `procedural` memory
4//! types) behind an optional approval step: before the write commits,
5//! a [`WriteDiff`] describing what would change is handed to an
6//! [`ApprovalHook`], which returns [`Approval::Approve`] or
7//! [`Approval::Reject`]. On approval the store emits a
8//! `Decision` audit event through mnemo's existing
9//! hash-chained event log, so the approve trail is tamper-evident and
10//! replayable alongside the write it authorized.
11//!
12//! Short-term tiers (`episodic` / `working`) bypass approval — they
13//! are high-churn and not worth a human gate.
14
15use crate::wire::AmpMemoryType;
16
17/// What a pending long-term write would change.
18///
19/// For a fresh write `before` is `None`; for a `merge` it carries the
20/// concatenated source content so the reviewer sees what is being
21/// folded together.
22#[derive(Debug, Clone, PartialEq)]
23pub struct WriteDiff {
24    pub agent_id: String,
25    pub memory_type: AmpMemoryType,
26    /// Existing content being replaced/folded, if any.
27    pub before: Option<String>,
28    /// Proposed content.
29    pub after: String,
30    pub tags: Vec<String>,
31}
32
33impl WriteDiff {
34    /// A compact, deterministic textual diff suitable for hashing into
35    /// the audit trail or showing a reviewer. Stable across runs (no
36    /// timestamps / addresses).
37    pub fn render(&self) -> String {
38        match &self.before {
39            Some(b) => format!(
40                "[{}] tags={:?}\n- {}\n+ {}",
41                self.memory_type.as_str(),
42                self.tags,
43                b,
44                self.after
45            ),
46            None => format!(
47                "[{}] tags={:?}\n+ {}",
48                self.memory_type.as_str(),
49                self.tags,
50                self.after
51            ),
52        }
53    }
54}
55
56/// The outcome of a HITL review.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum Approval {
59    Approve,
60    Reject(String),
61}
62
63impl Approval {
64    pub fn is_approved(&self) -> bool {
65        matches!(self, Approval::Approve)
66    }
67}
68
69/// A pluggable approval gate consulted before long-term writes.
70///
71/// Implementors are `Send + Sync` so a router can hold one behind an
72/// `Arc` and share it across async tasks. The default
73/// [`AutoApprove`] approves everything (no human in the loop); wire a
74/// real reviewer by implementing this trait or using
75/// [`ClosureApprove`].
76pub trait ApprovalHook: Send + Sync {
77    fn review(&self, diff: &WriteDiff) -> Approval;
78    fn name(&self) -> &str;
79}
80
81/// No-op hook: approves every write. The default when no HITL gate is
82/// configured.
83#[derive(Debug, Clone, Default)]
84pub struct AutoApprove;
85
86impl ApprovalHook for AutoApprove {
87    fn review(&self, _diff: &WriteDiff) -> Approval {
88        Approval::Approve
89    }
90    fn name(&self) -> &str {
91        "auto_approve"
92    }
93}
94
95/// Hook backed by an injectable closure, so tests (and real
96/// integrations that bridge to an out-of-band review UI) can supply a
97/// deterministic decision without defining a new type.
98pub struct ClosureApprove {
99    f: Box<dyn Fn(&WriteDiff) -> Approval + Send + Sync>,
100}
101
102impl ClosureApprove {
103    pub fn new<F>(f: F) -> Self
104    where
105        F: Fn(&WriteDiff) -> Approval + Send + Sync + 'static,
106    {
107        Self { f: Box::new(f) }
108    }
109}
110
111impl ApprovalHook for ClosureApprove {
112    fn review(&self, diff: &WriteDiff) -> Approval {
113        (self.f)(diff)
114    }
115    fn name(&self) -> &str {
116        "closure_approve"
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn diff() -> WriteDiff {
125        WriteDiff {
126            agent_id: "a".into(),
127            memory_type: AmpMemoryType::Semantic,
128            before: None,
129            after: "Paris is the capital of France".into(),
130            tags: vec!["geo".into()],
131        }
132    }
133
134    #[test]
135    fn auto_approve_always_approves() {
136        assert_eq!(AutoApprove.review(&diff()), Approval::Approve);
137    }
138
139    #[test]
140    fn closure_hook_is_honoured() {
141        let hook = ClosureApprove::new(|d| {
142            if d.after.contains("France") {
143                Approval::Approve
144            } else {
145                Approval::Reject("off-topic".into())
146            }
147        });
148        assert!(hook.review(&diff()).is_approved());
149
150        let mut other = diff();
151        other.after = "unrelated".into();
152        assert_eq!(hook.review(&other), Approval::Reject("off-topic".into()));
153    }
154
155    #[test]
156    fn diff_render_is_deterministic() {
157        let d = diff();
158        assert_eq!(d.render(), d.render());
159        assert!(d.render().contains("+ Paris is the capital of France"));
160    }
161}