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::coverage::{CREDENTIAL_FIXTURES, DOMAIN_FIXTURES};
21use super::policy::ResolvedPolicy;
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)]
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, Serialize)]
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, Serialize)]
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, Serialize)]
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!("partial by design — unredacted classes: {}", missing.join(", ")),
317                )
318            } else {
319                (
320                    RowStatus::NotEnforced,
321                    format!("unredacted identifier classes: {}", missing.join(", ")),
322                )
323            }
324        }
325        "AIA-15.5-secrets" | "SOC2-C1.1" => {
326            let missing = missing_fixtures(CREDENTIAL_FIXTURES);
327            if missing.is_empty() {
328                (
329                    RowStatus::Enforced,
330                    format!(
331                        "{}/{} credential fixture classes redacted",
332                        CREDENTIAL_FIXTURES.len(),
333                        CREDENTIAL_FIXTURES.len()
334                    ),
335                )
336            } else {
337                (
338                    RowStatus::NotEnforced,
339                    format!("unredacted credential classes: {}", missing.join(", ")),
340                )
341            }
342        }
343        "AIA-15.5-access" | "ISO-A.9.4" | "SOC2-CC6.1" => {
344            if tool_surface_scoped {
345                (
346                    RowStatus::Enforced,
347                    format!(
348                        "tool surface scoped (allow: {}, deny: {}) on top of the default-deny capability gate",
349                        p.allow_tools.as_ref().map_or(0, Vec::len),
350                        p.deny_tools.len()
351                    ),
352                )
353            } else {
354                (
355                    RowStatus::NotEnforced,
356                    "pack neither allows nor denies tools — only the engine capability gate applies"
357                        .to_string(),
358                )
359            }
360        }
361        "AIA-14.4e" => match p.max_context_tokens {
362            Some(cap) => (
363                RowStatus::Enforced,
364                format!("max_context_tokens = {cap} bounds every assembly"),
365            ),
366            None => (
367                RowStatus::NotEnforced,
368                "no hard context cap declared".to_string(),
369            ),
370        },
371        "SOC2-CC6.6" => {
372            if egress_denied {
373                (
374                    RowStatus::Enforced,
375                    format!("egress tools denied: {}", p.deny_tools.join(", ")),
376                )
377            } else {
378                (
379                    RowStatus::NotEnforced,
380                    "no egress tool denied by pack".to_string(),
381                )
382            }
383        }
384        "ISO-A.9.2" => {
385            let mut declared = Vec::new();
386            if p.max_context_tokens.is_some() {
387                declared.push("budget cap");
388            }
389            if p.audit_retention_days.is_some() {
390                declared.push("retention");
391            }
392            if tool_surface_scoped {
393                declared.push("tool scope");
394            }
395            if !p.redaction.is_empty() {
396                declared.push("redaction");
397            }
398            if declared.len() >= 3 {
399                (
400                    RowStatus::Enforced,
401                    format!("enforced process declared: {}", declared.join(", ")),
402                )
403            } else {
404                (
405                    RowStatus::NotEnforced,
406                    format!(
407                        "pack declares too little process ({}) for an A.9.2 claim",
408                        if declared.is_empty() {
409                            "nothing".to_string()
410                        } else {
411                            declared.join(", ")
412                        }
413                    ),
414                )
415            }
416        }
417        other => (
418            RowStatus::NotVerified,
419            format!("no live check wired for pack-rule control {other} — fix the mapping or add a check"),
420        ),
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::core::policy::{builtin, resolve};
428
429    #[test]
430    fn all_mappings_parse_and_are_pinned() {
431        for m in frameworks() {
432            assert!(
433                !m.version_pin.is_empty(),
434                "{} missing version pin",
435                m.framework
436            );
437            assert!(!m.pinned_on.is_empty(), "{} missing pin date", m.framework);
438            assert!(m.review_cycle_months > 0);
439            assert!(
440                !m.disclaimer.is_empty(),
441                "{} missing disclaimer",
442                m.framework
443            );
444            assert!(!m.controls.is_empty());
445        }
446        assert_eq!(names(), vec!["eu-ai-act", "iso42001", "soc2"]);
447    }
448
449    #[test]
450    fn full_claims_carry_tests_and_partial_or_none_carry_gaps() {
451        for m in frameworks() {
452            for c in &m.controls {
453                match c.coverage {
454                    Coverage::Full => {
455                        assert!(
456                            c.test.is_some(),
457                            "{}/{} claims full coverage without a CI test (AC 2)",
458                            m.framework,
459                            c.id
460                        );
461                        assert!(c.mechanism != Mechanism::None);
462                    }
463                    Coverage::None => {
464                        assert!(
465                            c.gap.is_some(),
466                            "{}/{} claims no coverage without documenting the gap",
467                            m.framework,
468                            c.id
469                        );
470                        assert_eq!(c.mechanism, Mechanism::None);
471                    }
472                    Coverage::Partial => {
473                        assert!(
474                            c.gap.is_some(),
475                            "{}/{} partial coverage must document the residual gap",
476                            m.framework,
477                            c.id
478                        );
479                    }
480                }
481            }
482        }
483    }
484
485    #[test]
486    fn reference_packs_exist_and_enforce_every_pack_rule() {
487        for m in frameworks() {
488            let pack = builtin::get(&m.reference_pack).unwrap_or_else(|| {
489                panic!(
490                    "{}: reference pack '{}' missing",
491                    m.framework, m.reference_pack
492                )
493            });
494            let resolved = resolve(&pack).expect("reference pack resolves");
495            let rep = report(m, Some(&resolved));
496            for row in &rep.rows {
497                assert_ne!(
498                    row.status,
499                    RowStatus::NotEnforced,
500                    "{}/{}: reference pack '{}' fails its own claim: {}",
501                    m.framework,
502                    row.id,
503                    m.reference_pack,
504                    row.detail
505                );
506                assert_ne!(
507                    row.status,
508                    RowStatus::NotVerified,
509                    "{}/{}: full pack-rule claim without a wired live check",
510                    m.framework,
511                    row.id
512                );
513            }
514        }
515    }
516
517    #[test]
518    fn weak_pack_downgrades_pack_rule_claims() {
519        // The open-source pack has no regulated-identifier redaction and no
520        // retention floor — full claims must downgrade, not silently pass.
521        let m = get("eu-ai-act").unwrap();
522        let pack = builtin::get("open-source").unwrap();
523        let resolved = resolve(&pack).expect("resolves");
524        let rep = report(m, Some(&resolved));
525        let not_enforced = rep
526            .rows
527            .iter()
528            .filter(|r| r.status == RowStatus::NotEnforced)
529            .count();
530        assert!(
531            not_enforced >= 1,
532            "a weak pack must produce NotEnforced rows, got none"
533        );
534    }
535
536    #[test]
537    fn report_without_pack_marks_pack_rules_not_verified() {
538        let m = get("soc2").unwrap();
539        let rep = report(m, None);
540        assert!(rep
541            .rows
542            .iter()
543            .filter(|r| r.mechanism == Mechanism::PackRule)
544            .all(|r| r.status == RowStatus::NotVerified));
545        // Engine guarantees stay engine guarantees without a pack.
546        assert!(rep
547            .rows
548            .iter()
549            .any(|r| r.status == RowStatus::EngineGuarantee));
550    }
551}