Skip to main content

lean_ctx/core/compliance_evidence/
frameworks.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Wire types of a framework coverage report as the licensed runtime returns it
3//! (`enterprise.runtime.compliance_frameworks`). The engine owns the format and
4//! checks every answer; the framework mappings and the per-control assessment
5//! are proprietary and never part of this tree.
6
7use serde::{Deserialize, Serialize};
8
9/// Most frameworks one answer may carry, and most rows per framework.
10pub const MAX_FRAMEWORKS: usize = 16;
11pub const MAX_ROWS: usize = 256;
12/// Longest text field of a row or header.
13const MAX_TEXT: usize = 2048;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Coverage {
18    Full,
19    Partial,
20    None,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum Mechanism {
26    /// Enforced by a policy-pack rule, verified against the assessed pack.
27    PackRule,
28    /// Engine guarantee recorded in the audit/event plane.
29    AuditEvent,
30    /// Engine produces an exportable evidence artifact.
31    EvidenceExport,
32    /// Service-level objective / monitoring guarantee.
33    Slo,
34    /// Not technically addressable (organisational duty).
35    None,
36}
37
38/// Verification status of one control row.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum RowStatus {
42    Enforced,
43    EngineGuarantee,
44    NotEnforced,
45    NotVerified,
46    Gap,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct ReportRow {
52    pub id: String,
53    pub clause: String,
54    pub requirement: String,
55    pub coverage: Coverage,
56    pub mechanism: Mechanism,
57    pub status: RowStatus,
58    pub detail: String,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub test: Option<String>,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct ReportSummary {
66    pub controls_total: usize,
67    pub full_claimed: usize,
68    pub enforced: usize,
69    pub engine_guarantee: usize,
70    pub not_enforced: usize,
71    pub not_verified: usize,
72    pub gaps: usize,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct FrameworkReport {
78    pub framework: String,
79    pub title: String,
80    pub version_pin: String,
81    pub pinned_on: String,
82    pub disclaimer: String,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub pack: Option<String>,
85    pub rows: Vec<ReportRow>,
86    pub summary: ReportSummary,
87}
88
89/// The runtime's answer: one report per requested framework, plus the ids it
90/// supports (so the engine can name them when a request is unknown).
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct FrameworksAnswer {
94    pub schema_version: u32,
95    pub supported: Vec<String>,
96    pub frameworks: Vec<FrameworkReport>,
97}
98
99impl FrameworksAnswer {
100    /// Accept an answer only when it is bounded, answers exactly the request
101    /// (all supported frameworks when `requested` is empty) and every summary
102    /// is the count of its rows.
103    pub fn check(&self, requested: &[String], pack: Option<&str>) -> Result<(), String> {
104        let bad = |why: &str| Err(format!("compliance runtime answer refused: {why}"));
105        if self.schema_version != 1 {
106            return bad("unknown schema version");
107        }
108        if self.supported.is_empty()
109            || self.supported.len() > MAX_FRAMEWORKS
110            || self.supported.iter().any(|id| !valid_id(id))
111        {
112            return bad("invalid list of supported frameworks");
113        }
114        if let Some(unknown) = requested.iter().find(|id| !self.supported.contains(id)) {
115            return Err(format!(
116                "unknown framework '{unknown}' (supported: {})",
117                self.supported.join(", ")
118            ));
119        }
120        let expected: Vec<&str> = if requested.is_empty() {
121            self.supported.iter().map(String::as_str).collect()
122        } else {
123            requested.iter().map(String::as_str).collect()
124        };
125        let answered: Vec<&str> = self
126            .frameworks
127            .iter()
128            .map(|f| f.framework.as_str())
129            .collect();
130        if answered != expected {
131            return bad("frameworks differ from the request");
132        }
133        for report in &self.frameworks {
134            report.check(pack)?;
135        }
136        Ok(())
137    }
138}
139
140impl FrameworkReport {
141    fn check(&self, pack: Option<&str>) -> Result<(), String> {
142        let bad = |why: &str| {
143            Err(format!(
144                "compliance runtime answer refused ({}): {why}",
145                self.framework
146            ))
147        };
148        let texts = [
149            &self.title,
150            &self.version_pin,
151            &self.pinned_on,
152            &self.disclaimer,
153        ];
154        if texts.iter().any(|t| t.is_empty() || t.len() > MAX_TEXT) {
155            return bad("empty or oversized header");
156        }
157        if self.pack.as_deref() != pack {
158            return bad("assessed a different pack");
159        }
160        if self.rows.is_empty() || self.rows.len() > MAX_ROWS {
161            return bad("row count out of bounds");
162        }
163        let mut ids = std::collections::BTreeSet::new();
164        for row in &self.rows {
165            let row_texts = [&row.id, &row.clause, &row.requirement, &row.detail];
166            if row_texts.iter().any(|t| t.is_empty() || t.len() > MAX_TEXT)
167                || row.test.as_ref().is_some_and(|t| t.len() > MAX_TEXT)
168                || !ids.insert(row.id.as_str())
169            {
170                return bad("invalid or duplicate row");
171            }
172            // A claim without a pack can only be unverified, and only a pack
173            // rule is verified against a pack.
174            let pack_rule = row.mechanism == Mechanism::PackRule;
175            let consistent = match row.status {
176                RowStatus::Enforced | RowStatus::NotEnforced => pack_rule && pack.is_some(),
177                RowStatus::NotVerified => pack_rule,
178                RowStatus::EngineGuarantee => matches!(
179                    row.mechanism,
180                    Mechanism::AuditEvent | Mechanism::EvidenceExport | Mechanism::Slo
181                ),
182                RowStatus::Gap => row.mechanism == Mechanism::None,
183            };
184            if !consistent {
185                return bad("row status contradicts its mechanism");
186            }
187        }
188        let count = |s: RowStatus| self.rows.iter().filter(|r| r.status == s).count();
189        let summary = ReportSummary {
190            controls_total: self.rows.len(),
191            full_claimed: self
192                .rows
193                .iter()
194                .filter(|r| r.coverage == Coverage::Full)
195                .count(),
196            enforced: count(RowStatus::Enforced),
197            engine_guarantee: count(RowStatus::EngineGuarantee),
198            not_enforced: count(RowStatus::NotEnforced),
199            not_verified: count(RowStatus::NotVerified),
200            gaps: count(RowStatus::Gap),
201        };
202        if summary != self.summary {
203            return bad("summary does not count the rows");
204        }
205        Ok(())
206    }
207}
208
209/// Framework ids: lowercase ASCII, digits and `-`, at most 32 bytes.
210pub fn valid_id(id: &str) -> bool {
211    !id.is_empty()
212        && id.len() <= 32
213        && id
214            .bytes()
215            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
216}
217
218#[cfg(test)]
219pub(crate) mod tests {
220    use super::*;
221
222    /// A well-formed one-framework answer in neutral terms (the real mappings
223    /// are not part of this tree).
224    pub(crate) fn answer(pack: Option<&str>) -> FrameworksAnswer {
225        let row = |id: &str, mechanism, status, coverage| ReportRow {
226            id: id.into(),
227            clause: format!("clause {id}"),
228            requirement: "requirement".into(),
229            coverage,
230            mechanism,
231            status,
232            detail: "detail".into(),
233            test: None,
234        };
235        let pack_status = if pack.is_some() {
236            RowStatus::Enforced
237        } else {
238            RowStatus::NotVerified
239        };
240        let rows = vec![
241            row("c-1", Mechanism::PackRule, pack_status, Coverage::Full),
242            row(
243                "c-2",
244                Mechanism::AuditEvent,
245                RowStatus::EngineGuarantee,
246                Coverage::Full,
247            ),
248            row("c-3", Mechanism::None, RowStatus::Gap, Coverage::None),
249        ];
250        let enforced = usize::from(pack.is_some());
251        FrameworksAnswer {
252            schema_version: 1,
253            supported: vec!["framework-a".into(), "framework-b".into()],
254            frameworks: vec![FrameworkReport {
255                framework: "framework-a".into(),
256                title: "Framework A".into(),
257                version_pin: "edition 1".into(),
258                pinned_on: "2026-01-01".into(),
259                disclaimer: "not legal advice".into(),
260                pack: pack.map(str::to_owned),
261                rows,
262                summary: ReportSummary {
263                    controls_total: 3,
264                    full_claimed: 2,
265                    enforced,
266                    engine_guarantee: 1,
267                    not_enforced: 0,
268                    not_verified: 1 - enforced,
269                    gaps: 1,
270                },
271            }],
272        }
273    }
274
275    /// The engine accepts only answers that match the request and count their
276    /// own rows; a runtime cannot inflate a summary or claim an unassessed pack.
277    #[test]
278    fn check_binds_answer_to_request_and_rows() {
279        let requested = vec!["framework-a".to_string()];
280        assert!(answer(Some("p v1")).check(&requested, Some("p v1")).is_ok());
281        assert!(answer(None).check(&requested, None).is_ok());
282
283        let mut inflated = answer(Some("p v1"));
284        inflated.frameworks[0].summary.enforced = 3;
285        let mut other_pack = answer(Some("p v1"));
286        other_pack.frameworks[0].pack = Some("q v1".into());
287        let mut claim_without_pack = answer(None);
288        claim_without_pack.frameworks[0].rows[0].status = RowStatus::Enforced;
289        claim_without_pack.frameworks[0].summary.enforced = 1;
290        claim_without_pack.frameworks[0].summary.not_verified = 0;
291        let mut gap_as_guarantee = answer(None);
292        gap_as_guarantee.frameworks[0].rows[2].status = RowStatus::EngineGuarantee;
293        for (bad, pack) in [
294            (inflated, Some("p v1")),
295            (other_pack, Some("p v1")),
296            (claim_without_pack, None),
297            (gap_as_guarantee, None),
298        ] {
299            assert!(bad.check(&requested, pack).is_err());
300        }
301        // All supported frameworks were requested but only one was answered.
302        assert!(answer(None).check(&[], None).is_err());
303        assert!(
304            answer(None)
305                .check(&["framework-b".to_string()], None)
306                .is_err()
307        );
308    }
309}