Skip to main content

taudit_core/
summary.rs

1//! Deterministic propagation aggregates for triage (ADR 0002 Phase 3).
2//!
3//! Summaries are **read-only projections** over the same BFS the rule engine
4//! uses for boundary crossings — they **inform** analysis; [`crate::rules`]
5//! and **`verify`** remain the policy surfaces.
6
7use crate::graph::{AuthorityCompleteness, AuthorityGraph, GapKind, NodeId, NodeKind, TrustZone};
8use crate::propagation::{propagation_analysis_checked, DenseGraphError, PropagationPath};
9use serde::Serialize;
10use std::collections::HashMap;
11
12/// Semver for [`AuthorityPropagationSummaryDocument`] JSON.
13///
14/// 1.1.0: additive — surfaces `completeness_gap_kinds` and `worst_gap_kind`
15/// alongside the existing free-text `completeness_gaps`. Older consumers that
16/// validate the schema as written keep working because the schema's
17/// `schema_version` const was loosened to a `^1\.\d+\.\d+$` pattern; older
18/// consumers that key on field presence ignore unknown fields by default.
19pub const AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_VERSION: &str = "1.1.0";
20
21/// JSON Schema `$id` for the propagation summary document.
22pub const AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_URI: &str =
23    "https://taudit.dev/schemas/authority-propagation-summary.v1.json";
24
25/// Max rows in each ranked list for bounded output size.
26pub const PROPAGATION_SUMMARY_TOP_N: usize = 32;
27
28/// One row in a ranked list of nodes by path count.
29#[derive(Debug, Clone, Serialize)]
30pub struct PropagationNodeAgg {
31    pub node_id: NodeId,
32    pub kind: NodeKind,
33    pub name: String,
34    pub trust_zone: TrustZone,
35    pub path_count: usize,
36}
37
38/// Rollup counts over all boundary-crossing propagation paths.
39#[derive(Debug, Clone, Serialize)]
40pub struct PropagationSummaryTotals {
41    /// Paths where authority reaches a strictly lower trust zone than its source.
42    pub boundary_path_count: usize,
43    pub distinct_authority_sources: usize,
44    pub distinct_sinks: usize,
45}
46
47/// Standalone JSON document for `taudit graph --format summary`.
48#[derive(Debug, Clone, Serialize)]
49pub struct AuthorityPropagationSummaryDocument {
50    pub schema_version: &'static str,
51    pub schema_uri: &'static str,
52    pub source_file: String,
53    pub graph_completeness: AuthorityCompleteness,
54    #[serde(skip_serializing_if = "Vec::is_empty")]
55    pub completeness_gaps: Vec<String>,
56    /// Typed gap-kind classifications matching `completeness_gaps` by index.
57    /// Parallel array — same length, same order as `completeness_gaps`.
58    /// Added in v1.1.0-beta.3 (schema 1.1.0). Older consumers ignore unknown fields.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub completeness_gap_kinds: Vec<GapKind>,
61    /// The most severe `GapKind` present in the graph, or `None` if the graph
62    /// is `Complete` / `Unknown` or carries no typed gaps. Severity ordering is
63    /// canonical to [`GapKind`]: `Opaque > Structural > Expression`.
64    /// Added in schema 1.1.0; omitted when absent so older payloads remain valid.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub worst_gap_kind: Option<GapKind>,
67    pub max_hops: usize,
68    pub method: &'static str,
69    pub totals: PropagationSummaryTotals,
70    pub top_sinks_by_path_count: Vec<PropagationNodeAgg>,
71    pub top_sources_by_path_count: Vec<PropagationNodeAgg>,
72}
73
74fn rank_node_aggs(
75    counts: HashMap<NodeId, usize>,
76    graph: &AuthorityGraph,
77    top_n: usize,
78) -> Vec<PropagationNodeAgg> {
79    let mut pairs: Vec<(NodeId, usize)> = counts.into_iter().collect();
80    pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
81    pairs
82        .into_iter()
83        .take(top_n)
84        .filter_map(|(id, path_count)| {
85            let n = graph.node(id)?;
86            Some(PropagationNodeAgg {
87                node_id: id,
88                kind: n.kind,
89                name: n.name.clone(),
90                trust_zone: n.trust_zone,
91                path_count,
92            })
93        })
94        .collect()
95}
96
97/// Build a bounded propagation summary from `graph`, using the same density gate
98/// as [`crate::propagation::propagation_analysis_checked`].
99pub fn build_authority_propagation_summary(
100    graph: &AuthorityGraph,
101    max_hops: usize,
102    force_dense: bool,
103) -> Result<AuthorityPropagationSummaryDocument, DenseGraphError> {
104    let paths: Vec<PropagationPath> = propagation_analysis_checked(graph, max_hops, force_dense)?;
105
106    let mut sink_count: HashMap<NodeId, usize> = HashMap::new();
107    let mut source_count: HashMap<NodeId, usize> = HashMap::new();
108    for p in &paths {
109        *sink_count.entry(p.sink).or_insert(0) += 1;
110        *source_count.entry(p.source).or_insert(0) += 1;
111    }
112
113    Ok(AuthorityPropagationSummaryDocument {
114        schema_version: AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_VERSION,
115        schema_uri: AUTHORITY_PROPAGATION_SUMMARY_SCHEMA_URI,
116        source_file: graph.source.file.clone(),
117        graph_completeness: graph.completeness,
118        completeness_gaps: graph.completeness_gaps.clone(),
119        completeness_gap_kinds: graph.completeness_gap_kinds.clone(),
120        worst_gap_kind: graph.worst_gap_kind(),
121        max_hops,
122        method: "bfs_lower_trust_zone_sinks",
123        totals: PropagationSummaryTotals {
124            boundary_path_count: paths.len(),
125            distinct_authority_sources: source_count.len(),
126            distinct_sinks: sink_count.len(),
127        },
128        top_sinks_by_path_count: rank_node_aggs(sink_count, graph, PROPAGATION_SUMMARY_TOP_N),
129        top_sources_by_path_count: rank_node_aggs(source_count, graph, PROPAGATION_SUMMARY_TOP_N),
130    })
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::graph::{EdgeKind, PipelineSource};
137
138    fn src(file: &str) -> PipelineSource {
139        PipelineSource {
140            file: file.into(),
141            repo: None,
142            git_ref: None,
143            commit_sha: None,
144        }
145    }
146
147    #[test]
148    fn summary_counts_crossing_paths() {
149        let mut g = AuthorityGraph::new(src("t.yml"));
150        let secret = g.add_node(NodeKind::Secret, "K", TrustZone::FirstParty);
151        let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
152        let art = g.add_node(NodeKind::Artifact, "a", TrustZone::FirstParty);
153        let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
154        g.add_edge(build, secret, EdgeKind::HasAccessTo);
155        g.add_edge(build, art, EdgeKind::Produces);
156        g.add_edge(art, deploy, EdgeKind::Consumes);
157
158        let doc =
159            build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
160                .unwrap();
161        assert_eq!(doc.totals.boundary_path_count, 1);
162        assert_eq!(doc.totals.distinct_authority_sources, 1);
163        assert_eq!(doc.totals.distinct_sinks, 1);
164        assert_eq!(doc.top_sinks_by_path_count.len(), 1);
165        assert_eq!(doc.top_sinks_by_path_count[0].node_id, deploy);
166        assert_eq!(doc.top_sources_by_path_count[0].node_id, secret);
167    }
168
169    #[test]
170    fn summary_carries_gap_kinds_from_graph() {
171        // Regression: prior to schema 1.1.0 the summary cloned only the
172        // free-text `completeness_gaps`, silently dropping the typed
173        // `completeness_gap_kinds` taxonomy and `worst_gap_kind`.
174        let mut g = AuthorityGraph::new(src("partial.yml"));
175        g.mark_partial(GapKind::Structural, "composite action not resolved");
176        g.mark_partial(GapKind::Expression, "matrix expansion hides paths");
177        g.mark_partial(GapKind::Opaque, "platform unknown");
178
179        let doc =
180            build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
181                .unwrap();
182
183        assert_eq!(doc.graph_completeness, AuthorityCompleteness::Partial);
184        assert_eq!(doc.completeness_gaps.len(), 3);
185        assert_eq!(
186            doc.completeness_gap_kinds,
187            vec![GapKind::Structural, GapKind::Expression, GapKind::Opaque,]
188        );
189        // Parallel-array invariant: same length as the free-text gaps.
190        assert_eq!(
191            doc.completeness_gap_kinds.len(),
192            doc.completeness_gaps.len(),
193            "completeness_gap_kinds must be a parallel array of completeness_gaps"
194        );
195        // Canonical severity ordering on `AuthorityGraph::worst_gap_kind`:
196        // Opaque (2) > Structural (1) > Expression (0).
197        assert_eq!(doc.worst_gap_kind, Some(GapKind::Opaque));
198    }
199
200    #[test]
201    fn summary_omits_gap_kinds_when_complete() {
202        // schema_version 1.1.0 is additive: a Complete graph emits the doc
203        // without `completeness_gap_kinds` or `worst_gap_kind`, keeping the
204        // wire-format minimal for the common case.
205        let g = AuthorityGraph::new(src("clean.yml"));
206
207        let doc =
208            build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
209                .unwrap();
210        assert!(doc.completeness_gap_kinds.is_empty());
211        assert_eq!(doc.worst_gap_kind, None);
212
213        let v = serde_json::to_value(&doc).expect("summary doc serialises");
214        assert!(
215            v.get("completeness_gap_kinds").is_none(),
216            "empty completeness_gap_kinds must be skipped on the wire"
217        );
218        assert!(
219            v.get("worst_gap_kind").is_none(),
220            "absent worst_gap_kind must be skipped on the wire"
221        );
222        assert_eq!(
223            v.get("schema_version").and_then(|x| x.as_str()),
224            Some("1.1.0"),
225            "schema_version must reflect the additive 1.1.0 bump"
226        );
227    }
228
229    #[test]
230    fn summary_empty_when_no_crossing() {
231        let mut g = AuthorityGraph::new(src("t.yml"));
232        let secret = g.add_node(NodeKind::Secret, "T", TrustZone::FirstParty);
233        let step = g.add_node(NodeKind::Step, "s", TrustZone::FirstParty);
234        g.add_edge(step, secret, EdgeKind::HasAccessTo);
235
236        let doc =
237            build_authority_propagation_summary(&g, crate::propagation::DEFAULT_MAX_HOPS, true)
238                .unwrap();
239        assert_eq!(doc.totals.boundary_path_count, 0);
240        assert!(doc.top_sinks_by_path_count.is_empty());
241    }
242}