Skip to main content

scc_graph/
trust.rs

1//! TrustedGraphView (P0): the only way the Context Compiler may query the
2//! reality graph. Enforces the trust contract (docs/SYSTEM_DESIGN.md §5):
3//!
4//! - STALE facts (evidence whose file changed since indexing) are excluded
5//!   from every trusted traversal and reported as warnings.
6//! - Provenance policy: extracted/resolved/observed/declared facts are
7//!   trusted by default; INFERRED facts below the confidence floor are
8//!   excluded unless `include_low_confidence_inference` is set.
9//! - No transformation may strengthen a claim: the view only *filters*, it
10//!   never rewrites provenance.
11
12use crate::components::parse_prov;
13use crate::RealityGraph;
14use scc_core::{Entity, Flow, Invariant, Provenance, Relationship};
15use scc_store::Store;
16use std::collections::{HashMap, HashSet};
17
18/// Trust policy applied to graph queries. STALE facts are always excluded;
19/// the remaining provenance classes are gated here.
20#[derive(Debug, Clone)]
21pub struct TrustPolicy {
22    pub allow_extracted: bool,
23    pub allow_resolved: bool,
24    pub allow_observed: bool,
25    pub allow_declared: bool,
26    pub allow_inferred: bool,
27    pub min_inferred_confidence: f64,
28}
29
30impl Default for TrustPolicy {
31    fn default() -> Self {
32        TrustPolicy {
33            allow_extracted: true,
34            allow_resolved: true,
35            allow_observed: true,
36            allow_declared: true,
37            allow_inferred: true,
38            min_inferred_confidence: 0.85,
39        }
40    }
41}
42
43impl TrustPolicy {
44    /// Policy derived from the context settings: lowering the inferred
45    /// confidence floor to zero when low-confidence inference is explicitly
46    /// requested (still labeled INFERRED, still evidence-linked).
47    pub fn with_inferred_floor(mut self, floor: f64) -> Self {
48        self.min_inferred_confidence = floor;
49        self
50    }
51
52    pub fn allows(&self, prov: Provenance, confidence: f64) -> bool {
53        match prov {
54            Provenance::Stale => false,
55            Provenance::Extracted => self.allow_extracted,
56            Provenance::Resolved => self.allow_resolved,
57            Provenance::Observed => self.allow_observed,
58            Provenance::Declared => self.allow_declared,
59            Provenance::Inferred => {
60                self.allow_inferred && confidence >= self.min_inferred_confidence
61            }
62        }
63    }
64}
65// trace:v1 id=impl.scc.trust work=WORK-SCC-001 satisfies=REQ-SCC-IR
66
67/// Filtered, policy-governed view over the reality graph.
68pub struct TrustedGraphView<'a> {
69    pub graph: &'a RealityGraph,
70    stale_paths: HashSet<String>,
71    /// evidence ids whose path is in `stale_paths`
72    stale_evidence: HashSet<String>,
73    /// entity ids carrying any stale evidence
74    stale_entities: HashSet<String>,
75    policy: TrustPolicy,
76}
77
78impl<'a> TrustedGraphView<'a> {
79    pub fn new(
80        graph: &'a RealityGraph,
81        store: &'a Store,
82        stale_paths: &[String],
83        policy: TrustPolicy,
84    ) -> TrustedGraphView<'a> {
85        let stale_paths: HashSet<String> = stale_paths.iter().cloned().collect();
86        // map evidence id -> path once per view construction
87        let evidence_paths: HashMap<String, String> = store
88            .all_evidence()
89            .ok()
90            .map(|evs| {
91                evs.into_iter()
92                    .filter_map(|e| e.path.map(|p| (e.id, p)))
93                    .collect()
94            })
95            .unwrap_or_default();
96
97        let mut stale_evidence: HashSet<String> = HashSet::new();
98        for (id, path) in &evidence_paths {
99            if stale_paths.contains(path) {
100                stale_evidence.insert(id.clone());
101            }
102        }
103
104        let mut stale_entities: HashSet<String> = HashSet::new();
105        for e in graph.entities.values() {
106            if e.evidence.iter().any(|ev| stale_evidence.contains(ev)) {
107                stale_entities.insert(e.id.clone());
108            }
109        }
110
111        TrustedGraphView {
112            graph,
113            stale_paths,
114            stale_evidence,
115            stale_entities,
116            policy,
117        }
118    }
119
120    pub fn policy(&self) -> &TrustPolicy {
121        &self.policy
122    }
123
124    pub fn is_stale_path(&self, path: &str) -> bool {
125        self.stale_paths.contains(path)
126    }
127
128    pub fn stale_paths(&self) -> Vec<String> {
129        let mut v: Vec<String> = self.stale_paths.iter().cloned().collect();
130        v.sort();
131        v
132    }
133
134    pub fn is_stale_evidence(&self, id: &str) -> bool {
135        self.stale_evidence.contains(id)
136    }
137
138    pub fn is_stale_entity(&self, id: &str) -> bool {
139        self.stale_entities.contains(id)
140    }
141
142    fn rel_allowed(&self, r: &Relationship) -> bool {
143        if r.evidence.iter().any(|ev| self.stale_evidence.contains(ev)) {
144            return false;
145        }
146        self.policy.allows(r.provenance, r.confidence)
147    }
148
149    // ---- entity access (staleness-filtered; names remain readable) ----
150
151    /// Trusted entity lookup: `None` for entities whose evidence is stale.
152    pub fn entity(&self, id: &str) -> Option<&Entity> {
153        self.graph
154            .entities
155            .get(id)
156            .filter(|_| !self.is_stale_entity(id))
157    }
158
159    /// Display name of an entity id; falls back to the id's last segment.
160    /// Entity staleness does not hide the name — callers decide whether the
161    /// entity may appear in trusted sections.
162    pub fn name_of(&self, id: &str) -> String {
163        self.graph
164            .entities
165            .get(id)
166            .map(|e| e.name.clone())
167            .unwrap_or_else(|| id.rsplit('/').next().unwrap_or(id).to_string())
168    }
169
170    /// All entities, staleness-filtered (unfiltered raw map for display
171    /// helpers that need presence only).
172    pub fn entities(&self) -> impl Iterator<Item = &Entity> {
173        self.graph
174            .entities
175            .values()
176            .filter(|e| !self.is_stale_entity(&e.id))
177    }
178
179    /// Entities of a kind (staleness-filtered, name-sorted for determinism).
180    pub fn entities_of_kind(&self, kind: &str) -> Vec<&Entity> {
181        let mut v: Vec<&Entity> = self
182            .graph
183            .entities
184            .values()
185            .filter(|e| e.kind == kind && !self.is_stale_entity(&e.id))
186            .collect();
187        v.sort_by(|a, b| a.name.cmp(&b.name));
188        v
189    }
190
191    // ---- relationship access (staleness + policy filtered) ----
192
193    pub fn out_edges(&self, id: &str) -> Vec<&Relationship> {
194        self.graph
195            .out
196            .get(id)
197            .map(|v| v.iter().filter(|r| self.rel_allowed(r)).collect())
198            .unwrap_or_default()
199    }
200
201    pub fn in_edges(&self, id: &str) -> Vec<&Relationship> {
202        self.graph
203            .inn
204            .get(id)
205            .map(|v| v.iter().filter(|r| self.rel_allowed(r)).collect())
206            .unwrap_or_default()
207    }
208
209    pub fn out_pred(&self, id: &str, predicate: &str) -> Vec<&Relationship> {
210        self.graph
211            .out
212            .get(id)
213            .map(|v| {
214                v.iter()
215                    .filter(|r| r.predicate == predicate && self.rel_allowed(r))
216                    .collect()
217            })
218            .unwrap_or_default()
219    }
220
221    pub fn in_pred(&self, id: &str, predicate: &str) -> Vec<&Relationship> {
222        self.graph
223            .inn
224            .get(id)
225            .map(|v| {
226                v.iter()
227                    .filter(|r| r.predicate == predicate && self.rel_allowed(r))
228                    .collect()
229            })
230            .unwrap_or_default()
231    }
232
233    /// All trusted relationships, sorted by id for determinism.
234    pub fn all_rels(&self) -> Vec<&Relationship> {
235        let mut v: Vec<&Relationship> = self
236            .graph
237            .out
238            .values()
239            .flatten()
240            .filter(|r| self.rel_allowed(r))
241            .collect();
242        v.sort_by(|a, b| a.id.cmp(&b.id));
243        v
244    }
245
246    // ---- derived tables (staleness + policy filtered) ----
247
248    /// Whether a derived claim passes the trust policy. `None` provenance
249    /// (compilers that never recorded one) is treated as extracted-grade;
250    /// INFERRED claims use the provenance's default confidence when no
251    /// explicit confidence exists.
252    fn claim_allowed(&self, prov: Option<Provenance>) -> bool {
253        match prov {
254            None => true,
255            Some(p) => self.policy.allows(p, p.default_confidence()),
256        }
257    }
258
259    /// Staleness + policy filtered flows. A flow is stale when any step's
260    /// evidence is stale; steps whose provenance the policy rejects
261    /// (INFERRED below the confidence floor) are removed, and a flow left
262    /// with fewer than two steps is dropped — a low-confidence derived
263    /// interpretation must never reach the atlas as architecture.
264    pub fn flows(&self) -> Vec<Flow> {
265        let mut out: Vec<Flow> = Vec::new();
266        for f in &self.graph.flows {
267            let stale = f
268                .steps
269                .iter()
270                .any(|s| s.evidence.iter().any(|ev| self.stale_evidence.contains(ev)));
271            if stale {
272                continue;
273            }
274            let mut steps: Vec<scc_core::FlowStep> = Vec::new();
275            for s in &f.steps {
276                if !self.claim_allowed(s.provenance) {
277                    continue;
278                }
279                steps.push(s.clone());
280            }
281            if steps.is_empty() {
282                continue;
283            }
284            let mut nf = f.clone();
285            nf.steps = steps;
286            out.push(nf);
287        }
288        out
289    }
290
291    /// Staleness + policy filtered invariants.
292    pub fn invariants(&self) -> Vec<Invariant> {
293        let mut out: Vec<Invariant> = Vec::new();
294        for i in &self.graph.invariants {
295            let stale = i.evidence.iter().any(|ev| self.stale_evidence.contains(ev));
296            if stale {
297                continue;
298            }
299            if !self.claim_allowed(i.provenance) {
300                continue;
301            }
302            out.push(i.clone());
303        }
304        out
305    }
306
307    /// Staleness + policy filtered components: responsibility/ownership/
308    /// dependency CLAIMS rejected by the policy are removed from the
309    /// attributes, so `include_low_confidence_inference: false` really
310    /// means low-confidence inferred claims never appear in the atlas.
311    pub fn components(&self) -> Vec<Entity> {
312        let mut out: Vec<Entity> = Vec::new();
313        for c in &self.graph.components {
314            let stale = c
315                .evidence
316                .iter()
317                .any(|ev| self.stale_evidence.contains(ev));
318            if stale {
319                continue;
320            }
321            let mut nc = c.clone();
322            let attr_claims = |v: &serde_json::Value, prov_key: &str| -> Option<serde_json::Value> {
323                let arr = v.as_array()?;
324                let kept: Vec<&serde_json::Value> = arr
325                    .iter()
326                    .filter(|claim| {
327                        let prov = claim
328                            .get(prov_key)
329                            .and_then(|p| p.as_str())
330                            .map(parse_prov);
331                        let conf = claim
332                            .get("confidence")
333                            .and_then(|c| c.as_f64())
334                            .unwrap_or(1.0);
335                        match prov {
336                            None => true,
337                            Some(p) => self.policy.allows(p, conf),
338                        }
339                    })
340                    .collect();
341                if kept.len() == arr.len() {
342                    None // unchanged
343                } else {
344                    Some(serde_json::Value::Array(kept.into_iter().cloned().collect()))
345                }
346            };
347            for key in ["responsibility", "owns", "depends_on"] {
348                if let Some(v) = nc.attributes.get(key).cloned() {
349                    if let Some(filtered) = attr_claims(&v, "provenance") {
350                        nc.attributes.insert(key.to_string(), filtered);
351                    }
352                }
353            }
354            out.push(nc);
355        }
356        out
357    }
358
359    // ---- staleness warnings ----
360
361    /// Deterministic stale-fact warnings for pack footers.
362    pub fn stale_warnings(&self) -> Vec<String> {
363        let mut v: Vec<String> = Vec::new();
364        let mut paths: Vec<&String> = self.stale_paths.iter().collect();
365        paths.sort();
366        for p in paths {
367            v.push(format!("{p} changed since indexing — its facts are excluded (STALE)"));
368        }
369        v
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use scc_core::{Evidence, EvidenceType};
377    use scc_store::Store;
378
379    fn setup() -> (Store, tempfile::TempDir) {
380        let dir = tempfile::TempDir::new().unwrap();
381        let root = dir.path().join("repo");
382        std::fs::create_dir_all(&root).unwrap();
383        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
384        (store, dir)
385    }
386
387    fn entity(id: &str, kind: &str, evidence: Vec<&str>) -> scc_core::Entity {
388        let evidence: Vec<String> = evidence.into_iter().map(|s| s.to_string()).collect();
389        let _ = kind;
390        let mut e = scc_core::Entity::new(id, "component", id.rsplit('/').next().unwrap_or(id));
391        e.evidence = evidence;
392        e
393    }
394
395    fn view<'a>(
396        store: &'a Store,
397        graph: &'a RealityGraph,
398        stale: &[String],
399    ) -> TrustedGraphView<'a> {
400        TrustedGraphView::new(graph, store, stale, TrustPolicy::default())
401    }
402
403    #[test]
404    fn stale_facts_are_excluded_and_warned() {
405        let (store, _d) = setup();
406        let ev = Evidence {
407            id: "evidence:1".into(),
408            r#type: EvidenceType::Source,
409            path: Some("main.py".into()),
410            symbol: None,
411            start_line: None,
412            end_line: None,
413            revision: None,
414            content_hash: None,
415            extractor: Some("test".into()),
416            extractor_version: None,
417        };
418        store.insert_evidence(&ev).unwrap();
419
420        // graph: sym calls other; both facts point at main.py evidence
421        let sym = entity("repo://r/symbol/a", "symbol", vec![]);
422        let rel = scc_core::Relationship::new(
423            "rel:1",
424            sym.id.clone(),
425            scc_core::predicates::CALLS,
426            "repo://r/symbol/b",
427            Provenance::Extracted,
428        )
429        .with_evidence(vec!["evidence:1".to_string()]);
430        let mut graph = RealityGraph {
431            repo_id: "r".into(),
432            entities: [(sym.id.clone(), sym.clone())].into_iter().collect(),
433            out: [(sym.id.clone(), vec![rel.clone()])].into_iter().collect(),
434            inn: [(rel.object.clone(), vec![rel])].into_iter().collect(),
435            components: vec![],
436            flows: vec![],
437            invariants: vec![],
438        };
439        graph.components = vec![entity(
440            "repo://r/component/c",
441            "component",
442            vec!["evidence:1"],
443        )];
444
445        // fresh view: everything visible
446        let v = view(&store, &graph, &[]);
447        assert_eq!(v.out_edges(&sym.id).len(), 1);
448        assert!(v.entity(&sym.id).is_some());
449        assert_eq!(v.components().len(), 1);
450        assert!(v.stale_warnings().is_empty());
451
452        // stale view: fact excluded, warning surfaced; the symbol itself
453        // carries no stale evidence (only its CALLS fact does), so it stays
454        // visible — but the component whose evidence is stale is hidden
455        let v = view(&store, &graph, &["main.py".to_string()]);
456        assert!(v.out_edges(&sym.id).is_empty());
457        assert!(v.entity(&sym.id).is_some());
458        assert!(v.components().is_empty());
459        let warns = v.stale_warnings();
460        assert_eq!(warns.len(), 1);
461        assert!(warns[0].contains("main.py"));
462    }
463
464    #[test]
465    fn policy_filters_derived_claims_not_just_relationships() {
466        // P0: include_low_confidence_inference: false must strip
467        // low-confidence INFERRED claims from derived flows/components —
468        // not only from raw relationships.
469        let (store, _d) = setup();
470        let mut flow = Flow {
471            id: "repo://r/flow/f1".into(),
472            kind: scc_core::FlowKind::Lifecycle,
473            name: "svc-lifecycle".into(),
474            trigger: None,
475            steps: vec![
476                scc_core::FlowStep {
477                    id: "step:1".into(),
478                    order: 1,
479                    actor: "repo://r/component/svc".into(),
480                    operation: "svc".into(),
481                    condition: None,
482                    r#async: None,
483                    timeout_ms: None,
484                    retry_policy: None,
485                    failure_outcome: None,
486                    provenance: Some(Provenance::Inferred),
487                    evidence: vec![],
488                },
489                scc_core::FlowStep {
490                    id: "step:2".into(),
491                    order: 2,
492                    actor: "repo://r/component/svc".into(),
493                    operation: "advance".into(),
494                    condition: None,
495                    r#async: None,
496                    timeout_ms: None,
497                    retry_policy: None,
498                    failure_outcome: None,
499                    provenance: Some(Provenance::Inferred),
500                    evidence: vec![],
501                },
502            ],
503            attributes: Default::default(),
504        };
505        let _ = &mut flow;
506
507        // a component carrying a low-confidence inferred responsibility
508        let mut comp = entity("repo://r/component/svc", "component", vec![]);
509        comp.attributes.insert(
510            "responsibility".into(),
511            serde_json::json!([
512                {"text": "Handles GET /api/x", "provenance": "RESOLVED", "confidence": 1.0},
513                {"text": "Hosts the svc code module", "provenance": "INFERRED", "confidence": 0.5}
514            ]),
515        );
516        let graph = RealityGraph {
517            repo_id: "r".into(),
518            entities: Default::default(),
519            out: Default::default(),
520            inn: Default::default(),
521            components: vec![comp.clone()],
522            flows: vec![flow.clone()],
523            invariants: vec![],
524        };
525
526        // default policy (floor 0.85): inferred steps and claims vanish
527        let v = view(&store, &graph, &[]);
528        let flows = v.flows();
529        assert!(flows.is_empty(), "inferred lifecycle must not survive: {flows:?}");
530        let comps = v.components();
531        assert_eq!(comps.len(), 1);
532        let resp = comps[0].attributes["responsibility"].as_array().unwrap();
533        assert_eq!(resp.len(), 1, "low-confidence inferred claim filtered: {resp:?}");
534        assert_eq!(resp[0]["provenance"], "RESOLVED");
535
536        // explicit floor 0.0 keeps labeled inference
537        let v = TrustedGraphView::new(
538            &graph,
539            &store,
540            &[],
541            TrustPolicy::default().with_inferred_floor(0.0),
542        );
543        assert_eq!(v.flows().len(), 1, "floor 0 keeps the inferred lifecycle");
544        assert_eq!(
545            v.components()[0].attributes["responsibility"].as_array().unwrap().len(),
546            2
547        );
548    }
549
550    #[test]
551    fn policy_gates_inferred_confidence() {
552        let (store, _d) = setup();
553        let sym = entity("repo://r/symbol/a", "symbol", vec![]);
554        let low = scc_core::Relationship::new(
555            "rel:low",
556            sym.id.clone(),
557            scc_core::predicates::CALLS,
558            "repo://r/symbol/b",
559            Provenance::Inferred,
560        )
561        .with_confidence(0.5);
562        let high = scc_core::Relationship::new(
563            "rel:high",
564            sym.id.clone(),
565            scc_core::predicates::CALLS,
566            "repo://r/symbol/c",
567            Provenance::Inferred,
568        )
569        .with_confidence(0.9);
570        let graph = RealityGraph {
571            repo_id: "r".into(),
572            entities: [(sym.id.clone(), sym.clone())].into_iter().collect(),
573            out: [(sym.id.clone(), vec![low.clone(), high.clone()])].into_iter().collect(),
574            inn: [(low.object.clone(), vec![low]), (high.object.clone(), vec![high])]
575                .into_iter()
576                .collect(),
577            components: vec![],
578            flows: vec![],
579            invariants: vec![],
580        };
581
582        // default floor 0.85: low-confidence inference excluded
583        let v = view(&store, &graph, &[]);
584        let edges = v.out_edges(&sym.id);
585        assert_eq!(edges.len(), 1);
586        assert_eq!(edges[0].id, "rel:high");
587
588        // explicit floor 0.0: all labeled inference trusted
589        let v = TrustedGraphView::new(
590            &graph,
591            &store,
592            &[],
593            TrustPolicy::default().with_inferred_floor(0.0),
594        );
595        assert_eq!(v.out_edges(&sym.id).len(), 2);
596    }
597}