Skip to main content

scc_graph/
boundaries.rs

1//! Trust-boundary compiler (SCC-148): derives `crosses_boundary`
2//! relationships from deployment units, component dependencies, and calls
3//! to external APIs.
4//!
5//! Model:
6//! - Deployment units (entities kind `deployment_unit`) with a
7//!   `build_context` attribute map to that directory; units with only an
8//!   `image` attribute map to their own name (no directory is recorded, so
9//!   the unit name is the best deterministic stand-in). Units with neither
10//!   attribute (e.g. pure Dockerfile units) are ignored.
11//! - A component (from `store.components()`) belongs to the unit whose
12//!   directory is the longest prefix match against any of the component's
13//!   `implementation.paths`. Components matching no unit belong to the
14//!   synthetic unit `local`.
15//! - Every RESOLVED `depends_on` edge between components in different units,
16//!   and every `calls` edge into an `external_api`, becomes a
17//!   `crosses_boundary` relationship carrying the evidence of the underlying
18//!   fact. Ids are content-derived (blake3, `rel:boundary:` prefix) and the
19//!   derived set is replaced wholesale on each compile, so the output is
20//!   deterministic and idempotent.
21
22use crate::{RealityGraph, Result};
23use scc_core::kinds;
24use scc_core::{Entity, Provenance, Relationship};
25use scc_store::Store;
26
27pub const RELPREFIX: &str = "rel:boundary:";
28
29fn rel_id(parts: &[&str]) -> String {
30    let mut h = blake3::Hasher::new();
31    for p in parts {
32        h.update(p.as_bytes());
33        h.update(b"|");
34    }
35    format!("{RELPREFIX}{}", &h.finalize().to_hex()[..12])
36}
37
38/// `(unit name, directory)` pairs for every deployment unit that maps to a
39/// directory. `build_context` wins; image-only units fall back to their name;
40/// `"."`/empty build contexts are skipped (they would match everything).
41fn unit_dirs(graph: &RealityGraph) -> Vec<(String, String)> {
42    let mut out: Vec<(String, String)> = Vec::new();
43    for e in graph.entities_of_kind(kinds::DEPLOYMENT_UNIT) {
44        let dir = if let Some(ctx) = e.attributes.get("build_context").and_then(|v| v.as_str()) {
45            let ctx = ctx.trim().trim_start_matches("./");
46            if ctx.is_empty() || ctx == "." {
47                continue;
48            }
49            ctx.to_string()
50        } else if e.attributes.contains_key("image") {
51            e.name.clone()
52        } else {
53            continue;
54        };
55        out.push((e.name.clone(), dir));
56    }
57    out
58}
59
60/// Unit owning the component whose `implementation.paths` best match `path`
61/// (longest directory prefix wins), or `"local"` when nothing matches.
62fn unit_for_component(comp: &Entity, units: &[(String, String)]) -> String {
63    let mut best: Option<(String, usize)> = None;
64    if let Some(paths) = comp
65        .attributes
66        .get("implementation")
67        .and_then(|v| v.get("paths"))
68        .and_then(|v| v.as_array())
69    {
70        for p in paths {
71            if let Some(p) = p.as_str() {
72                for (name, dir) in units {
73                    if (p == dir || p.starts_with(&format!("{dir}/")))
74                        && best.as_ref().map(|(_, l)| dir.len() > *l).unwrap_or(true)
75                    {
76                        best = Some((name.clone(), dir.len()));
77                    }
78                }
79            }
80        }
81    }
82    best.map(|(n, _)| n).unwrap_or_else(|| "local".to_string())
83}
84
85/// Compile the full set of trust-boundary crossings for the current reality
86/// graph. Returns `(relationship, source_path)` pairs ready for
87/// `store.insert_relationship`; the source path is empty (derived facts).
88/// Replaces any previously compiled crossings (stale edges from removed
89/// dependencies or calls do not survive a rebuild).
90pub fn compile_boundaries(graph: &RealityGraph, store: &Store) -> Result<Vec<(Relationship, String)>> {
91    // drop the previous derived set so removed edges don't linger
92    let stale: Vec<String> = store
93        .all_relationships()?
94        .into_iter()
95        .filter(|r| r.id.starts_with(RELPREFIX))
96        .map(|r| r.id)
97        .collect();
98    for id in stale {
99        store.delete_relationship(&id)?;
100    }
101
102    let units = unit_dirs(graph);
103    let mut crossings: Vec<Relationship> = Vec::new();
104    for r in store.all_relationships()? {
105        if r.predicate == scc_core::predicates::DEPENDS_ON
106            && r.provenance == Provenance::Resolved
107        {
108            // component dependency crossing a unit boundary
109            let (subj, obj) = match (graph.entity(&r.subject), graph.entity(&r.object)) {
110                (Some(s), Some(o)) => (s, o),
111                _ => continue,
112            };
113            if subj.kind != kinds::COMPONENT || obj.kind != kinds::COMPONENT {
114                continue;
115            }
116            if unit_for_component(subj, &units) == unit_for_component(obj, &units) {
117                continue;
118            }
119            crossings.push(
120                Relationship::new(
121                    rel_id(&["crosses_boundary", &r.subject, &r.object]),
122                    r.subject.clone(),
123                    scc_core::predicates::CROSSES_BOUNDARY,
124                    r.object.clone(),
125                    Provenance::Extracted,
126                )
127                .with_confidence(0.9)
128                .with_evidence(r.evidence.clone()),
129            );
130        } else if r.predicate == scc_core::predicates::CALLS
131            && r.object.contains("/external_api/")
132        {
133            // call from a symbol into an external API leaves the unit
134            crossings.push(
135                Relationship::new(
136                    rel_id(&["external_crossing", &r.subject, &r.object]),
137                    r.subject.clone(),
138                    scc_core::predicates::CROSSES_BOUNDARY,
139                    r.object.clone(),
140                    Provenance::Extracted,
141                )
142                .with_confidence(0.9)
143                .with_evidence(r.evidence.clone()),
144            );
145        }
146    }
147
148    crossings.sort_by(|a, b| a.subject.cmp(&b.subject).then_with(|| a.object.cmp(&b.object)));
149    Ok(crossings.into_iter().map(|r| (r, String::new())).collect())
150}
151
152/// Human-readable, sorted list of boundary crossings in the form
153/// `"unitA/compA -> unitB/compB"` (external crossings read
154/// `"unit/comp -> external/name"`), for `verify`/CLI/Atlas display.
155///
156/// P0 correctness: this is a PURE READ of the STORED `CROSSES_BOUNDARY`
157/// relationships (inserted by `compile_boundaries` during recompile). It
158/// never mutates the database — context generation must be side-effect free.
159/// An atlas/verify run on an un-recompiled store shows the last compiled
160/// crossings; run `scc index` (or the pipeline) to refresh them.
161// trace:exempt reason=internal-detail
162pub fn boundary_crossings(graph: &RealityGraph, store: &Store) -> Result<Vec<String>> {
163    crossing_lines(graph, store, false)
164}
165
166/// [`boundary_crossings`] restricted to production-side crossings: a
167/// crossing whose subject (component or calling symbol) lives under a
168/// test/fixture/benchmark/example tree is fixture chatter, not
169/// architecture. Used by atlas scope filtering; `verify`/CLI keep the
170/// unfiltered diagnostic view.
171// trace:exempt reason=internal-detail
172pub fn production_crossings(graph: &RealityGraph, store: &Store) -> Result<Vec<String>> {
173    crossing_lines(graph, store, true)
174}
175
176/// Shared crossing renderer. `prod_only` keeps a crossing only when its
177/// subject side is production-placed; unplaceable subjects are kept
178/// (never drop facts we cannot place).
179// trace:exempt reason=internal-detail
180fn crossing_lines(graph: &RealityGraph, store: &Store, prod_only: bool) -> Result<Vec<String>> {
181    let units = unit_dirs(graph);
182    let comps = store.components()?;
183    let mut lines: Vec<String> = Vec::new();
184    for rel in store.all_relationships()? {
185        if rel.predicate != scc_core::predicates::CROSSES_BOUNDARY {
186            continue;
187        }
188        if prod_only && !crossing_subject_production(graph, &rel.subject) {
189            continue;
190        }
191        let Some(subj) = graph.entity(&rel.subject) else {
192            continue;
193        };
194        let obj_name = graph
195            .entity(&rel.object)
196            .map(|o| o.name.clone())
197            .unwrap_or_else(|| {
198                rel.object.rsplit('/').next().unwrap_or(&rel.object).to_string()
199            });
200        match subj.kind.as_str() {
201            kinds::COMPONENT => {
202                let ua = unit_for_component(subj, &units);
203                let ub = unit_for_component(
204                    graph.entity(&rel.object).unwrap_or(subj),
205                    &units,
206                );
207                lines.push(format!("{ua}/{} -> {ub}/{obj_name}", subj.name));
208            }
209            kinds::SYMBOL => {
210                let (unit, owner) = component_of_symbol(graph, &rel.subject, &comps)
211                    .map(|c| (unit_for_component(c, &units), c.name.clone()))
212                    .unwrap_or_else(|| ("local".to_string(), subj.name.clone()));
213                lines.push(format!("{unit}/{owner} -> external/{obj_name}"));
214            }
215            _ => {}
216        }
217    }
218    lines.sort();
219    lines.dedup();
220    Ok(lines)
221}
222
223/// True when a crossing's subject side is production-placed (or
224/// unplaceable — kept, never dropped).
225// trace:exempt reason=internal-detail
226fn crossing_subject_production(graph: &RealityGraph, subject: &str) -> bool {
227    let Some(subj) = graph.entity(subject) else {
228        return true;
229    };
230    match subj.kind.as_str() {
231        kinds::COMPONENT => {
232            let paths: Vec<String> = subj
233                .attributes
234                .get("implementation")
235                .and_then(|v| v.get("paths"))
236                .and_then(|v| v.as_array())
237                .map(|a| {
238                    a.iter()
239                        .filter_map(|x| x.as_str().map(String::from))
240                        .collect()
241                })
242                .unwrap_or_default();
243            crate::components::component_role(&paths) == "production"
244        }
245        _ => subj
246            .attributes
247            .get("file")
248            .and_then(|v| v.as_str())
249            .and_then(crate::components::path_role)
250            .map(|r| r == "production")
251            .unwrap_or(true),
252    }
253}
254
255/// The component whose `implementation.paths` best match the file attribute
256/// of symbol `sym_id` (longest directory prefix wins).
257fn component_of_symbol<'a>(
258    graph: &RealityGraph,
259    sym_id: &str,
260    comps: &'a [Entity],
261) -> Option<&'a Entity> {
262    let file = graph.entity(sym_id)?.attributes.get("file")?.as_str()?;
263    let mut best: Option<(&'a Entity, usize)> = None;
264    for c in comps {
265        if let Some(paths) = c
266            .attributes
267            .get("implementation")
268            .and_then(|v| v.get("paths"))
269            .and_then(|v| v.as_array())
270        {
271            for p in paths {
272                if let Some(p) = p.as_str() {
273                    if file == p || file.starts_with(&format!("{p}/")) {
274                        let len = p.len();
275                        if best.map(|(_, l)| len > l).unwrap_or(true) {
276                            best = Some((c, len));
277                        }
278                    }
279                }
280            }
281        }
282    }
283    best.map(|(c, _)| c)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use scc_core::{entity_id, symbol_id};
290
291    fn store_with() -> (Store, tempfile::TempDir) {
292        let tmp = tempfile::TempDir::new().unwrap();
293        let root = tmp.path().join("repo");
294        std::fs::create_dir_all(&root).unwrap();
295        let store = Store::open(&tmp.path().join("scc.db"), &root).unwrap();
296        (store, tmp)
297    }
298
299    fn unit(store: &Store, name: &str, dir: &str) {
300        let mut e = Entity::new(
301            entity_id(&store.repo_id, kinds::DEPLOYMENT_UNIT, name),
302            kinds::DEPLOYMENT_UNIT,
303            name,
304        );
305        e.attr("build_context", serde_json::json!(dir));
306        store.insert_entity(&e, &[]).unwrap();
307    }
308
309    fn component(store: &Store, name: &str, paths: &[&str]) -> Entity {
310        let mut e = Entity::new(
311            entity_id(&store.repo_id, kinds::COMPONENT, name),
312            kinds::COMPONENT,
313            name,
314        );
315        e.attr(
316            "implementation",
317            serde_json::json!({ "paths": paths, "symbols": [] }),
318        );
319        e
320    }
321
322    fn dep(store: &Store, from: &Entity, to: &Entity, prov: Provenance) {
323        let r = Relationship::new(
324            format!("rel:test:{}:{}", from.name, to.name),
325            from.id.clone(),
326            scc_core::predicates::DEPENDS_ON,
327            to.id.clone(),
328            prov,
329        )
330        .with_evidence(vec!["evidence:test1".to_string()]);
331        store.insert_relationship(&r, "").unwrap();
332    }
333
334    /// Units web (`services/web`) and api (`services/api`); components web,
335    /// web-worker (both in unit web), api, util (unassigned -> local).
336    /// Dependencies: web->api (cross), web->web-worker (same unit),
337    /// web-worker->util (cross), api->util (cross), plus a non-resolved
338    /// web->util edge that must be ignored.
339    fn setup_basic(store: &Store) -> Vec<Entity> {
340        unit(store, "web", "services/web");
341        unit(store, "api", "services/api");
342        let web = component(store, "web", &["services/web"]);
343        let web_worker = component(store, "web-worker", &["services/web/worker"]);
344        let api = component(store, "api", &["services/api"]);
345        let util = component(store, "util", &["shared"]);
346        let comps = vec![web.clone(), web_worker.clone(), api.clone(), util.clone()];
347        store.replace_components(&comps).unwrap();
348        dep(store, &web, &api, Provenance::Resolved);
349        dep(store, &web, &web_worker, Provenance::Resolved);
350        dep(store, &web_worker, &util, Provenance::Resolved);
351        dep(store, &api, &util, Provenance::Resolved);
352        dep(store, &web, &util, Provenance::Extracted);
353        comps
354    }
355
356    #[test]
357    // trace:v1 id=test.scc.graph.boundary-display-pure work=WORK-phase-7-of-scc-x-ripwire-lessons-1-one-hop-type-narrowing-from-unique verifies=REQ-implement-phase-7-of-scc-x-ripwire-lessons-1-one-hop-type-narrowing
358    fn display_does_not_mutate_the_store() {
359        // P0 regression: boundary_crossings() is a pure read. Calling it
360        // (atlas/verify) must not delete or add relationships.
361        let (store, _t) = store_with();
362        let _comps = setup_basic(&store);
363        let graph = RealityGraph::load(&store).unwrap();
364
365        let before = store.all_relationships().unwrap().len();
366        let lines = boundary_crossings(&graph, &store).unwrap();
367        let after = store.all_relationships().unwrap().len();
368        assert_eq!(before, after, "display must not mutate the store");
369        // the display shows the STORED crossings — none until the pipeline
370        // compiles them, so an un-recompiled store renders empty
371        assert!(lines.is_empty(), "{lines:?}");
372
373        // Compile crossings without reclustering: a full `recompile()` would
374        // replace components from an empty file set and drop the hand-built
375        // units under vanished-entity cleanup (needed for incremental≡cold).
376        let compiled = compile_boundaries(&graph, &store).unwrap();
377        for (rel, src) in compiled {
378            store.insert_relationship(&rel, &src).unwrap();
379        }
380        let after_compile = store.all_relationships().unwrap().len();
381        assert!(after_compile > before, "compile inserts crossings");
382        let before2 = store.all_relationships().unwrap().len();
383        let lines2 = boundary_crossings(&graph, &store).unwrap();
384        let after2 = store.all_relationships().unwrap().len();
385        assert_eq!(before2, after2, "display must not mutate the store (2)");
386        assert!(!lines2.is_empty(), "compiled crossings render: {lines2:?}");
387    }
388
389    #[test]
390    fn crossings_only_across_units() {
391        let (store, _t) = store_with();
392        let comps = setup_basic(&store);
393        let graph = RealityGraph::load(&store).unwrap();
394        let out = compile_boundaries(&graph, &store).unwrap();
395        assert_eq!(out.len(), 3, "web->api, web-worker->util, api->util");
396
397        for (rel, src) in &out {
398            assert_eq!(rel.predicate, scc_core::predicates::CROSSES_BOUNDARY);
399            assert_eq!(rel.provenance, Provenance::Extracted);
400            assert_eq!(rel.confidence, 0.9);
401            assert_eq!(rel.evidence, vec!["evidence:test1".to_string()]);
402            assert!(src.is_empty(), "derived facts carry no source path");
403        }
404
405        let find = |name: &str| comps.iter().find(|c| c.name == name).unwrap();
406        let web = find("web");
407        let web_worker = find("web-worker");
408        let api = find("api");
409        let util = find("util");
410
411        // same-unit edge and non-resolved edge produce no crossing
412        assert!(!out.iter().any(|(r, _)| r.subject == web.id && r.object == web_worker.id));
413        assert!(!out.iter().any(|(r, _)| r.subject == web.id && r.object == util.id));
414        // cross-unit edges are all present
415        assert!(out.iter().any(|(r, _)| r.subject == web.id && r.object == api.id));
416        assert!(out.iter().any(|(r, _)| r.subject == web_worker.id && r.object == util.id));
417        assert!(out.iter().any(|(r, _)| r.subject == api.id && r.object == util.id));
418
419        // idempotent: recompiling replaces, never duplicates
420        let out2 = compile_boundaries(&graph, &store).unwrap();
421        assert_eq!(out2.len(), 3);
422    }
423
424    #[test]
425    fn external_api_call_crosses_boundary() {
426        let (store, _t) = store_with();
427        unit(&store, "api", "services/api");
428        let api_comp = component(&store, "api", &["services/api"]);
429        store.replace_components(&[api_comp]).unwrap();
430
431        let sym = symbol_id(&store.repo_id, "services/api/app.py", "main");
432        let ext = entity_id(&store.repo_id, kinds::EXTERNAL_API, "stripe.api");
433        let mut se = Entity::new(sym.clone(), kinds::SYMBOL, "main");
434        se.attr("file", serde_json::json!("services/api/app.py"));
435        store.insert_entity(&se, &[]).unwrap();
436        store
437            .insert_entity(&Entity::new(ext.clone(), kinds::EXTERNAL_API, "stripe.api"), &[])
438            .unwrap();
439        store
440            .insert_relationship(
441                &Relationship::new(
442                    "rel:test:call".to_string(),
443                    sym.clone(),
444                    scc_core::predicates::CALLS,
445                    ext.clone(),
446                    Provenance::Resolved,
447                )
448                .with_evidence(vec!["evidence:call1".to_string()]),
449                "services/api/app.py",
450            )
451            .unwrap();
452
453        let graph = RealityGraph::load(&store).unwrap();
454        let out = compile_boundaries(&graph, &store).unwrap();
455        assert_eq!(out.len(), 1);
456        let (rel, src) = &out[0];
457        assert_eq!(rel.subject, sym);
458        assert_eq!(rel.object, ext);
459        assert_eq!(rel.predicate, scc_core::predicates::CROSSES_BOUNDARY);
460        assert_eq!(rel.provenance, Provenance::Extracted);
461        assert_eq!(rel.confidence, 0.9);
462        assert_eq!(rel.evidence, vec!["evidence:call1".to_string()]);
463        assert!(src.is_empty());
464    }
465
466    #[test]
467    fn no_crossings_within_unit() {
468        let (store, _t) = store_with();
469        unit(&store, "svc", "src/svc");
470        let a = component(&store, "a", &["src/svc/a"]);
471        let b = component(&store, "b", &["src/svc/b"]);
472        store.replace_components(&[a.clone(), b.clone()]).unwrap();
473        dep(&store, &a, &b, Provenance::Resolved);
474        let graph = RealityGraph::load(&store).unwrap();
475        let out = compile_boundaries(&graph, &store).unwrap();
476        assert!(out.is_empty(), "same deployment unit: no crossing");
477        let lines = boundary_crossings(&graph, &store).unwrap();
478        assert!(lines.is_empty());
479    }
480
481    #[test]
482    fn boundary_crossings_strings() {
483        let (store, _t) = store_with();
484        setup_basic(&store);
485
486        let sym = symbol_id(&store.repo_id, "services/api/app.py", "main");
487        let ext = entity_id(&store.repo_id, kinds::EXTERNAL_API, "stripe.api");
488        let mut se = Entity::new(sym.clone(), kinds::SYMBOL, "main");
489        se.attr("file", serde_json::json!("services/api/app.py"));
490        store.insert_entity(&se, &[]).unwrap();
491        store
492            .insert_entity(&Entity::new(ext.clone(), kinds::EXTERNAL_API, "stripe.api"), &[])
493            .unwrap();
494        store
495            .insert_relationship(
496                &Relationship::new(
497                    "rel:test:call".to_string(),
498                    sym,
499                    scc_core::predicates::CALLS,
500                    ext,
501                    Provenance::Resolved,
502                ),
503                "services/api/app.py",
504            )
505            .unwrap();
506
507        let graph = RealityGraph::load(&store).unwrap();
508        // display is a pure read of stored crossings: compile first
509        let crossings = compile_boundaries(&graph, &store).unwrap();
510        for (rel, src) in crossings {
511            store.insert_relationship(&rel, &src).unwrap();
512        }
513        let lines = boundary_crossings(&graph, &store).unwrap();
514        assert_eq!(
515            lines,
516            vec![
517                "api/api -> external/stripe.api".to_string(),
518                "api/api -> local/util".to_string(),
519                "web/web -> api/api".to_string(),
520                "web/web-worker -> local/util".to_string(),
521            ]
522        );
523    }
524}