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 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 pub fn from_engine(engine: &'a Engine) -> Self {
37 Self {
38 repo: &engine.repo,
39 fts: engine.fts.as_ref(),
40 }
41 }
42}
43
44pub 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
69pub 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 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
151pub 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
162pub(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
168fn 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
186fn 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 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 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 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 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 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 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 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}