Skip to main content

agentstategraph_taint/
types.rs

1//! Core data types for the taint substrate.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8/// A taint record. Persisted to the `taints` table in storage and
9/// round-tripped across the MCP / FFI / binding surfaces.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct Taint {
12    /// UUID string generated at creation.
13    pub id: String,
14    /// Path the taint applies to (e.g. `/cluster/nodes/picoup2`).
15    pub path: String,
16    /// Human-readable taint name (e.g. `disk-pressure`,
17    /// `crash-loop`, `security-review`).
18    pub name: String,
19    /// Discriminator between taint / quarantine / watch.
20    pub kind: TaintKind,
21    /// How the pre-commit hook should behave when this taint
22    /// matches an incoming write.
23    pub effect: TaintEffect,
24    /// Advisory severity (does not change pre-commit semantics).
25    pub severity: TaintSeverity,
26    /// Why the taint was applied. Free-form.
27    pub reason: String,
28    /// Agent that applied the taint.
29    pub agent_id: String,
30    /// Commit id of the `Taint` / `Quarantine` / `Watch` intent
31    /// commit that created this record. Empty until the commit is
32    /// written (the repository patches it post-commit).
33    #[serde(default)]
34    pub commit_id: String,
35    /// When the taint was created.
36    pub created_at: DateTime<Utc>,
37    /// Optional auto-expiry.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub expires_at: Option<DateTime<Utc>>,
40    /// Set when resolved via `untaint` / `unquarantine` / `unwatch`.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub resolved_at: Option<DateTime<Utc>>,
43    /// Agent that resolved the taint.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub resolved_by: Option<String>,
46    /// Reason given at resolution.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub resolved_reason: Option<String>,
49    /// Optional evidence (e.g. a remediation commit id) proving
50    /// the issue is resolved.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub resolved_proof: Option<String>,
53    /// When true, the taint applies to all descendant paths of
54    /// `path`.
55    pub propagate: bool,
56    /// Kind-specific metadata: `authorized_agents` for quarantines,
57    /// `metric` / `threshold` / `direction` for watches, etc.
58    #[serde(default)]
59    pub metadata: TaintMetadata,
60}
61
62impl Taint {
63    /// `true` when the taint is currently active against `now`:
64    /// not resolved and not yet expired.
65    pub fn is_active(&self, now: DateTime<Utc>) -> bool {
66        if self.resolved_at.is_some() {
67            return false;
68        }
69        match self.expires_at {
70            Some(exp) => exp > now,
71            None => true,
72        }
73    }
74
75    /// Authorized-agents allowlist parsed from `metadata`. Applies
76    /// to `Quarantine` kind; empty for others.
77    pub fn authorized_agents(&self) -> Vec<String> {
78        self.metadata
79            .0
80            .get("authorized_agents")
81            .and_then(|v| v.as_array())
82            .map(|arr| {
83                arr.iter()
84                    .filter_map(|v| v.as_str().map(str::to_string))
85                    .collect()
86            })
87            .unwrap_or_default()
88    }
89}
90
91/// Discriminator between the three taint kinds.
92#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
93#[serde(rename_all = "snake_case")]
94pub enum TaintKind {
95    Taint,
96    Quarantine,
97    Watch,
98}
99
100/// Pre-commit-hook behavior for this taint.
101#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
102#[serde(rename_all = "snake_case")]
103pub enum TaintEffect {
104    /// Attach a warning to the commit metadata but allow the write.
105    Warn,
106    /// Reject the write with [`TaintError::Blocked`].
107    Block,
108    /// Require confidence >= 0.9; reject lower-confidence writes.
109    Review,
110    /// Allow the write; flag the path for query / search filtering.
111    Isolate,
112    /// Watch kind only — purely advisory.
113    Advisory,
114}
115
116/// Advisory severity. Does not change pre-commit semantics; used
117/// by consumers for triage ordering.
118#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
119#[serde(rename_all = "snake_case")]
120pub enum TaintSeverity {
121    Low,
122    #[default]
123    Medium,
124    High,
125    Critical,
126}
127
128/// Flat string-to-JSON map for kind-specific metadata. Wrapped so
129/// we can control the on-disk shape without bleeding
130/// `HashMap<String, serde_json::Value>` through every API.
131#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
132#[serde(transparent)]
133pub struct TaintMetadata(pub HashMap<String, serde_json::Value>);
134
135impl TaintMetadata {
136    pub fn new() -> Self {
137        Self::default()
138    }
139
140    pub fn insert(
141        &mut self,
142        key: impl Into<String>,
143        value: impl Into<serde_json::Value>,
144    ) -> &mut Self {
145        self.0.insert(key.into(), value.into());
146        self
147    }
148
149    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
150        self.0.get(key)
151    }
152}
153
154/// Result of [`crate::evaluate_access`]. Consumed by the pre-commit
155/// hook + `policy_evaluate_change` integration.
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
157pub struct TaintCheck {
158    pub tainted: bool,
159    pub quarantined: bool,
160    pub watched: bool,
161    pub taints: Vec<Taint>,
162    pub quarantines: Vec<Taint>,
163    pub watches: Vec<Taint>,
164    /// Given the current taints + quarantines + supplied
165    /// `agent_id` + `confidence`, is writing allowed?
166    pub can_write: bool,
167    /// Minimum confidence the caller must supply to pass the
168    /// pre-commit hook. `0.0` when no review-effect taint applies.
169    pub required_confidence: f64,
170    /// Union of `authorized_agents` across all active quarantines
171    /// on this path. Empty when no quarantine applies.
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub authorized_agents: Vec<String>,
174    /// When any `Isolate`-effect taint applies, the path should be
175    /// omitted from search / query / `get_tree` unless the caller
176    /// explicitly opts in.
177    pub isolated: bool,
178}
179
180impl TaintCheck {
181    pub fn clear() -> Self {
182        Self {
183            tainted: false,
184            quarantined: false,
185            watched: false,
186            taints: Vec::new(),
187            quarantines: Vec::new(),
188            watches: Vec::new(),
189            can_write: true,
190            required_confidence: 0.0,
191            authorized_agents: Vec::new(),
192            isolated: false,
193        }
194    }
195}
196
197// ---------------------------------------------------------------------------
198// Parameter bundles for the Repository surface (§4).
199// ---------------------------------------------------------------------------
200
201/// Parameters for `Repository::taint`.
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
203pub struct TaintParams {
204    pub name: String,
205    pub effect: TaintEffect,
206    pub reason: String,
207    #[serde(default)]
208    pub severity: TaintSeverity,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub expires_at: Option<DateTime<Utc>>,
211    #[serde(default = "default_true")]
212    pub propagate: bool,
213    #[serde(default)]
214    pub metadata: TaintMetadata,
215    pub agent_id: String,
216}
217
218/// Parameters for `Repository::untaint`.
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
220pub struct UntaintParams {
221    pub reason: String,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub proof: Option<String>,
224    pub agent_id: String,
225}
226
227/// Parameters for `Repository::quarantine`.
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
229pub struct QuarantineParams {
230    pub name: String,
231    pub reason: String,
232    #[serde(default)]
233    pub severity: TaintSeverity,
234    pub authorized_agents: Vec<String>,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub expires_at: Option<DateTime<Utc>>,
237    #[serde(default = "default_true")]
238    pub propagate: bool,
239    pub agent_id: String,
240}
241
242/// Parameters for `Repository::unquarantine`. Same shape as untaint.
243pub type UnquarantineParams = UntaintParams;
244
245/// Parameters for `Repository::watch`.
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
247pub struct WatchParams {
248    pub name: String,
249    pub reason: String,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub metric: Option<String>,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub threshold: Option<f64>,
254    #[serde(default = "default_watch_direction")]
255    pub direction: WatchDirection,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub check_interval_secs: Option<u64>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub expires_at: Option<DateTime<Utc>>,
260    #[serde(default)]
261    pub severity: TaintSeverity,
262    #[serde(default = "default_true")]
263    pub propagate: bool,
264    pub agent_id: String,
265}
266
267/// Parameters for `Repository::unwatch`. Watches are lightweight so
268/// reason is optional.
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
270pub struct UnwatchParams {
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub reason: Option<String>,
273    pub agent_id: String,
274}
275
276/// Direction a watch threshold fires in.
277#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
278#[serde(rename_all = "snake_case")]
279pub enum WatchDirection {
280    #[default]
281    Above,
282    Below,
283}
284
285fn default_true() -> bool {
286    true
287}
288
289fn default_watch_direction() -> WatchDirection {
290    WatchDirection::Above
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn taint_kind_serializes_snake_case() {
299        assert_eq!(
300            serde_json::to_string(&TaintKind::Taint).unwrap(),
301            "\"taint\""
302        );
303        assert_eq!(
304            serde_json::to_string(&TaintKind::Quarantine).unwrap(),
305            "\"quarantine\""
306        );
307        assert_eq!(
308            serde_json::to_string(&TaintKind::Watch).unwrap(),
309            "\"watch\""
310        );
311    }
312
313    #[test]
314    fn taint_effect_round_trips() {
315        for e in [
316            TaintEffect::Warn,
317            TaintEffect::Block,
318            TaintEffect::Review,
319            TaintEffect::Isolate,
320            TaintEffect::Advisory,
321        ] {
322            let j = serde_json::to_value(e).unwrap();
323            let back: TaintEffect = serde_json::from_value(j).unwrap();
324            assert_eq!(e, back);
325        }
326    }
327
328    #[test]
329    fn is_active_honors_resolved_and_expired() {
330        let base = Taint {
331            id: "t1".into(),
332            path: "/x".into(),
333            name: "n".into(),
334            kind: TaintKind::Taint,
335            effect: TaintEffect::Warn,
336            severity: TaintSeverity::Low,
337            reason: "r".into(),
338            agent_id: "a".into(),
339            commit_id: String::new(),
340            created_at: Utc::now(),
341            expires_at: None,
342            resolved_at: None,
343            resolved_by: None,
344            resolved_reason: None,
345            resolved_proof: None,
346            propagate: true,
347            metadata: TaintMetadata::new(),
348        };
349        assert!(base.is_active(Utc::now()));
350
351        let mut expired = base.clone();
352        expired.expires_at = Some(Utc::now() - chrono::Duration::seconds(10));
353        assert!(!expired.is_active(Utc::now()));
354
355        let mut resolved = base.clone();
356        resolved.resolved_at = Some(Utc::now() - chrono::Duration::seconds(1));
357        assert!(!resolved.is_active(Utc::now()));
358    }
359
360    #[test]
361    fn authorized_agents_parses_metadata() {
362        let mut t = Taint {
363            id: "q1".into(),
364            path: "/x".into(),
365            name: "sec".into(),
366            kind: TaintKind::Quarantine,
367            effect: TaintEffect::Block,
368            severity: TaintSeverity::High,
369            reason: "r".into(),
370            agent_id: "a".into(),
371            commit_id: String::new(),
372            created_at: Utc::now(),
373            expires_at: None,
374            resolved_at: None,
375            resolved_by: None,
376            resolved_reason: None,
377            resolved_proof: None,
378            propagate: true,
379            metadata: TaintMetadata::new(),
380        };
381        t.metadata.insert(
382            "authorized_agents",
383            serde_json::json!(["agent/security", "human/sre-lead"]),
384        );
385        assert_eq!(
386            t.authorized_agents(),
387            vec!["agent/security".to_string(), "human/sre-lead".to_string()]
388        );
389    }
390
391    fn base_taint() -> Taint {
392        Taint {
393            id: "t1".into(),
394            path: "/x".into(),
395            name: "n".into(),
396            kind: TaintKind::Taint,
397            effect: TaintEffect::Warn,
398            severity: TaintSeverity::Low,
399            reason: "r".into(),
400            agent_id: "a".into(),
401            commit_id: String::new(),
402            created_at: Utc::now(),
403            expires_at: None,
404            resolved_at: None,
405            resolved_by: None,
406            resolved_reason: None,
407            resolved_proof: None,
408            propagate: true,
409            metadata: TaintMetadata::new(),
410        }
411    }
412
413    // --- is_active edge cases ---
414
415    #[test]
416    fn is_active_future_expiry_still_active() {
417        let mut t = base_taint();
418        t.expires_at = Some(Utc::now() + chrono::Duration::hours(1));
419        assert!(t.is_active(Utc::now()));
420    }
421
422    #[test]
423    fn is_active_resolved_overrides_non_expired() {
424        let mut t = base_taint();
425        t.expires_at = Some(Utc::now() + chrono::Duration::hours(1)); // not expired
426        t.resolved_at = Some(Utc::now() - chrono::Duration::seconds(1)); // but resolved
427        assert!(!t.is_active(Utc::now()));
428    }
429
430    #[test]
431    fn is_active_no_expiry_no_resolution_always_active() {
432        let t = base_taint();
433        assert!(t.is_active(Utc::now()));
434    }
435
436    // --- authorized_agents edge cases ---
437
438    #[test]
439    fn authorized_agents_empty_when_key_absent() {
440        let t = base_taint();
441        assert!(t.authorized_agents().is_empty());
442    }
443
444    #[test]
445    fn authorized_agents_empty_when_value_not_array() {
446        let mut t = base_taint();
447        t.metadata.insert("authorized_agents", "not-an-array");
448        assert!(t.authorized_agents().is_empty());
449    }
450
451    #[test]
452    fn authorized_agents_filters_non_string_entries() {
453        let mut t = base_taint();
454        // Mixed array: string + number; only strings should survive
455        t.metadata.insert(
456            "authorized_agents",
457            serde_json::json!(["agent/ok", 42, "human/ok"]),
458        );
459        let agents = t.authorized_agents();
460        assert_eq!(agents, vec!["agent/ok", "human/ok"]);
461    }
462
463    // --- TaintMetadata ---
464
465    #[test]
466    fn taint_metadata_insert_get_roundtrip() {
467        let mut m = TaintMetadata::new();
468        m.insert("key1", serde_json::json!("value1"));
469        m.insert("key2", serde_json::json!(42));
470        assert_eq!(m.get("key1"), Some(&serde_json::json!("value1")));
471        assert_eq!(m.get("key2"), Some(&serde_json::json!(42)));
472        assert_eq!(m.get("missing"), None);
473    }
474
475    #[test]
476    fn taint_metadata_insert_returns_self_for_chaining() {
477        let mut m = TaintMetadata::new();
478        m.insert("a", "1").insert("b", "2");
479        assert!(m.get("a").is_some());
480        assert!(m.get("b").is_some());
481    }
482
483    #[test]
484    fn taint_metadata_default_is_empty() {
485        let m = TaintMetadata::default();
486        assert!(m.0.is_empty());
487    }
488
489    // --- Default values ---
490
491    #[test]
492    fn taint_severity_default_is_medium() {
493        assert_eq!(TaintSeverity::default(), TaintSeverity::Medium);
494    }
495
496    #[test]
497    fn watch_direction_default_is_above() {
498        assert_eq!(WatchDirection::default(), WatchDirection::Above);
499    }
500
501    // --- TaintCheck::clear ---
502
503    #[test]
504    fn taint_check_clear_is_all_clear() {
505        let c = TaintCheck::clear();
506        assert!(!c.tainted);
507        assert!(!c.quarantined);
508        assert!(!c.watched);
509        assert!(c.taints.is_empty());
510        assert!(c.quarantines.is_empty());
511        assert!(c.watches.is_empty());
512        assert!(c.can_write);
513        assert_eq!(c.required_confidence, 0.0);
514        assert!(c.authorized_agents.is_empty());
515        assert!(!c.isolated);
516    }
517
518    // --- Serialization round-trips ---
519
520    #[test]
521    fn taint_severity_round_trips_all_variants() {
522        for s in [
523            TaintSeverity::Low,
524            TaintSeverity::Medium,
525            TaintSeverity::High,
526            TaintSeverity::Critical,
527        ] {
528            let j = serde_json::to_value(s).unwrap();
529            let back: TaintSeverity = serde_json::from_value(j).unwrap();
530            assert_eq!(s, back);
531        }
532    }
533
534    #[test]
535    fn taint_kind_deserializes_snake_case() {
536        assert_eq!(
537            serde_json::from_str::<TaintKind>("\"quarantine\"").unwrap(),
538            TaintKind::Quarantine
539        );
540        assert_eq!(
541            serde_json::from_str::<TaintKind>("\"watch\"").unwrap(),
542            TaintKind::Watch
543        );
544    }
545
546    #[test]
547    fn taint_full_round_trip_serialization() {
548        let t = Taint {
549            id: "uuid-123".into(),
550            path: "/nodes/pico1".into(),
551            name: "disk-pressure".into(),
552            kind: TaintKind::Quarantine,
553            effect: TaintEffect::Block,
554            severity: TaintSeverity::Critical,
555            reason: "disk full".into(),
556            agent_id: "monitor/disk".into(),
557            commit_id: "abc123".into(),
558            created_at: Utc::now(),
559            expires_at: Some(Utc::now() + chrono::Duration::hours(24)),
560            resolved_at: None,
561            resolved_by: None,
562            resolved_reason: None,
563            resolved_proof: None,
564            propagate: false,
565            metadata: {
566                let mut m = TaintMetadata::new();
567                m.insert("authorized_agents", serde_json::json!(["agent/ops"]));
568                m
569            },
570        };
571
572        let json = serde_json::to_string(&t).unwrap();
573        let restored: Taint = serde_json::from_str(&json).unwrap();
574
575        assert_eq!(restored.id, t.id);
576        assert_eq!(restored.kind, TaintKind::Quarantine);
577        assert_eq!(restored.effect, TaintEffect::Block);
578        assert_eq!(restored.severity, TaintSeverity::Critical);
579        assert!(!restored.propagate);
580        assert_eq!(restored.authorized_agents(), vec!["agent/ops"]);
581    }
582
583    #[test]
584    fn taint_resolved_fields_round_trip() {
585        let mut t = base_taint();
586        t.resolved_at = Some(Utc::now());
587        t.resolved_by = Some("human/sre".into());
588        t.resolved_reason = Some("disk replaced".into());
589        t.resolved_proof = Some("commit-xyz".into());
590
591        let json = serde_json::to_string(&t).unwrap();
592        let restored: Taint = serde_json::from_str(&json).unwrap();
593
594        assert!(restored.resolved_at.is_some());
595        assert_eq!(restored.resolved_by.as_deref(), Some("human/sre"));
596        assert_eq!(restored.resolved_reason.as_deref(), Some("disk replaced"));
597        assert_eq!(restored.resolved_proof.as_deref(), Some("commit-xyz"));
598        assert!(!restored.is_active(Utc::now()));
599    }
600
601    #[test]
602    fn taint_optional_fields_omitted_from_serialization() {
603        let t = base_taint();
604        let json = serde_json::to_string(&t).unwrap();
605        // Fields with skip_serializing_if should be absent
606        assert!(!json.contains("expires_at"));
607        assert!(!json.contains("resolved_at"));
608        assert!(!json.contains("resolved_by"));
609    }
610
611    #[test]
612    fn watch_direction_round_trips() {
613        for d in [WatchDirection::Above, WatchDirection::Below] {
614            let j = serde_json::to_value(d).unwrap();
615            let back: WatchDirection = serde_json::from_value(j).unwrap();
616            assert_eq!(d, back);
617        }
618    }
619}