Skip to main content

kranz_engine/
standards_enforcement.rs

1//! Flight Rules checker binding and enforcement policy (KRZ-346, D-B/D-F).
2//!
3//! This module is deliberately pure: it classifies lifecycle/level posture,
4//! selects final/merge-stage pinned rules from actual paths, resolves typed
5//! checker references against approval-pinned gate declarations, and builds
6//! rule-cited findings. The orchestrator owns async command/model execution;
7//! merge owns its scratch integration tree. Keeping the policy here gives
8//! both surfaces one fail-closed matrix.
9
10use crate::pack::resolution::{resolve_pin, TouchInput};
11use crate::pack::standards::RuleStage;
12use crate::types::{Finding, PinnedGate, PinnedRule, RuleCitation, StandardsPin};
13
14/// An already-computed checker outcome adapted back into the shared gate
15/// pipeline. Async commands/model turns finish before construction; pipeline
16/// registration still structurally orders deterministic outcomes before
17/// model judgement.
18pub struct PreparedGate {
19    report: crate::gate::GateReport,
20}
21
22impl PreparedGate {
23    pub fn new(report: crate::gate::GateReport) -> Self {
24        Self { report }
25    }
26}
27
28impl crate::gate::Gate for PreparedGate {
29    fn name(&self) -> &str {
30        &self.report.name
31    }
32
33    fn kind(&self) -> crate::gate::GateKind {
34        self.report.kind
35    }
36
37    fn evaluate(&self) -> crate::gate::GateOutcome {
38        self.report.outcome.clone()
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum RuleMode {
44    /// Draft/retired policy never participates in a mission evaluation.
45    Absent,
46    /// Approved rules and enforced SHOULDs produce evidence but never block.
47    Advisory,
48    /// Enforced MUST: a failing/missing checker blocks unless an exact live
49    /// human waiver covers its failure.
50    Authoritative,
51}
52
53/// D-B's lifecycle × level matrix. Unknown pin spellings fail closed as
54/// authoritative: pins are engine-authored, so an unknown value means a
55/// corrupt/hand-edited consent artifact, never a reason to weaken policy.
56pub fn rule_mode(rule: &PinnedRule) -> RuleMode {
57    match (rule.effective_status.as_str(), rule.level.as_str()) {
58        ("draft" | "retired", _) => RuleMode::Absent,
59        ("approved", "must" | "should") | ("enforced", "should") => RuleMode::Advisory,
60        ("enforced", "must") => RuleMode::Authoritative,
61        _ => RuleMode::Authoritative,
62    }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum CheckerBinding<'a> {
67    Gate(&'a PinnedGate),
68    AgentJudgement,
69    ManualAttestation,
70    Unavailable(String),
71}
72
73/// Resolve one typed checker from the approval pin. This never consults the
74/// pack directory or mission worktree. A gate declaration whose own path
75/// scope does not match the actual diff is unavailable for this evaluation:
76/// silently skipping it would turn a scoped enforced MUST green by omission.
77pub fn checker_binding<'a>(
78    pin: &'a StandardsPin,
79    rule: &PinnedRule,
80    actual_paths: &[String],
81) -> CheckerBinding<'a> {
82    let evaluation_paths = crate::pack::resolution::evaluation_paths(pin, actual_paths);
83    match rule.checker.as_deref() {
84        Some("agent-judgement") => CheckerBinding::AgentJudgement,
85        Some("manual-attestation") => CheckerBinding::ManualAttestation,
86        Some(checker) => match checker.strip_prefix("gate:") {
87            Some(id) if !id.is_empty() => match pin.gates.iter().find(|gate| gate.id == id) {
88                Some(gate)
89                    if crate::merge_gate::when_paths_match(&gate.when_paths, &evaluation_paths) =>
90                {
91                    CheckerBinding::Gate(gate)
92                }
93                Some(_) => CheckerBinding::Unavailable(format!(
94                    "checker `gate:{id}` is stage/path-incompatible with the actual diff"
95                )),
96                None => CheckerBinding::Unavailable(format!(
97                    "checker `gate:{id}` has no approval-pinned gate declaration"
98                )),
99            },
100            _ => CheckerBinding::Unavailable(format!(
101                "unknown checker `{checker}`; supported forms are gate:<id>, \
102                 agent-judgement, and manual-attestation"
103            )),
104        },
105        None => CheckerBinding::Unavailable("rule has no approval-pinned checker".to_string()),
106    }
107}
108
109/// Stable union of rules applicable at any requested stage against actual
110/// paths. A rule listed for validation AND merge appears once.
111pub fn applicable_rules(
112    pin: &StandardsPin,
113    stages: &[RuleStage],
114    actual_paths: &[String],
115) -> Vec<PinnedRule> {
116    let mut rules = std::collections::BTreeMap::new();
117    for stage in stages {
118        for rule in resolve_pin(pin, *stage, &TouchInput::Actual(actual_paths)) {
119            if rule_mode(&rule) != RuleMode::Absent {
120                rules.entry(rule.id.clone()).or_insert(rule);
121            }
122        }
123    }
124    rules.into_values().collect()
125}
126
127pub fn citation(pin: &StandardsPin, rule: &PinnedRule) -> RuleCitation {
128    RuleCitation {
129        id: rule.id.clone(),
130        revision: rule.revision,
131        source: format!("{} {}", pin.pack_name, pin.standards_root),
132        digest: pin.digest.clone(),
133        lifecycle: rule.effective_status.clone(),
134        level: rule.level.clone(),
135        checker: rule.checker.clone(),
136    }
137}
138
139/// Canonical failure shape shared by deterministic, contextual, manual, and
140/// unavailable-checker paths. Keeping it stable is important: the D-I waiver
141/// fingerprint binds every byte of this finding and must survive a re-check
142/// of the same unchanged diff.
143pub fn failure_finding(pin: &StandardsPin, rule: &PinnedRule, evidence: &str) -> Finding {
144    Finding {
145        subject: format!("flight-rule:{}", rule.id),
146        severity: if rule_mode(rule) == RuleMode::Authoritative {
147            "critical".to_string()
148        } else {
149            "major".to_string()
150        },
151        evidence: evidence.to_string(),
152        suggested_fix: format!(
153            "bring the change into compliance with {} r{} or request an authorized exact waiver when the rule permits one",
154            rule.id, rule.revision
155        ),
156        class: if rule_mode(rule) == RuleMode::Authoritative {
157            "standards-authoritative".to_string()
158        } else {
159            "standards-advisory".to_string()
160        },
161        rule: Some(citation(pin, rule)),
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::types::{PinnedGate, StandardsPinSource};
169
170    fn rule(status: &str, level: &str, checker: Option<&str>) -> PinnedRule {
171        PinnedRule {
172            id: "ZZ-RULE-001".to_string(),
173            revision: 1,
174            rfc: "RFC-001".to_string(),
175            level: level.to_string(),
176            effective_status: status.to_string(),
177            statement: "Do the safe thing.".to_string(),
178            domains: Vec::new(),
179            stages: vec!["validation".to_string(), "merge".to_string()],
180            when_paths: Vec::new(),
181            task_classes: Vec::new(),
182            checker: checker.map(str::to_string),
183            waivable: true,
184        }
185    }
186
187    fn pin(rule: PinnedRule) -> StandardsPin {
188        StandardsPin {
189            pack_name: "zz-pack".to_string(),
190            pack_dir: "vendor/pack".to_string(),
191            standards_root: "standards".to_string(),
192            digest: "ab".repeat(32),
193            source: StandardsPinSource::RepoTracked,
194            task_class: None,
195            touch_set: vec!["src/**".to_string()],
196            context_paths: Vec::new(),
197            gates: vec![PinnedGate {
198                id: "zz-gate".to_string(),
199                command: "true".to_string(),
200                when_paths: Vec::new(),
201            }],
202            rules: vec![rule],
203        }
204    }
205
206    #[test]
207    fn flight_rules_enforcement_lifecycle_level_matrix_is_exact() {
208        for (status, level, expected) in [
209            ("draft", "must", RuleMode::Absent),
210            ("retired", "must", RuleMode::Absent),
211            ("approved", "must", RuleMode::Advisory),
212            ("approved", "should", RuleMode::Advisory),
213            ("enforced", "should", RuleMode::Advisory),
214            ("enforced", "must", RuleMode::Authoritative),
215        ] {
216            assert_eq!(
217                rule_mode(&rule(status, level, Some("gate:zz-gate"))),
218                expected
219            );
220        }
221        assert_eq!(
222            rule_mode(&rule("unknown", "unknown", None)),
223            RuleMode::Authoritative,
224            "corrupt pins fail closed"
225        );
226    }
227
228    #[test]
229    fn flight_rules_enforcement_checker_uses_only_pinned_gate_binding() {
230        let enforced_rule = rule("enforced", "must", Some("gate:zz-gate"));
231        let pin = pin(enforced_rule.clone());
232        let CheckerBinding::Gate(gate) =
233            checker_binding(&pin, &enforced_rule, &["src/x.rs".into()])
234        else {
235            panic!("expected pinned gate")
236        };
237        assert_eq!(gate.command, "true");
238
239        let missing = rule("enforced", "must", Some("gate:not-there"));
240        let CheckerBinding::Unavailable(reason) = checker_binding(&pin, &missing, &[]) else {
241            panic!("missing binding must fail closed")
242        };
243        assert!(reason.contains("no approval-pinned"), "{reason}");
244    }
245
246    #[test]
247    fn flight_rules_enforcement_stage_selection_is_stable_and_nonduplicating() {
248        let rule = rule("enforced", "must", Some("gate:zz-gate"));
249        let pin = pin(rule);
250        let selected = applicable_rules(
251            &pin,
252            &[RuleStage::Validation, RuleStage::Merge],
253            &["src/x.rs".to_string()],
254        );
255        assert_eq!(selected.len(), 1);
256        assert_eq!(selected[0].id, "ZZ-RULE-001");
257    }
258}