lean_ctx/core/compliance_evidence/
frameworks.rs1use serde::{Deserialize, Serialize};
8
9pub const MAX_FRAMEWORKS: usize = 16;
11pub const MAX_ROWS: usize = 256;
12const 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 PackRule,
28 AuditEvent,
30 EvidenceExport,
32 Slo,
34 None,
36}
37
38#[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#[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 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 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
209pub 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 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 #[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 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}