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`] over an already-loaded relationship slice (C6):
167/// same predicate filter and render path as [`crossing_lines`], without the
168/// extra full-table `all_relationships()` + `components()` store reads.
169/// Callers that already hold the trusted edge set (e.g. `verify`) use this.
170// trace:v1 id=impl.scc-graph-boundary-crossings-from-rels work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
171pub fn boundary_crossings_from_rels(
172    graph: &RealityGraph,
173    rels: &[&scc_core::Relationship],
174    prod_only: bool,
175) -> Vec<String> {
176    // Same arms as crossing_lines, over the caller's edge slice: no
177    // all_relationships() re-read, no components() re-read (graph holds
178    // the compiled components). Sort+dedup preserved.
179    let units = unit_dirs(graph);
180    let mut lines: Vec<String> = Vec::new();
181    for rel in rels {
182        if rel.predicate != scc_core::predicates::CROSSES_BOUNDARY {
183            continue;
184        }
185        if prod_only && !crossing_subject_production(graph, &rel.subject) {
186            continue;
187        }
188        let Some(subj) = graph.entity(&rel.subject) else {
189            continue;
190        };
191        let obj_name = graph
192            .entity(&rel.object)
193            .map(|o| o.name.clone())
194            .unwrap_or_else(|| {
195                rel.object.rsplit('/').next().unwrap_or(&rel.object).to_string()
196            });
197        match subj.kind.as_str() {
198            kinds::COMPONENT => {
199                let ua = unit_for_component(subj, &units);
200                let ub = unit_for_component(
201                    graph.entity(&rel.object).unwrap_or(subj),
202                    &units,
203                );
204                lines.push(format!("{ua}/{} -> {ub}/{obj_name}", subj.name));
205            }
206            kinds::SYMBOL => {
207                let (unit, owner) = component_of_symbol(graph, &rel.subject, &graph.components)
208                    .map(|c| (unit_for_component(c, &units), c.name.clone()))
209                    .unwrap_or_else(|| ("local".to_string(), subj.name.clone()));
210                lines.push(format!("{unit}/{owner} -> external/{obj_name}"));
211            }
212            _ => {}
213        }
214    }
215    lines.sort();
216    lines.dedup();
217    lines
218}
219
220/// [`boundary_crossings`] restricted to production-side crossings: a
221/// crossing whose subject (component or calling symbol) lives under a
222/// test/fixture/benchmark/example tree is fixture chatter, not
223/// architecture. Used by atlas scope filtering; `verify`/CLI keep the
224/// unfiltered diagnostic view.
225// trace:exempt reason=internal-detail
226pub fn production_crossings(graph: &RealityGraph, store: &Store) -> Result<Vec<String>> {
227    crossing_lines(graph, store, true)
228}
229
230/// Shared crossing renderer. `prod_only` keeps a crossing only when its
231/// subject side is production-placed; unplaceable subjects are kept
232/// (never drop facts we cannot place).
233// trace:exempt reason=internal-detail
234fn crossing_lines(graph: &RealityGraph, store: &Store, prod_only: bool) -> Result<Vec<String>> {
235    let units = unit_dirs(graph);
236    let comps = store.components()?;
237    let mut lines: Vec<String> = Vec::new();
238    for rel in store.all_relationships()? {
239        if rel.predicate != scc_core::predicates::CROSSES_BOUNDARY {
240            continue;
241        }
242        if prod_only && !crossing_subject_production(graph, &rel.subject) {
243            continue;
244        }
245        let Some(subj) = graph.entity(&rel.subject) else {
246            continue;
247        };
248        let obj_name = graph
249            .entity(&rel.object)
250            .map(|o| o.name.clone())
251            .unwrap_or_else(|| {
252                rel.object.rsplit('/').next().unwrap_or(&rel.object).to_string()
253            });
254        match subj.kind.as_str() {
255            kinds::COMPONENT => {
256                let ua = unit_for_component(subj, &units);
257                let ub = unit_for_component(
258                    graph.entity(&rel.object).unwrap_or(subj),
259                    &units,
260                );
261                lines.push(format!("{ua}/{} -> {ub}/{obj_name}", subj.name));
262            }
263            kinds::SYMBOL => {
264                let (unit, owner) = component_of_symbol(graph, &rel.subject, &comps)
265                    .map(|c| (unit_for_component(c, &units), c.name.clone()))
266                    .unwrap_or_else(|| ("local".to_string(), subj.name.clone()));
267                lines.push(format!("{unit}/{owner} -> external/{obj_name}"));
268            }
269            _ => {}
270        }
271    }
272    lines.sort();
273    lines.dedup();
274    Ok(lines)
275}
276
277/// True when a crossing's subject side is production-placed (or
278/// unplaceable — kept, never dropped).
279// trace:exempt reason=internal-detail
280fn crossing_subject_production(graph: &RealityGraph, subject: &str) -> bool {
281    let Some(subj) = graph.entity(subject) else {
282        return true;
283    };
284    match subj.kind.as_str() {
285        kinds::COMPONENT => {
286            let paths: Vec<String> = subj
287                .attributes
288                .get("implementation")
289                .and_then(|v| v.get("paths"))
290                .and_then(|v| v.as_array())
291                .map(|a| {
292                    a.iter()
293                        .filter_map(|x| x.as_str().map(String::from))
294                        .collect()
295                })
296                .unwrap_or_default();
297            crate::components::component_role(&paths) == "production"
298        }
299        _ => subj
300            .attributes
301            .get("file")
302            .and_then(|v| v.as_str())
303            .and_then(crate::components::path_role)
304            .map(|r| r == "production")
305            .unwrap_or(true),
306    }
307}
308
309/// The component whose `implementation.paths` best match the file attribute
310/// of symbol `sym_id` (longest directory prefix wins).
311fn component_of_symbol<'a>(
312    graph: &RealityGraph,
313    sym_id: &str,
314    comps: &'a [Entity],
315) -> Option<&'a Entity> {
316    let file = graph.entity(sym_id)?.attributes.get("file")?.as_str()?;
317    let mut best: Option<(&'a Entity, usize)> = None;
318    for c in comps {
319        if let Some(paths) = c
320            .attributes
321            .get("implementation")
322            .and_then(|v| v.get("paths"))
323            .and_then(|v| v.as_array())
324        {
325            for p in paths {
326                if let Some(p) = p.as_str() {
327                    if file == p || file.starts_with(&format!("{p}/")) {
328                        let len = p.len();
329                        if best.map(|(_, l)| len > l).unwrap_or(true) {
330                            best = Some((c, len));
331                        }
332                    }
333                }
334            }
335        }
336    }
337    best.map(|(c, _)| c)
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use scc_core::{entity_id, symbol_id};
344
345    fn store_with() -> (Store, tempfile::TempDir) {
346        let tmp = tempfile::TempDir::new().unwrap();
347        let root = tmp.path().join("repo");
348        std::fs::create_dir_all(&root).unwrap();
349        let store = Store::open(&tmp.path().join("scc.db"), &root).unwrap();
350        (store, tmp)
351    }
352
353    fn unit(store: &Store, name: &str, dir: &str) {
354        let mut e = Entity::new(
355            entity_id(&store.repo_id, kinds::DEPLOYMENT_UNIT, name),
356            kinds::DEPLOYMENT_UNIT,
357            name,
358        );
359        e.attr("build_context", serde_json::json!(dir));
360        store.insert_entity(&e, &[]).unwrap();
361    }
362
363    fn component(store: &Store, name: &str, paths: &[&str]) -> Entity {
364        let mut e = Entity::new(
365            entity_id(&store.repo_id, kinds::COMPONENT, name),
366            kinds::COMPONENT,
367            name,
368        );
369        e.attr(
370            "implementation",
371            serde_json::json!({ "paths": paths, "symbols": [] }),
372        );
373        e
374    }
375
376    fn dep(store: &Store, from: &Entity, to: &Entity, prov: Provenance) {
377        let r = Relationship::new(
378            format!("rel:test:{}:{}", from.name, to.name),
379            from.id.clone(),
380            scc_core::predicates::DEPENDS_ON,
381            to.id.clone(),
382            prov,
383        )
384        .with_evidence(vec!["evidence:test1".to_string()]);
385        store.insert_relationship(&r, "").unwrap();
386    }
387
388    /// Units web (`services/web`) and api (`services/api`); components web,
389    /// web-worker (both in unit web), api, util (unassigned -> local).
390    /// Dependencies: web->api (cross), web->web-worker (same unit),
391    /// web-worker->util (cross), api->util (cross), plus a non-resolved
392    /// web->util edge that must be ignored.
393    fn setup_basic(store: &Store) -> Vec<Entity> {
394        unit(store, "web", "services/web");
395        unit(store, "api", "services/api");
396        let web = component(store, "web", &["services/web"]);
397        let web_worker = component(store, "web-worker", &["services/web/worker"]);
398        let api = component(store, "api", &["services/api"]);
399        let util = component(store, "util", &["shared"]);
400        let comps = vec![web.clone(), web_worker.clone(), api.clone(), util.clone()];
401        store.replace_components(&comps).unwrap();
402        dep(store, &web, &api, Provenance::Resolved);
403        dep(store, &web, &web_worker, Provenance::Resolved);
404        dep(store, &web_worker, &util, Provenance::Resolved);
405        dep(store, &api, &util, Provenance::Resolved);
406        dep(store, &web, &util, Provenance::Extracted);
407        comps
408    }
409
410    #[test]
411    // 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
412    fn display_does_not_mutate_the_store() {
413        // P0 regression: boundary_crossings() is a pure read. Calling it
414        // (atlas/verify) must not delete or add relationships.
415        let (store, _t) = store_with();
416        let _comps = setup_basic(&store);
417        let graph = RealityGraph::load(&store).unwrap();
418
419        let before = store.all_relationships().unwrap().len();
420        let lines = boundary_crossings(&graph, &store).unwrap();
421        let after = store.all_relationships().unwrap().len();
422        assert_eq!(before, after, "display must not mutate the store");
423        // the display shows the STORED crossings — none until the pipeline
424        // compiles them, so an un-recompiled store renders empty
425        assert!(lines.is_empty(), "{lines:?}");
426
427        // Compile crossings without reclustering: a full `recompile()` would
428        // replace components from an empty file set and drop the hand-built
429        // units under vanished-entity cleanup (needed for incremental≡cold).
430        let compiled = compile_boundaries(&graph, &store).unwrap();
431        for (rel, src) in compiled {
432            store.insert_relationship(&rel, &src).unwrap();
433        }
434        let after_compile = store.all_relationships().unwrap().len();
435        assert!(after_compile > before, "compile inserts crossings");
436        let before2 = store.all_relationships().unwrap().len();
437        let lines2 = boundary_crossings(&graph, &store).unwrap();
438        let after2 = store.all_relationships().unwrap().len();
439        assert_eq!(before2, after2, "display must not mutate the store (2)");
440        assert!(!lines2.is_empty(), "compiled crossings render: {lines2:?}");
441    }
442
443    #[test]
444    fn crossings_only_across_units() {
445        let (store, _t) = store_with();
446        let comps = setup_basic(&store);
447        let graph = RealityGraph::load(&store).unwrap();
448        let out = compile_boundaries(&graph, &store).unwrap();
449        assert_eq!(out.len(), 3, "web->api, web-worker->util, api->util");
450
451        for (rel, src) in &out {
452            assert_eq!(rel.predicate, scc_core::predicates::CROSSES_BOUNDARY);
453            assert_eq!(rel.provenance, Provenance::Extracted);
454            assert_eq!(rel.confidence, 0.9);
455            assert_eq!(rel.evidence, vec!["evidence:test1".to_string()]);
456            assert!(src.is_empty(), "derived facts carry no source path");
457        }
458
459        let find = |name: &str| comps.iter().find(|c| c.name == name).unwrap();
460        let web = find("web");
461        let web_worker = find("web-worker");
462        let api = find("api");
463        let util = find("util");
464
465        // same-unit edge and non-resolved edge produce no crossing
466        assert!(!out.iter().any(|(r, _)| r.subject == web.id && r.object == web_worker.id));
467        assert!(!out.iter().any(|(r, _)| r.subject == web.id && r.object == util.id));
468        // cross-unit edges are all present
469        assert!(out.iter().any(|(r, _)| r.subject == web.id && r.object == api.id));
470        assert!(out.iter().any(|(r, _)| r.subject == web_worker.id && r.object == util.id));
471        assert!(out.iter().any(|(r, _)| r.subject == api.id && r.object == util.id));
472
473        // idempotent: recompiling replaces, never duplicates
474        let out2 = compile_boundaries(&graph, &store).unwrap();
475        assert_eq!(out2.len(), 3);
476    }
477
478    #[test]
479    fn external_api_call_crosses_boundary() {
480        let (store, _t) = store_with();
481        unit(&store, "api", "services/api");
482        let api_comp = component(&store, "api", &["services/api"]);
483        store.replace_components(&[api_comp]).unwrap();
484
485        let sym = symbol_id(&store.repo_id, "services/api/app.py", "main");
486        let ext = entity_id(&store.repo_id, kinds::EXTERNAL_API, "stripe.api");
487        let mut se = Entity::new(sym.clone(), kinds::SYMBOL, "main");
488        se.attr("file", serde_json::json!("services/api/app.py"));
489        store.insert_entity(&se, &[]).unwrap();
490        store
491            .insert_entity(&Entity::new(ext.clone(), kinds::EXTERNAL_API, "stripe.api"), &[])
492            .unwrap();
493        store
494            .insert_relationship(
495                &Relationship::new(
496                    "rel:test:call".to_string(),
497                    sym.clone(),
498                    scc_core::predicates::CALLS,
499                    ext.clone(),
500                    Provenance::Resolved,
501                )
502                .with_evidence(vec!["evidence:call1".to_string()]),
503                "services/api/app.py",
504            )
505            .unwrap();
506
507        let graph = RealityGraph::load(&store).unwrap();
508        let out = compile_boundaries(&graph, &store).unwrap();
509        assert_eq!(out.len(), 1);
510        let (rel, src) = &out[0];
511        assert_eq!(rel.subject, sym);
512        assert_eq!(rel.object, ext);
513        assert_eq!(rel.predicate, scc_core::predicates::CROSSES_BOUNDARY);
514        assert_eq!(rel.provenance, Provenance::Extracted);
515        assert_eq!(rel.confidence, 0.9);
516        assert_eq!(rel.evidence, vec!["evidence:call1".to_string()]);
517        assert!(src.is_empty());
518    }
519
520    #[test]
521    fn no_crossings_within_unit() {
522        let (store, _t) = store_with();
523        unit(&store, "svc", "src/svc");
524        let a = component(&store, "a", &["src/svc/a"]);
525        let b = component(&store, "b", &["src/svc/b"]);
526        store.replace_components(&[a.clone(), b.clone()]).unwrap();
527        dep(&store, &a, &b, Provenance::Resolved);
528        let graph = RealityGraph::load(&store).unwrap();
529        let out = compile_boundaries(&graph, &store).unwrap();
530        assert!(out.is_empty(), "same deployment unit: no crossing");
531        let lines = boundary_crossings(&graph, &store).unwrap();
532        assert!(lines.is_empty());
533    }
534
535    #[test]
536    fn boundary_crossings_strings() {
537        let (store, _t) = store_with();
538        setup_basic(&store);
539
540        let sym = symbol_id(&store.repo_id, "services/api/app.py", "main");
541        let ext = entity_id(&store.repo_id, kinds::EXTERNAL_API, "stripe.api");
542        let mut se = Entity::new(sym.clone(), kinds::SYMBOL, "main");
543        se.attr("file", serde_json::json!("services/api/app.py"));
544        store.insert_entity(&se, &[]).unwrap();
545        store
546            .insert_entity(&Entity::new(ext.clone(), kinds::EXTERNAL_API, "stripe.api"), &[])
547            .unwrap();
548        store
549            .insert_relationship(
550                &Relationship::new(
551                    "rel:test:call".to_string(),
552                    sym,
553                    scc_core::predicates::CALLS,
554                    ext,
555                    Provenance::Resolved,
556                ),
557                "services/api/app.py",
558            )
559            .unwrap();
560
561        let graph = RealityGraph::load(&store).unwrap();
562        // display is a pure read of stored crossings: compile first
563        let crossings = compile_boundaries(&graph, &store).unwrap();
564        for (rel, src) in crossings {
565            store.insert_relationship(&rel, &src).unwrap();
566        }
567        let lines = boundary_crossings(&graph, &store).unwrap();
568        assert_eq!(
569            lines,
570            vec![
571                "api/api -> external/stripe.api".to_string(),
572                "api/api -> local/util".to_string(),
573                "web/web -> api/api".to_string(),
574                "web/web-worker -> local/util".to_string(),
575            ]
576        );
577    }
578}