Skip to main content

agentstatedeveloper_core/
effects.rs

1use agentstategraph::{CommitOptions, Repository};
2use agentstategraph_core::IntentCategory;
3
4use crate::engine::Engine;
5use crate::error::{AsdError, Result};
6use crate::paths;
7use crate::schema::{
8    Effect, EffectDecl, Mismatch, Verification, VerificationSource, VerificationStatus,
9};
10use crate::search_fts::SearchFtsDb;
11
12pub trait EffectStore {
13    fn get_effects(&self, ref_name: &str, symbol_id: &str) -> Result<Option<EffectDecl>>;
14    fn put_effects(
15        &self,
16        ref_name: &str,
17        symbol_id: &str,
18        decl: &EffectDecl,
19        agent_id: &str,
20    ) -> Result<()>;
21}
22
23pub struct AsgEffectStore<'a> {
24    pub repo: &'a Repository,
25    /// Borrowed FTS connection from the owning `Engine`.  When `Some`,
26    /// enables the SQLite write-through cache without a per-call
27    /// `Connection::open`.
28    pub fts: Option<&'a SearchFtsDb>,
29}
30
31impl<'a> AsgEffectStore<'a> {
32    pub fn new(repo: &'a Repository) -> Self {
33        Self { repo, fts: None }
34    }
35    /// Convenience: borrow the FTS connection already open in `engine`.
36    pub fn from_engine(engine: &'a Engine) -> Self {
37        Self {
38            repo: &engine.repo,
39            fts: engine.fts.as_ref(),
40        }
41    }
42}
43
44/// List every stored `EffectDecl` in the workspace as `(symbol_id, decl)`
45/// pairs by walking the `/asd/v1/effects` subtree. Read-only. Used by
46/// overview-style consumers (e.g. `asd-serve`'s `/api/v1/effects/overview`)
47/// that need the whole distribution rather than one symbol's decl — the
48/// per-symbol path stays `EffectStore::get_effects`.
49pub fn list_all_effect_decls(
50    repo: &Repository,
51    ref_name: &str,
52) -> Result<Vec<(String, EffectDecl)>> {
53    let prefix = format!("{}/effects", paths::ASD_ROOT);
54    let tree = match repo.get_tree(ref_name, &prefix) {
55        Ok(t) => t,
56        Err(_) => return Ok(Vec::new()),
57    };
58    let mut out: Vec<(String, EffectDecl)> = Vec::new();
59    if let serde_json::Value::Object(map) = tree {
60        for (symbol_id, value) in map {
61            if let Ok(decl) = serde_json::from_value::<EffectDecl>(value) {
62                out.push((symbol_id, decl));
63            }
64        }
65    }
66    Ok(out)
67}
68
69/// What a re-index stores for a symbol, given what is stored for it now and
70/// what its adapter inferred this run (`inferred` stamped with
71/// [`Effect::adapter`]).
72///
73/// The index owns inferred effects and the static verification of them —
74/// nothing anyone else recorded:
75/// - A declaration made by hand (`effect_declare`; its effects carry no
76///   `adapter`) stands. This run's inference is recorded against it as
77///   verification mismatches, the same ones `verify-effects` reports.
78/// - Otherwise `declared` becomes this run's inference.
79/// - A static verification is replaced when its verdict changed — an
80///   unchanged one keeps its time, so a re-index leaves an unchanged symbol's
81///   record as it was. A runtime or test verification is evidence the index
82///   cannot reproduce, and is kept. So are runtime confidence,
83///   `confidence`, the matched policy, and the transitive effects (which the
84///   transitive pass recomputes).
85///
86/// Effects inferred before inference was stamped carry no `adapter` either. A
87/// list identical to this run's inference is taken as inferred; any other is
88/// kept as declared — a stale inference then shows up as a mismatch rather
89/// than a declaration being dropped.
90pub fn merge_reindexed_effects(
91    existing: Option<EffectDecl>,
92    symbol_id: &str,
93    inferred: Vec<Effect>,
94    file: &str,
95    now: chrono::DateTime<chrono::Utc>,
96) -> EffectDecl {
97    let mut decl = existing.unwrap_or_else(|| EffectDecl {
98        symbol_id: symbol_id.to_string(),
99        declared: Vec::new(),
100        transitive: Vec::new(),
101        verification: None,
102        confidence: None,
103        runtime: None,
104        matched_policy: None,
105    });
106    decl.symbol_id = symbol_id.to_string();
107
108    let declared_by_hand = decl.declared.iter().any(|e| e.adapter.is_none())
109        && !same_effects(&decl.declared, &inferred);
110
111    let (status, mismatches) = if declared_by_hand {
112        let mismatches = static_mismatches(&decl.declared, &inferred, file);
113        let status = if mismatches.is_empty() {
114            VerificationStatus::Ok
115        } else {
116            VerificationStatus::Mismatch
117        };
118        (status, mismatches)
119    } else {
120        // An empty inference means the adapter could not determine effects,
121        // not that it confirmed purity — Unverified until a trace or a
122        // declaration says more.
123        let status = if inferred.is_empty() {
124            VerificationStatus::Unverified
125        } else {
126            VerificationStatus::Ok
127        };
128        decl.declared = inferred;
129        (status, Vec::new())
130    };
131
132    let fresh = Verification {
133        by: VerificationSource::StaticChecker,
134        at: now,
135        status,
136        mismatches,
137    };
138    match &decl.verification {
139        None => decl.verification = Some(fresh),
140        Some(v) if matches!(v.by, VerificationSource::StaticChecker) => {
141            let verdict = |v: &Verification| serde_json::to_value((&v.status, &v.mismatches)).ok();
142            if verdict(v) != verdict(&fresh) {
143                decl.verification = Some(fresh);
144            }
145        }
146        Some(_) => {}
147    }
148    decl
149}
150
151/// Whether an effects record holds what no re-index can reproduce: runtime
152/// evidence, or a verification by a trace or a test. Such a record is kept
153/// when its symbol leaves the index; anything else is re-derived from source.
154pub fn carries_evidence(decl: &EffectDecl) -> bool {
155    decl.runtime.is_some()
156        || decl
157            .verification
158            .as_ref()
159            .is_some_and(|v| !matches!(v.by, VerificationSource::StaticChecker))
160}
161
162/// [`carries_evidence`] for a stored record; one that does not parse holds
163/// nothing worth keeping.
164pub(crate) fn value_carries_evidence(value: &serde_json::Value) -> bool {
165    serde_json::from_value::<EffectDecl>(value.clone()).is_ok_and(|decl| carries_evidence(&decl))
166}
167
168/// Same effects in any order, ignoring the provenance stamp and the
169/// `verify-effects` flag.
170fn same_effects(a: &[Effect], b: &[Effect]) -> bool {
171    let key = |e: &Effect| {
172        (
173            e.effect.clone(),
174            e.qualifiers.to_string(),
175            e.note.clone(),
176            e.source_pattern.clone(),
177        )
178    };
179    let mut a: Vec<_> = a.iter().map(key).collect();
180    let mut b: Vec<_> = b.iter().map(key).collect();
181    a.sort();
182    b.sort();
183    a == b
184}
185
186/// Declared-vs-inferred mismatches by category, as `verify-effects` reports
187/// them.
188fn static_mismatches(declared: &[Effect], inferred: &[Effect], file: &str) -> Vec<Mismatch> {
189    let mut mismatches = Vec::new();
190    for d in declared {
191        if !inferred.iter().any(|i| i.effect == d.effect) {
192            mismatches.push(Mismatch {
193                kind: "declared_not_inferred".to_string(),
194                effect: d.effect.clone(),
195                detected_in: Some(file.to_string()),
196                note: Some("declared but not found by static checker".to_string()),
197            });
198        }
199    }
200    for i in inferred {
201        if !declared.iter().any(|d| d.effect == i.effect) {
202            mismatches.push(Mismatch {
203                kind: "inferred_not_declared".to_string(),
204                effect: i.effect.clone(),
205                detected_in: Some(file.to_string()),
206                note: Some("found by static checker but not in declared effects".to_string()),
207            });
208        }
209    }
210    mismatches
211}
212
213impl<'a> EffectStore for AsgEffectStore<'a> {
214    fn get_effects(&self, ref_name: &str, symbol_id: &str) -> Result<Option<EffectDecl>> {
215        // Fast path: SQLite cache.
216        if let Some(fts) = self.fts {
217            if fts.effects_cached_for(symbol_id, ref_name) {
218                if let Ok(opt) = fts.get_effects_for(symbol_id, ref_name) {
219                    return Ok(opt);
220                }
221            }
222        }
223
224        // Authoritative git path + populate cache as side effect.
225        let path = paths::effects_path(symbol_id);
226        let result = match self.repo.get_json(ref_name, &path) {
227            Ok(value) => Ok(Some(serde_json::from_value::<EffectDecl>(value)?)),
228            Err(agentstategraph::RepoError::Tree(_)) => Ok(None),
229            Err(e) => Err(AsdError::Repo(e)),
230        };
231        if let Ok(Some(ref decl)) = result {
232            if let Some(fts) = self.fts {
233                let _ = fts.upsert_effects(symbol_id, ref_name, decl);
234            }
235        }
236        result
237    }
238
239    fn put_effects(
240        &self,
241        ref_name: &str,
242        symbol_id: &str,
243        decl: &EffectDecl,
244        agent_id: &str,
245    ) -> Result<()> {
246        // Git is authoritative — always write there first.
247        let path = paths::effects_path(symbol_id);
248        let value = serde_json::to_value(decl)?;
249        let opts = CommitOptions::new(
250            agent_id,
251            IntentCategory::Refine,
252            format!("declare effects for {}", symbol_id),
253        );
254        self.repo.set_json(ref_name, &path, &value, opts)?;
255        // Best-effort SQLite write-through.
256        if let Some(fts) = self.fts {
257            let _ = fts.upsert_effects(symbol_id, ref_name, decl);
258        }
259        Ok(())
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::schema::{EffectCategory, RuntimeEvidence, TransitiveEffect};
267    use chrono::Utc;
268
269    fn inferred(category: EffectCategory) -> Effect {
270        Effect {
271            adapter: Some("python".into()),
272            ..Effect::new(category)
273        }
274    }
275
276    fn by_hand(category: EffectCategory, note: &str) -> Effect {
277        Effect {
278            note: Some(note.into()),
279            ..Effect::new(category)
280        }
281    }
282
283    fn decl(declared: Vec<Effect>) -> EffectDecl {
284        EffectDecl {
285            symbol_id: "sym".into(),
286            declared,
287            transitive: Vec::new(),
288            verification: None,
289            confidence: None,
290            runtime: None,
291            matched_policy: None,
292        }
293    }
294
295    /// `Effect` has no `PartialEq`; compare what is stored.
296    fn json(effects: &[Effect]) -> serde_json::Value {
297        serde_json::to_value(effects).unwrap()
298    }
299
300    fn kinds(d: &EffectDecl) -> Vec<(String, String)> {
301        let v = d.verification.as_ref().expect("verification");
302        v.mismatches
303            .iter()
304            .map(|m| (m.kind.clone(), m.effect.as_str().to_string()))
305            .collect()
306    }
307
308    #[test]
309    fn a_new_symbol_gets_its_inferred_effects() {
310        let d = merge_reindexed_effects(
311            None,
312            "sym",
313            vec![inferred(EffectCategory::IoNetOut)],
314            "a.py",
315            Utc::now(),
316        );
317        assert_eq!(
318            json(&d.declared),
319            json(&[inferred(EffectCategory::IoNetOut)])
320        );
321        let v = d.verification.unwrap();
322        assert!(matches!(v.by, VerificationSource::StaticChecker));
323        assert!(matches!(v.status, VerificationStatus::Ok));
324
325        let none = merge_reindexed_effects(None, "sym", Vec::new(), "a.py", Utc::now());
326        assert!(matches!(
327            none.verification.unwrap().status,
328            VerificationStatus::Unverified
329        ));
330    }
331
332    #[test]
333    fn a_hand_declaration_survives_and_is_checked_against_the_inference() {
334        let declared = vec![by_hand(EffectCategory::IoNetOut, "calls the rates API")];
335        let d = merge_reindexed_effects(
336            Some(decl(declared.clone())),
337            "sym",
338            vec![inferred(EffectCategory::IoFsRead)],
339            "a.py",
340            Utc::now(),
341        );
342        assert_eq!(json(&d.declared), json(&declared));
343        assert!(matches!(
344            d.verification.as_ref().unwrap().status,
345            VerificationStatus::Mismatch
346        ));
347        assert_eq!(
348            kinds(&d),
349            vec![
350                ("declared_not_inferred".into(), "io.net.out".into()),
351                ("inferred_not_declared".into(), "io.fs.read".into()),
352            ]
353        );
354    }
355
356    #[test]
357    fn a_previous_inference_is_replaced() {
358        let d = merge_reindexed_effects(
359            Some(decl(vec![inferred(EffectCategory::IoNetOut)])),
360            "sym",
361            vec![inferred(EffectCategory::IoFsRead)],
362            "a.py",
363            Utc::now(),
364        );
365        assert_eq!(
366            json(&d.declared),
367            json(&[inferred(EffectCategory::IoFsRead)])
368        );
369        assert!(kinds(&d).is_empty());
370    }
371
372    #[test]
373    fn an_unstamped_list_equal_to_the_inference_is_taken_as_inferred() {
374        // Stored before inferred effects were stamped with their adapter.
375        let legacy = Effect::new(EffectCategory::IoNetOut);
376        let first = merge_reindexed_effects(
377            Some(decl(vec![legacy])),
378            "sym",
379            vec![inferred(EffectCategory::IoNetOut)],
380            "a.py",
381            Utc::now(),
382        );
383        assert_eq!(
384            json(&first.declared),
385            json(&[inferred(EffectCategory::IoNetOut)])
386        );
387
388        // Now stamped, so the next change to the code replaces it.
389        let next = merge_reindexed_effects(
390            Some(first),
391            "sym",
392            vec![inferred(EffectCategory::IoFsWrite)],
393            "a.py",
394            Utc::now(),
395        );
396        assert_eq!(
397            json(&next.declared),
398            json(&[inferred(EffectCategory::IoFsWrite)])
399        );
400    }
401
402    #[test]
403    fn runtime_evidence_confidence_policy_and_transitive_are_kept() {
404        let traced = Verification {
405            by: VerificationSource::RuntimeTracer,
406            at: Utc::now(),
407            status: VerificationStatus::Ok,
408            mismatches: Vec::new(),
409        };
410        let runtime = RuntimeEvidence {
411            confirmations: 3,
412            contradictions: 0,
413            prior: 0.5,
414            last_trace_id: Some("trace-1".into()),
415            last_observed_at: Utc::now(),
416        };
417        let mut existing = decl(vec![inferred(EffectCategory::IoNetOut)]);
418        existing.verification = Some(traced.clone());
419        existing.confidence = Some(0.9);
420        existing.runtime = Some(runtime);
421        existing.matched_policy = Some("net-ok".into());
422        existing.transitive = vec![TransitiveEffect {
423            effect: EffectCategory::IoFsRead,
424            via: vec!["callee".into()],
425            qualifiers: serde_json::Value::Null,
426        }];
427
428        let d = merge_reindexed_effects(
429            Some(existing.clone()),
430            "sym",
431            vec![inferred(EffectCategory::IoNetOut)],
432            "a.py",
433            Utc::now(),
434        );
435        let v = d.verification.unwrap();
436        assert!(matches!(v.by, VerificationSource::RuntimeTracer));
437        assert_eq!(v.at, traced.at);
438        assert_eq!(d.confidence, Some(0.9));
439        assert_eq!(d.runtime.unwrap().confirmations, 3);
440        assert_eq!(d.matched_policy.as_deref(), Some("net-ok"));
441        assert_eq!(d.transitive.len(), 1);
442    }
443
444    #[test]
445    fn a_static_verification_is_restamped_only_when_its_verdict_changes() {
446        let then = Utc::now() - chrono::Duration::hours(1);
447        let mut existing = decl(vec![inferred(EffectCategory::IoNetOut)]);
448        existing.verification = Some(Verification {
449            by: VerificationSource::StaticChecker,
450            at: then,
451            status: VerificationStatus::Ok,
452            mismatches: Vec::new(),
453        });
454        let now = Utc::now();
455        let same = merge_reindexed_effects(
456            Some(existing.clone()),
457            "sym",
458            vec![inferred(EffectCategory::IoNetOut)],
459            "a.py",
460            now,
461        );
462        assert_eq!(
463            serde_json::to_value(&same).unwrap(),
464            serde_json::to_value(&existing).unwrap(),
465            "an unchanged symbol's record must come back byte-identical"
466        );
467
468        let changed = merge_reindexed_effects(Some(existing), "sym", Vec::new(), "a.py", now);
469        let v = changed.verification.unwrap();
470        assert_eq!(v.at, now);
471        assert!(matches!(v.status, VerificationStatus::Unverified));
472    }
473}