Skip to main content

lean_ctx/core/
compliance.rs

1//! Framework compliance mappings + coverage reports (GL #424, H3 Epic A).
2//!
3//! The mapping matrices live as machine-readable TOML under
4//! `compliance/mappings/` (embedded at compile time, version-pinned to a
5//! framework edition with a semi-annual review cycle). This module loads
6//! them and produces the audit-conversation artifact:
7//! `lean-ctx policy coverage --framework eu-ai-act`.
8//!
9//! Honesty contract (same spirit as `policy::coverage`):
10//! * `full` coverage is only claimed where enforcement exists AND a CI test
11//!   proves it — every full control carries its test name; a unit test
12//!   below fails the build when a full claim has no test.
13//! * Pack-dependent claims are verified LIVE against the resolved pack and
14//!   downgrade to `NotEnforced` when the pack doesn't hold up.
15//! * Organisational duties are reported as explicit gaps, never hidden.
16
17use serde::{Deserialize, Serialize};
18use std::sync::OnceLock;
19
20use super::policy::ResolvedPolicy;
21use super::policy::coverage::{CREDENTIAL_FIXTURES, DOMAIN_FIXTURES};
22
23/// `(framework id, mapping TOML)` — pinned editions, see each file header.
24const MAPPING_SOURCES: &[(&str, &str)] = &[
25    (
26        "eu-ai-act",
27        include_str!("../../data/compliance/mappings/eu-ai-act.toml"),
28    ),
29    (
30        "iso42001",
31        include_str!("../../data/compliance/mappings/iso42001.toml"),
32    ),
33    (
34        "soc2",
35        include_str!("../../data/compliance/mappings/soc2.toml"),
36    ),
37];
38
39// ── Mapping wire format ──────────────────────────────────────────────────────
40
41#[derive(Debug, Clone, Deserialize, Serialize)]
42#[serde(deny_unknown_fields)]
43pub struct FrameworkMapping {
44    pub framework: String,
45    pub title: String,
46    /// Exact framework edition the mapping was written against.
47    pub version_pin: String,
48    /// Date the pin was taken (maintenance: re-review every `review_cycle_months`).
49    pub pinned_on: String,
50    pub review_cycle_months: u32,
51    pub disclaimer: String,
52    /// Built-in pack implementing the enforceable slice of this framework.
53    pub reference_pack: String,
54    pub controls: Vec<ControlMapping>,
55}
56
57#[derive(Debug, Clone, Deserialize, Serialize)]
58#[serde(deny_unknown_fields)]
59pub struct ControlMapping {
60    /// Stable mapping id, e.g. `AIA-26.6`.
61    pub id: String,
62    /// Framework clause, e.g. `Art. 26(6)`.
63    pub clause: String,
64    /// Requirement, paraphrased in one sentence.
65    pub requirement: String,
66    pub mechanism: Mechanism,
67    /// How LeanCTX addresses it (mechanism detail).
68    pub leanctx: String,
69    /// The evidence artifact an assessor receives.
70    pub evidence: String,
71    pub coverage: Coverage,
72    /// Documented residual gap (`coverage = partial|none`).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub gap: Option<String>,
75    /// CI test proving enforcement (required for `coverage = full`).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub test: Option<String>,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
81#[serde(rename_all = "snake_case")]
82pub enum Mechanism {
83    /// Enforced by a policy-pack rule — verifiable against a resolved pack.
84    PackRule,
85    /// Engine guarantee recorded in the audit/event plane.
86    AuditEvent,
87    /// Engine produces an exportable evidence artifact.
88    EvidenceExport,
89    /// Service-level objective / monitoring guarantee.
90    Slo,
91    /// Not technically addressable (organisational duty).
92    None,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
96#[serde(rename_all = "snake_case")]
97pub enum Coverage {
98    Full,
99    Partial,
100    None,
101}
102
103// ── Registry ─────────────────────────────────────────────────────────────────
104
105fn registry() -> &'static Vec<FrameworkMapping> {
106    static REGISTRY: OnceLock<Vec<FrameworkMapping>> = OnceLock::new();
107    REGISTRY.get_or_init(|| {
108        MAPPING_SOURCES
109            .iter()
110            .map(|(id, toml_text)| {
111                // Mappings are compile-time assets; a parse failure is a bug
112                // caught by the tests below before it can ship.
113                let m: FrameworkMapping = toml::from_str(toml_text)
114                    .unwrap_or_else(|e| panic!("compliance mapping '{id}' is invalid: {e}"));
115                assert_eq!(&m.framework, id, "mapping id/file mismatch for '{id}'");
116                m
117            })
118            .collect()
119    })
120}
121
122/// All mappings, registry order.
123pub fn frameworks() -> &'static [FrameworkMapping] {
124    registry()
125}
126
127/// Framework ids, registry order.
128pub fn names() -> Vec<&'static str> {
129    registry().iter().map(|m| m.framework.as_str()).collect()
130}
131
132/// Look up one mapping.
133pub fn get(framework: &str) -> Option<&'static FrameworkMapping> {
134    registry().iter().find(|m| m.framework == framework)
135}
136
137// ── Report ───────────────────────────────────────────────────────────────────
138
139/// Verification status of one control row in a report.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub enum RowStatus {
143    /// Pack-rule control verified live against the resolved pack.
144    Enforced,
145    /// Engine guarantee — proven by the named CI test, not pack-dependent.
146    EngineGuarantee,
147    /// Mapping claims a pack rule but the assessed pack does not hold it.
148    NotEnforced,
149    /// Pack-rule control, but no pack was supplied to verify against.
150    NotVerified,
151    /// Documented residual gap (organisational / out of boundary).
152    Gap,
153}
154
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct ReportRow {
157    pub id: String,
158    pub clause: String,
159    pub requirement: String,
160    pub coverage: Coverage,
161    pub mechanism: Mechanism,
162    pub status: RowStatus,
163    pub detail: String,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub test: Option<String>,
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct ReportSummary {
170    pub controls_total: usize,
171    pub full_claimed: usize,
172    pub enforced: usize,
173    pub engine_guarantee: usize,
174    pub not_enforced: usize,
175    pub not_verified: usize,
176    pub gaps: usize,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct FrameworkReport {
181    pub framework: String,
182    pub title: String,
183    pub version_pin: String,
184    pub pinned_on: String,
185    pub disclaimer: String,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub pack: Option<String>,
188    pub rows: Vec<ReportRow>,
189    pub summary: ReportSummary,
190}
191
192/// Build the coverage report for a framework, verifying pack-rule controls
193/// against `policy` when one is supplied.
194pub fn report(mapping: &FrameworkMapping, policy: Option<&ResolvedPolicy>) -> FrameworkReport {
195    let rows: Vec<ReportRow> = mapping
196        .controls
197        .iter()
198        .map(|c| {
199            let (status, detail) = row_status(c, policy);
200            ReportRow {
201                id: c.id.clone(),
202                clause: c.clause.clone(),
203                requirement: c.requirement.clone(),
204                coverage: c.coverage,
205                mechanism: c.mechanism,
206                status,
207                detail,
208                test: c.test.clone(),
209            }
210        })
211        .collect();
212
213    let count = |s: RowStatus| rows.iter().filter(|r| r.status == s).count();
214    let summary = ReportSummary {
215        controls_total: rows.len(),
216        full_claimed: rows.iter().filter(|r| r.coverage == Coverage::Full).count(),
217        enforced: count(RowStatus::Enforced),
218        engine_guarantee: count(RowStatus::EngineGuarantee),
219        not_enforced: count(RowStatus::NotEnforced),
220        not_verified: count(RowStatus::NotVerified),
221        gaps: count(RowStatus::Gap),
222    };
223
224    FrameworkReport {
225        framework: mapping.framework.clone(),
226        title: mapping.title.clone(),
227        version_pin: mapping.version_pin.clone(),
228        pinned_on: mapping.pinned_on.clone(),
229        disclaimer: mapping.disclaimer.clone(),
230        pack: policy.map(|p| format!("{} v{}", p.name, p.version)),
231        rows,
232        summary,
233    }
234}
235
236fn row_status(control: &ControlMapping, policy: Option<&ResolvedPolicy>) -> (RowStatus, String) {
237    match control.mechanism {
238        Mechanism::None => (
239            RowStatus::Gap,
240            control
241                .gap
242                .clone()
243                .unwrap_or_else(|| "documented gap".to_string()),
244        ),
245        Mechanism::AuditEvent | Mechanism::EvidenceExport | Mechanism::Slo => (
246            RowStatus::EngineGuarantee,
247            match &control.test {
248                Some(t) => format!("{} — CI: {t}", control.leanctx),
249                None => control.leanctx.clone(),
250            },
251        ),
252        Mechanism::PackRule => match policy {
253            None => (
254                RowStatus::NotVerified,
255                "pack rule — pass a pack to verify enforcement".to_string(),
256            ),
257            Some(p) => verify_pack_rule(control, p),
258        },
259    }
260}
261
262/// Live verification of pack-rule controls against a resolved pack.
263/// Wired per control id (explicit over generic — same approach as the CGB
264/// checks); the `full_claims_have_live_checks` test keeps TOML and code in
265/// sync.
266fn verify_pack_rule(control: &ControlMapping, p: &ResolvedPolicy) -> (RowStatus, String) {
267    let patterns: Vec<regex::Regex> = p
268        .redaction
269        .values()
270        .filter_map(|raw| regex::Regex::new(raw).ok())
271        .collect();
272    let matches = |fixture: &str| patterns.iter().any(|re| re.is_match(fixture));
273    let missing_fixtures = |set: &'static [(&'static str, &'static str)]| -> Vec<&'static str> {
274        set.iter()
275            .filter(|(_, fx)| !matches(fx))
276            .map(|(class, _)| *class)
277            .collect()
278    };
279    let tool_surface_scoped = p.allow_tools.is_some() || !p.deny_tools.is_empty();
280    let egress_denied = p.deny_tools.iter().any(|t| {
281        ["url", "web", "fetch", "http"]
282            .iter()
283            .any(|hint| t.contains(hint))
284    });
285
286    match control.id.as_str() {
287        "AIA-26.6" => match p.audit_retention_days {
288            Some(days) if days >= 180 => (
289                RowStatus::Enforced,
290                format!("audit_retention_days = {days} (≥ 180 d / six months)"),
291            ),
292            Some(days) => (
293                RowStatus::NotEnforced,
294                format!("audit_retention_days = {days} < 180 — below Art. 26(6) minimum"),
295            ),
296            None => (
297                RowStatus::NotEnforced,
298                "pack declares no audit retention".to_string(),
299            ),
300        },
301        "AIA-10.5" | "ISO-A.7.4" => {
302            let missing = missing_fixtures(DOMAIN_FIXTURES);
303            if missing.is_empty() {
304                (
305                    RowStatus::Enforced,
306                    format!(
307                        "{}/{} regulated-identifier fixture classes redacted",
308                        DOMAIN_FIXTURES.len(),
309                        DOMAIN_FIXTURES.len()
310                    ),
311                )
312            } else if control.coverage == Coverage::Partial && missing.len() < DOMAIN_FIXTURES.len()
313            {
314                (
315                    RowStatus::Enforced,
316                    format!(
317                        "partial by design — unredacted classes: {}",
318                        missing.join(", ")
319                    ),
320                )
321            } else {
322                (
323                    RowStatus::NotEnforced,
324                    format!("unredacted identifier classes: {}", missing.join(", ")),
325                )
326            }
327        }
328        "AIA-15.5-secrets" | "SOC2-C1.1" => {
329            let missing = missing_fixtures(CREDENTIAL_FIXTURES);
330            if missing.is_empty() {
331                (
332                    RowStatus::Enforced,
333                    format!(
334                        "{}/{} credential fixture classes redacted",
335                        CREDENTIAL_FIXTURES.len(),
336                        CREDENTIAL_FIXTURES.len()
337                    ),
338                )
339            } else {
340                (
341                    RowStatus::NotEnforced,
342                    format!("unredacted credential classes: {}", missing.join(", ")),
343                )
344            }
345        }
346        "AIA-15.5-access" | "ISO-A.9.4" | "SOC2-CC6.1" => {
347            if tool_surface_scoped {
348                (
349                    RowStatus::Enforced,
350                    format!(
351                        "tool surface scoped (allow: {}, deny: {}) on top of the default-deny capability gate",
352                        p.allow_tools.as_ref().map_or(0, Vec::len),
353                        p.deny_tools.len()
354                    ),
355                )
356            } else {
357                (
358                    RowStatus::NotEnforced,
359                    "pack neither allows nor denies tools — only the engine capability gate applies"
360                        .to_string(),
361                )
362            }
363        }
364        "AIA-14.4e" => match p.max_context_tokens {
365            Some(cap) => (
366                RowStatus::Enforced,
367                format!("max_context_tokens = {cap} bounds every assembly"),
368            ),
369            None => (
370                RowStatus::NotEnforced,
371                "no hard context cap declared".to_string(),
372            ),
373        },
374        "SOC2-CC6.6" => {
375            if egress_denied {
376                (
377                    RowStatus::Enforced,
378                    format!("egress tools denied: {}", p.deny_tools.join(", ")),
379                )
380            } else {
381                (
382                    RowStatus::NotEnforced,
383                    "no egress tool denied by pack".to_string(),
384                )
385            }
386        }
387        "ISO-A.9.2" => {
388            let mut declared = Vec::new();
389            if p.max_context_tokens.is_some() {
390                declared.push("budget cap");
391            }
392            if p.audit_retention_days.is_some() {
393                declared.push("retention");
394            }
395            if tool_surface_scoped {
396                declared.push("tool scope");
397            }
398            if !p.redaction.is_empty() {
399                declared.push("redaction");
400            }
401            if declared.len() >= 3 {
402                (
403                    RowStatus::Enforced,
404                    format!("enforced process declared: {}", declared.join(", ")),
405                )
406            } else {
407                (
408                    RowStatus::NotEnforced,
409                    format!(
410                        "pack declares too little process ({}) for an A.9.2 claim",
411                        if declared.is_empty() {
412                            "nothing".to_string()
413                        } else {
414                            declared.join(", ")
415                        }
416                    ),
417                )
418            }
419        }
420        other => (
421            RowStatus::NotVerified,
422            format!(
423                "no live check wired for pack-rule control {other} — fix the mapping or add a check"
424            ),
425        ),
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::core::policy::{builtin, resolve};
433
434    #[test]
435    fn all_mappings_parse_and_are_pinned() {
436        for m in frameworks() {
437            assert!(
438                !m.version_pin.is_empty(),
439                "{} missing version pin",
440                m.framework
441            );
442            assert!(!m.pinned_on.is_empty(), "{} missing pin date", m.framework);
443            assert!(m.review_cycle_months > 0);
444            assert!(
445                !m.disclaimer.is_empty(),
446                "{} missing disclaimer",
447                m.framework
448            );
449            assert!(!m.controls.is_empty());
450        }
451        assert_eq!(names(), vec!["eu-ai-act", "iso42001", "soc2"]);
452    }
453
454    #[test]
455    fn full_claims_carry_tests_and_partial_or_none_carry_gaps() {
456        for m in frameworks() {
457            for c in &m.controls {
458                match c.coverage {
459                    Coverage::Full => {
460                        assert!(
461                            c.test.is_some(),
462                            "{}/{} claims full coverage without a CI test (AC 2)",
463                            m.framework,
464                            c.id
465                        );
466                        assert_ne!(c.mechanism, Mechanism::None);
467                    }
468                    Coverage::None => {
469                        assert!(
470                            c.gap.is_some(),
471                            "{}/{} claims no coverage without documenting the gap",
472                            m.framework,
473                            c.id
474                        );
475                        assert_eq!(c.mechanism, Mechanism::None);
476                    }
477                    Coverage::Partial => {
478                        assert!(
479                            c.gap.is_some(),
480                            "{}/{} partial coverage must document the residual gap",
481                            m.framework,
482                            c.id
483                        );
484                    }
485                }
486            }
487        }
488    }
489
490    #[test]
491    fn reference_packs_exist_and_enforce_every_pack_rule() {
492        for m in frameworks() {
493            let pack = builtin::get(&m.reference_pack).unwrap_or_else(|| {
494                panic!(
495                    "{}: reference pack '{}' missing",
496                    m.framework, m.reference_pack
497                )
498            });
499            let resolved = resolve(&pack).expect("reference pack resolves");
500            let rep = report(m, Some(&resolved));
501            for row in &rep.rows {
502                assert_ne!(
503                    row.status,
504                    RowStatus::NotEnforced,
505                    "{}/{}: reference pack '{}' fails its own claim: {}",
506                    m.framework,
507                    row.id,
508                    m.reference_pack,
509                    row.detail
510                );
511                assert_ne!(
512                    row.status,
513                    RowStatus::NotVerified,
514                    "{}/{}: full pack-rule claim without a wired live check",
515                    m.framework,
516                    row.id
517                );
518            }
519        }
520    }
521
522    #[test]
523    fn weak_pack_downgrades_pack_rule_claims() {
524        // The open-source pack has no regulated-identifier redaction and no
525        // retention floor — full claims must downgrade, not silently pass.
526        let m = get("eu-ai-act").unwrap();
527        let pack = builtin::get("open-source").unwrap();
528        let resolved = resolve(&pack).expect("resolves");
529        let rep = report(m, Some(&resolved));
530        let not_enforced = rep
531            .rows
532            .iter()
533            .filter(|r| r.status == RowStatus::NotEnforced)
534            .count();
535        assert!(
536            not_enforced >= 1,
537            "a weak pack must produce NotEnforced rows, got none"
538        );
539    }
540
541    #[test]
542    fn report_without_pack_marks_pack_rules_not_verified() {
543        let m = get("soc2").unwrap();
544        let rep = report(m, None);
545        assert!(
546            rep.rows
547                .iter()
548                .filter(|r| r.mechanism == Mechanism::PackRule)
549                .all(|r| r.status == RowStatus::NotVerified)
550        );
551        // Engine guarantees stay engine guarantees without a pack.
552        assert!(
553            rep.rows
554                .iter()
555                .any(|r| r.status == RowStatus::EngineGuarantee)
556        );
557    }
558}