Skip to main content

crue_engine/
engine.rs

1//! CRUE Engine Core
2
3use crate::context::EvaluationContext;
4use crate::decision::{ActionResult, Decision};
5use crate::error::EngineError;
6use crate::ir::{ActionInstruction, RuleEffect};
7#[cfg(feature = "pq-proof")]
8use crate::proof::PqProofEnvelope;
9use crate::proof::{ProofBinding, ProofEnvelope, ProofEnvelopeV1};
10use crate::rules::RuleRegistry;
11use crate::vm::{ActionVm, BytecodeVm, Instruction};
12use crate::{EvaluationRequest, EvaluationResult};
13use crue_dsl::ast::RuleAst;
14use crue_dsl::compiler::{Bytecode, Compiler};
15use std::time::Instant;
16use tracing::{error, info, warn};
17
18/// Compiled policy rule evaluated through the bytecode VM.
19#[derive(Debug, Clone)]
20pub struct CompiledPolicyRule {
21    pub id: String,
22    pub version: String,
23    pub policy_hash: String,
24    pub bytecode: Bytecode,
25    pub effects: Vec<RuleEffect>,
26    pub match_program: Vec<Instruction>,
27    pub action_program: Vec<ActionInstruction>,
28}
29
30impl CompiledPolicyRule {
31    pub fn from_ast(ast: &RuleAst) -> Result<Self, EngineError> {
32        let policy_hash = hash_policy_ast(ast)?;
33        let bytecode =
34            Compiler::compile(ast).map_err(|e| EngineError::CompilationError(e.to_string()))?;
35        let effects = ast
36            .then_clause
37            .clone()
38            .into_iter()
39            .map(RuleEffect::try_from)
40            .collect::<Result<Vec<_>, _>>()?;
41        let action_program = if bytecode.action_instructions.is_empty() {
42            // Backward-compatible fallback for bytecode produced before THEN-action compilation existed.
43            compile_action_program(&effects)
44        } else {
45            bytecode
46                .action_instructions
47                .iter()
48                .cloned()
49                .map(ActionInstruction::try_from)
50                .collect::<Result<Vec<_>, _>>()?
51        };
52        let primary_decision = ActionVm::execute(&action_program)?.decision;
53        let match_program = BytecodeVm::build_match_program(&bytecode, primary_decision)?;
54        Ok(Self {
55            id: ast.id.clone(),
56            version: ast.version.clone(),
57            policy_hash,
58            bytecode,
59            effects,
60            match_program,
61            action_program,
62        })
63    }
64
65    pub fn from_source(source: &str) -> Result<Self, EngineError> {
66        let ast = crue_dsl::parser::parse(source)
67            .map_err(|e| EngineError::CompilationError(e.to_string()))?;
68        Self::from_ast(&ast)
69    }
70
71    pub fn evaluate(&self, ctx: &EvaluationContext) -> Result<bool, EngineError> {
72        BytecodeVm::eval(&self.bytecode, ctx)
73    }
74
75    /// Preferred compiled-path evaluation: returns `Some(decision)` on match, `None` otherwise.
76    pub fn evaluate_match_decision(
77        &self,
78        ctx: &EvaluationContext,
79    ) -> Result<Option<Decision>, EngineError> {
80        BytecodeVm::eval_match_program(&self.match_program, &self.bytecode, ctx)
81    }
82
83    pub fn apply_action(&self) -> ActionResult {
84        ActionVm::execute(&self.action_program)
85            .unwrap_or_else(|_| compiled_actions_to_result(&self.effects))
86    }
87}
88
89/// CRUE Engine - main rule evaluation engine
90pub struct CrueEngine {
91    rule_registry: RuleRegistry,
92    compiled_rules: Vec<CompiledPolicyRule>,
93    strict_mode: bool,
94}
95
96impl CrueEngine {
97    /// Create new CRUE engine
98    pub fn new() -> Self {
99        CrueEngine {
100            rule_registry: RuleRegistry::new(),
101            compiled_rules: Vec::new(),
102            strict_mode: true,
103        }
104    }
105
106    /// Load rules from legacy runtime registry.
107    pub fn load_rules(&mut self, registry: RuleRegistry) {
108        self.rule_registry = registry;
109    }
110
111    /// Replace compiled rules (preferred execution path when non-empty).
112    pub fn load_compiled_rules(&mut self, rules: Vec<CompiledPolicyRule>) {
113        self.compiled_rules = rules;
114    }
115
116    /// Register a compiled rule from AST.
117    pub fn register_compiled_rule_ast(&mut self, ast: &RuleAst) -> Result<(), EngineError> {
118        self.compiled_rules.push(CompiledPolicyRule::from_ast(ast)?);
119        Ok(())
120    }
121
122    /// Register a compiled rule from DSL source.
123    pub fn register_compiled_rule_source(&mut self, source: &str) -> Result<(), EngineError> {
124        self.compiled_rules
125            .push(CompiledPolicyRule::from_source(source)?);
126        Ok(())
127    }
128
129    /// Clear compiled rules and fall back to legacy runtime rules.
130    pub fn clear_compiled_rules(&mut self) {
131        self.compiled_rules.clear();
132    }
133
134    /// Evaluate request against compiled rules first, then legacy runtime rules.
135    pub fn evaluate(&self, request: &EvaluationRequest) -> EvaluationResult {
136        self.evaluate_internal(request, None).0
137    }
138
139    /// Evaluate request and produce a strict `ProofBinding` when the compiled-bytecode path is used.
140    ///
141    /// Returns `(result, binding)` where `binding` is:
142    /// - `Some(..)` when a compiled rule matched and binding generation succeeded
143    /// - `None` when evaluation used legacy runtime rules or no compiled rule matched
144    pub fn evaluate_with_proof(
145        &self,
146        request: &EvaluationRequest,
147        crypto_backend_id: &str,
148    ) -> (EvaluationResult, Option<ProofBinding>) {
149        self.evaluate_internal(request, Some(crypto_backend_id))
150    }
151
152    /// Evaluate request and return a signed proof envelope (Ed25519 bootstrap signing).
153    ///
154    /// The envelope is only produced when the compiled-bytecode path is used and a rule matches.
155    pub fn evaluate_with_signed_proof_ed25519(
156        &self,
157        request: &EvaluationRequest,
158        crypto_backend_id: &str,
159        signer_key_id: &str,
160        key_pair: &crypto_core::signature::Ed25519KeyPair,
161    ) -> (EvaluationResult, Option<ProofEnvelope>) {
162        let (result, binding) = self.evaluate_with_proof(request, crypto_backend_id);
163        let Some(binding) = binding else {
164            return (result, None);
165        };
166
167        match ProofEnvelope::sign_ed25519(binding, signer_key_id.to_string(), key_pair) {
168            Ok(envelope) => (result, Some(envelope)),
169            Err(e) => {
170                error!("Failed to sign proof envelope: {}", e);
171                if self.strict_mode {
172                    (
173                        engine_error_result(
174                            request,
175                            result.rule_id.clone(),
176                            result.rule_version.clone(),
177                            &format!("Proof envelope signing error: {}", e),
178                            result.evaluation_time_ms,
179                        ),
180                        None,
181                    )
182                } else {
183                    (result, None)
184                }
185            }
186        }
187    }
188
189    /// Evaluate request and return a canonical `ProofEnvelopeV1` signed with Ed25519.
190    pub fn evaluate_with_signed_proof_v1_ed25519(
191        &self,
192        request: &EvaluationRequest,
193        crypto_backend_id: &str,
194        signer_key_id: &str,
195        key_pair: &crypto_core::signature::Ed25519KeyPair,
196    ) -> (EvaluationResult, Option<ProofEnvelopeV1>) {
197        let (result, binding) = self.evaluate_with_proof(request, crypto_backend_id);
198        let Some(binding) = binding else {
199            return (result, None);
200        };
201        match ProofEnvelopeV1::sign_ed25519(&binding, signer_key_id, key_pair) {
202            Ok(envelope) => (result, Some(envelope)),
203            Err(e) => {
204                error!("Failed to sign proof envelope v1: {}", e);
205                if self.strict_mode {
206                    (
207                        engine_error_result(
208                            request,
209                            result.rule_id.clone(),
210                            result.rule_version.clone(),
211                            &format!("ProofEnvelopeV1 signing error: {}", e),
212                            result.evaluation_time_ms,
213                        ),
214                        None,
215                    )
216                } else {
217                    (result, None)
218                }
219            }
220        }
221    }
222
223    /// Evaluate request and return a signed PQ/hybrid proof envelope.
224    ///
225    /// Requires the `pq-proof` feature. The envelope is only produced when the
226    /// compiled-bytecode path matches a rule.
227    #[cfg(feature = "pq-proof")]
228    pub fn evaluate_with_signed_proof_hybrid(
229        &self,
230        request: &EvaluationRequest,
231        signer_key_id: &str,
232        signer: &pqcrypto::hybrid::HybridSigner,
233        keypair: &pqcrypto::hybrid::HybridKeyPair,
234    ) -> (EvaluationResult, Option<PqProofEnvelope>) {
235        let (result, binding) = self.evaluate_with_proof(request, signer.backend_id());
236        let Some(binding) = binding else {
237            return (result, None);
238        };
239
240        match PqProofEnvelope::sign_hybrid(binding, signer_key_id.to_string(), signer, keypair) {
241            Ok(envelope) => (result, Some(envelope)),
242            Err(e) => {
243                error!("Failed to sign PQ proof envelope: {}", e);
244                if self.strict_mode {
245                    (
246                        engine_error_result(
247                            request,
248                            result.rule_id.clone(),
249                            result.rule_version.clone(),
250                            &format!("PQ proof envelope signing error: {}", e),
251                            result.evaluation_time_ms,
252                        ),
253                        None,
254                    )
255                } else {
256                    (result, None)
257                }
258            }
259        }
260    }
261
262    /// Evaluate request and return a canonical `ProofEnvelopeV1` signed with the hybrid signer.
263    #[cfg(feature = "pq-proof")]
264    pub fn evaluate_with_signed_proof_v1_hybrid(
265        &self,
266        request: &EvaluationRequest,
267        signer_key_id: &str,
268        signer: &pqcrypto::hybrid::HybridSigner,
269        keypair: &pqcrypto::hybrid::HybridKeyPair,
270    ) -> (EvaluationResult, Option<ProofEnvelopeV1>) {
271        let (result, binding) = self.evaluate_with_proof(request, signer.backend_id());
272        let Some(binding) = binding else {
273            return (result, None);
274        };
275        match ProofEnvelopeV1::sign_hybrid(&binding, signer_key_id, signer, keypair) {
276            Ok(envelope) => (result, Some(envelope)),
277            Err(e) => {
278                error!("Failed to sign proof envelope v1 hybrid: {}", e);
279                if self.strict_mode {
280                    (
281                        engine_error_result(
282                            request,
283                            result.rule_id.clone(),
284                            result.rule_version.clone(),
285                            &format!("ProofEnvelopeV1 hybrid signing error: {}", e),
286                            result.evaluation_time_ms,
287                        ),
288                        None,
289                    )
290                } else {
291                    (result, None)
292                }
293            }
294        }
295    }
296
297    fn evaluate_internal(
298        &self,
299        request: &EvaluationRequest,
300        proof_backend: Option<&str>,
301    ) -> (EvaluationResult, Option<ProofBinding>) {
302        let start = Instant::now();
303        info!("Evaluating request: {}", request.request_id);
304        let ctx = EvaluationContext::from_request(request);
305
306        if let Some(result) = self.evaluate_compiled(request, &ctx, start, proof_backend) {
307            return result;
308        }
309
310        (self.evaluate_legacy_rules(request, &ctx, start), None)
311    }
312
313    fn evaluate_compiled(
314        &self,
315        request: &EvaluationRequest,
316        ctx: &EvaluationContext,
317        start: Instant,
318        proof_backend: Option<&str>,
319    ) -> Option<(EvaluationResult, Option<ProofBinding>)> {
320        for rule in &self.compiled_rules {
321            match rule.evaluate_match_decision(ctx) {
322                Ok(Some(vm_decision)) => {
323                    let mut result = rule.apply_action();
324                    if result.decision != vm_decision {
325                        let msg = format!(
326                            "Compiled VM decision mismatch for rule {}: vm={:?} action={:?}",
327                            rule.id, vm_decision, result.decision
328                        );
329                        error!("{}", msg);
330                        if self.strict_mode {
331                            return Some((
332                                engine_error_result(
333                                    request,
334                                    Some(rule.id.clone()),
335                                    Some(rule.version.clone()),
336                                    &msg,
337                                    start.elapsed().as_millis() as u64,
338                                ),
339                                None,
340                            ));
341                        }
342                    } else {
343                        result.decision = vm_decision;
344                    }
345                    let evaluation_time = start.elapsed().as_millis() as u64;
346                    info!(
347                        "Request {}: {} by compiled rule {} ({}ms)",
348                        request.request_id,
349                        format!("{:?}", result.decision),
350                        rule.id,
351                        evaluation_time
352                    );
353                    let eval_result = build_eval_result(
354                        request,
355                        result.clone(),
356                        Some(rule.id.clone()),
357                        Some(rule.version.clone()),
358                        evaluation_time,
359                    );
360
361                    let binding = if let Some(crypto_backend_id) = proof_backend {
362                        match ProofBinding::create_with_policy_hash(
363                            &rule.bytecode,
364                            request,
365                            ctx,
366                            result.decision,
367                            crypto_backend_id,
368                            Some(&rule.policy_hash),
369                        ) {
370                            Ok(binding) => Some(binding),
371                            Err(e) => {
372                                error!(
373                                    "Failed to build proof binding for compiled rule {}: {}",
374                                    rule.id, e
375                                );
376                                if self.strict_mode {
377                                    return Some((
378                                        engine_error_result(
379                                            request,
380                                            Some(rule.id.clone()),
381                                            Some(rule.version.clone()),
382                                            &format!("Proof binding error: {}", e),
383                                            evaluation_time,
384                                        ),
385                                        None,
386                                    ));
387                                }
388                                None
389                            }
390                        }
391                    } else {
392                        None
393                    };
394                    return Some((eval_result, binding));
395                }
396                Ok(None) => {}
397                Err(e) => {
398                    if self.strict_mode {
399                        error!("Error evaluating compiled rule {}: {}", rule.id, e);
400                        return Some((
401                            engine_error_result(
402                                request,
403                                Some(rule.id.clone()),
404                                Some(rule.version.clone()),
405                                &e.to_string(),
406                                start.elapsed().as_millis() as u64,
407                            ),
408                            None,
409                        ));
410                    } else {
411                        warn!(
412                            "Non-strict mode: continuing after error in compiled rule {}",
413                            rule.id
414                        );
415                    }
416                }
417            }
418        }
419        None
420    }
421
422    fn evaluate_legacy_rules(
423        &self,
424        request: &EvaluationRequest,
425        ctx: &EvaluationContext,
426        start: Instant,
427    ) -> EvaluationResult {
428        let rules = self.rule_registry.get_active_rules();
429
430        for rule in rules {
431            if !rule.is_valid_now() {
432                continue;
433            }
434
435            match rule.evaluate(ctx) {
436                Ok(true) => {
437                    let result = rule.apply_action(ctx);
438                    let evaluation_time = start.elapsed().as_millis() as u64;
439                    info!(
440                        "Request {}: {} by rule {} ({}ms)",
441                        request.request_id,
442                        format!("{:?}", result.decision),
443                        rule.id,
444                        evaluation_time
445                    );
446                    return build_eval_result(
447                        request,
448                        result,
449                        Some(rule.id.clone()),
450                        Some(rule.version.clone()),
451                        evaluation_time,
452                    );
453                }
454                Ok(false) => {}
455                Err(e) => {
456                    if self.strict_mode {
457                        error!("Error evaluating rule {}: {}", rule.id, e);
458                        return engine_error_result(
459                            request,
460                            Some(rule.id.clone()),
461                            Some(rule.version.clone()),
462                            &e.to_string(),
463                            start.elapsed().as_millis() as u64,
464                        );
465                    } else {
466                        warn!(
467                            "Non-strict mode: continuing after error in rule {}",
468                            rule.id
469                        );
470                    }
471                }
472            }
473        }
474
475        EvaluationResult {
476            request_id: request.request_id.clone(),
477            decision: Decision::Allow,
478            evaluated_at: chrono::Utc::now().to_rfc3339(),
479            evaluation_time_ms: start.elapsed().as_millis() as u64,
480            ..Default::default()
481        }
482    }
483
484    /// Set strict mode
485    pub fn set_strict_mode(&mut self, strict: bool) {
486        self.strict_mode = strict;
487    }
488
489    /// Get total rule count (compiled + legacy runtime registry)
490    pub fn rule_count(&self) -> usize {
491        self.compiled_rules.len() + self.rule_registry.len()
492    }
493
494    /// Number of compiled rules loaded in VM path.
495    pub fn compiled_rule_count(&self) -> usize {
496        self.compiled_rules.len()
497    }
498}
499
500impl Default for CrueEngine {
501    fn default() -> Self {
502        Self::new()
503    }
504}
505
506fn compiled_actions_to_result(actions: &[RuleEffect]) -> ActionResult {
507    let has_soc_alert = actions.iter().any(RuleEffect::is_alert_only);
508    let primary = actions
509        .iter()
510        .find(|a| !a.is_alert_only())
511        .cloned()
512        .unwrap_or(RuleEffect::Log);
513
514    let mut result = match primary {
515        RuleEffect::Block { code, message } => {
516            ActionResult::block(&code, message.as_deref().unwrap_or("Access denied"))
517        }
518        RuleEffect::Warn { code } => ActionResult::warn(&code, "Policy warning"),
519        RuleEffect::RequireApproval {
520            code,
521            timeout_minutes,
522        } => ActionResult::approval_required(&code, timeout_minutes),
523        RuleEffect::Log | RuleEffect::AlertSoc => ActionResult::allow(),
524    };
525
526    if has_soc_alert {
527        result = result.with_soc_alert();
528    }
529    result
530}
531
532fn compile_action_program(actions: &[RuleEffect]) -> Vec<ActionInstruction> {
533    let has_soc_alert = actions.iter().any(RuleEffect::is_alert_only);
534    let primary = actions
535        .iter()
536        .find(|a| !a.is_alert_only())
537        .cloned()
538        .unwrap_or(RuleEffect::Log);
539
540    let mut program = Vec::new();
541    match primary {
542        RuleEffect::Block { code, message } => {
543            program.push(ActionInstruction::SetDecision(Decision::Block));
544            program.push(ActionInstruction::SetErrorCode(code));
545            program.push(ActionInstruction::SetMessage(
546                message.unwrap_or_else(|| "Access denied".to_string()),
547            ));
548        }
549        RuleEffect::Warn { code } => {
550            program.push(ActionInstruction::SetDecision(Decision::Warn));
551            program.push(ActionInstruction::SetErrorCode(code));
552            program.push(ActionInstruction::SetMessage("Policy warning".to_string()));
553        }
554        RuleEffect::RequireApproval {
555            code,
556            timeout_minutes,
557        } => {
558            program.push(ActionInstruction::SetDecision(Decision::ApprovalRequired));
559            program.push(ActionInstruction::SetErrorCode(code));
560            program.push(ActionInstruction::SetApprovalTimeout(timeout_minutes));
561        }
562        RuleEffect::Log | RuleEffect::AlertSoc => {
563            program.push(ActionInstruction::SetDecision(Decision::Allow));
564        }
565    }
566
567    if has_soc_alert {
568        program.push(ActionInstruction::SetAlertSoc(true));
569    }
570    program.push(ActionInstruction::Halt);
571    program
572}
573
574fn hash_policy_ast(ast: &RuleAst) -> Result<String, EngineError> {
575    let bytes = serde_json::to_vec(ast).map_err(|e| {
576        EngineError::CompilationError(format!("Policy AST serialization error: {}", e))
577    })?;
578    Ok(crypto_core::hash::hex_encode(&crypto_core::hash::sha256(
579        &bytes,
580    )))
581}
582
583fn build_eval_result(
584    request: &EvaluationRequest,
585    result: ActionResult,
586    rule_id: Option<String>,
587    rule_version: Option<String>,
588    evaluation_time_ms: u64,
589) -> EvaluationResult {
590    EvaluationResult {
591        request_id: request.request_id.clone(),
592        decision: result.decision,
593        error_code: result.error_code,
594        message: result.message,
595        rule_id,
596        rule_version,
597        evaluated_at: chrono::Utc::now().to_rfc3339(),
598        evaluation_time_ms,
599    }
600}
601
602fn engine_error_result(
603    request: &EvaluationRequest,
604    rule_id: Option<String>,
605    rule_version: Option<String>,
606    msg: &str,
607    evaluation_time_ms: u64,
608) -> EvaluationResult {
609    EvaluationResult {
610        request_id: request.request_id.clone(),
611        decision: Decision::Block,
612        error_code: Some("ENGINE_ERROR".to_string()),
613        message: Some(format!("Rule evaluation error: {}", msg)),
614        rule_id,
615        rule_version,
616        evaluated_at: chrono::Utc::now().to_rfc3339(),
617        evaluation_time_ms,
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn test_engine_default() {
627        let engine = CrueEngine::new();
628        assert!(engine.rule_count() > 0);
629    }
630
631    #[test]
632    fn test_evaluate_default_allow() {
633        let mut engine = CrueEngine::new();
634        engine.load_rules(RuleRegistry::empty());
635
636        let request = EvaluationRequest {
637            request_id: "test_001".to_string(),
638            agent_id: "AGENT_001".to_string(),
639            agent_org: "DGFiP".to_string(),
640            agent_level: "standard".to_string(),
641            mission_id: None,
642            mission_type: None,
643            query_type: None,
644            justification: None,
645            export_format: None,
646            result_limit: None,
647            requests_last_hour: 0,
648            requests_last_24h: 0,
649            results_last_query: 0,
650            account_department: None,
651            allowed_departments: vec![],
652            request_hour: 12,
653            is_within_mission_hours: true,
654        };
655
656        let result = engine.evaluate(&request);
657        assert_eq!(result.decision, Decision::Allow);
658    }
659
660    #[test]
661    fn test_evaluate_compiled_rule_path() {
662        let source = r#"
663RULE CRUE_900 VERSION 1.0
664WHEN
665    agent.requests_last_hour >= 50
666THEN
667    BLOCK WITH CODE "VOLUME_EXCEEDED"
668"#;
669        let mut engine = CrueEngine::new();
670        engine.load_rules(RuleRegistry::empty());
671        engine.register_compiled_rule_source(source).unwrap();
672        assert_eq!(engine.compiled_rule_count(), 1);
673
674        let request = EvaluationRequest {
675            request_id: "req".to_string(),
676            agent_id: "A".to_string(),
677            agent_org: "O".to_string(),
678            agent_level: "L".to_string(),
679            mission_id: None,
680            mission_type: None,
681            query_type: None,
682            justification: Some("sufficient".to_string()),
683            export_format: None,
684            result_limit: None,
685            requests_last_hour: 51,
686            requests_last_24h: 100,
687            results_last_query: 1,
688            account_department: None,
689            allowed_departments: vec![],
690            request_hour: 8,
691            is_within_mission_hours: true,
692        };
693        let result = engine.evaluate(&request);
694        assert_eq!(result.decision, Decision::Block);
695        assert_eq!(result.rule_id.as_deref(), Some("CRUE_900"));
696        let compiled = &engine.compiled_rules[0];
697        assert!(!compiled.action_program.is_empty());
698        assert!(!compiled.bytecode.action_instructions.is_empty());
699    }
700
701    #[test]
702    fn test_evaluate_with_proof_returns_binding_for_compiled_path() {
703        let source = r#"
704RULE CRUE_901 VERSION 1.0
705WHEN
706    agent.requests_last_hour >= 50
707THEN
708    BLOCK WITH CODE "VOLUME_EXCEEDED"
709"#;
710        let mut engine = CrueEngine::new();
711        engine.load_rules(RuleRegistry::empty());
712        engine.register_compiled_rule_source(source).unwrap();
713
714        let request = EvaluationRequest {
715            request_id: "req".to_string(),
716            agent_id: "A".to_string(),
717            agent_org: "O".to_string(),
718            agent_level: "L".to_string(),
719            mission_id: None,
720            mission_type: None,
721            query_type: None,
722            justification: Some("sufficient".to_string()),
723            export_format: None,
724            result_limit: None,
725            requests_last_hour: 60,
726            requests_last_24h: 100,
727            results_last_query: 1,
728            account_department: None,
729            allowed_departments: vec![],
730            request_hour: 8,
731            is_within_mission_hours: true,
732        };
733        let (result, binding) = engine.evaluate_with_proof(&request, "mock-crypto");
734        assert_eq!(result.decision, Decision::Block);
735        let binding = binding.expect("compiled path should produce binding");
736        let ctx = EvaluationContext::from_request(&request);
737        assert!(binding
738            .verify_recompute(
739                &engine.compiled_rules[0].bytecode,
740                &request,
741                &ctx,
742                result.decision,
743                "mock-crypto",
744            )
745            .unwrap());
746    }
747
748    #[test]
749    fn test_compiled_path_falls_back_to_legacy_rules() {
750        let source = r#"
751RULE CRUE_900 VERSION 1.0
752WHEN
753    agent.requests_last_hour >= 500
754THEN
755    BLOCK WITH CODE "NEVER"
756"#;
757        let mut engine = CrueEngine::new();
758        engine.register_compiled_rule_source(source).unwrap();
759
760        let request = EvaluationRequest {
761            request_id: "req".to_string(),
762            agent_id: "A".to_string(),
763            agent_org: "O".to_string(),
764            agent_level: "L".to_string(),
765            mission_id: None,
766            mission_type: None,
767            query_type: None,
768            justification: Some("ok justification".to_string()),
769            export_format: None,
770            result_limit: None,
771            requests_last_hour: 60, // triggers built-in CRUE_001
772            requests_last_24h: 100,
773            results_last_query: 1,
774            account_department: None,
775            allowed_departments: vec![],
776            request_hour: 8,
777            is_within_mission_hours: true,
778        };
779        let result = engine.evaluate(&request);
780        assert_eq!(result.decision, Decision::Block);
781        assert_eq!(result.rule_id.as_deref(), Some("CRUE_001"));
782
783        let (result2, binding) = engine.evaluate_with_proof(&request, "mock-crypto");
784        assert_eq!(result2.decision, Decision::Block);
785        assert_eq!(result2.rule_id.as_deref(), Some("CRUE_001"));
786        assert!(binding.is_none());
787    }
788
789    #[test]
790    fn test_evaluate_with_signed_proof_ed25519_compiled_path() {
791        let source = r#"
792RULE CRUE_902 VERSION 1.0
793WHEN
794    agent.requests_last_hour >= 50
795THEN
796    BLOCK WITH CODE "VOLUME_EXCEEDED"
797"#;
798        let mut engine = CrueEngine::new();
799        engine.load_rules(RuleRegistry::empty());
800        engine.register_compiled_rule_source(source).unwrap();
801
802        let request = EvaluationRequest {
803            request_id: "req".to_string(),
804            agent_id: "A".to_string(),
805            agent_org: "O".to_string(),
806            agent_level: "L".to_string(),
807            mission_id: None,
808            mission_type: None,
809            query_type: None,
810            justification: Some("sufficient".to_string()),
811            export_format: None,
812            result_limit: None,
813            requests_last_hour: 70,
814            requests_last_24h: 100,
815            results_last_query: 1,
816            account_department: None,
817            allowed_departments: vec![],
818            request_hour: 8,
819            is_within_mission_hours: true,
820        };
821        let kp = crypto_core::signature::Ed25519KeyPair::generate().unwrap();
822        let pk = kp.verifying_key();
823        let (result, envelope) =
824            engine.evaluate_with_signed_proof_ed25519(&request, "mock-crypto", "proof-key-1", &kp);
825        assert_eq!(result.decision, Decision::Block);
826        let envelope = envelope.expect("compiled path should produce signed envelope");
827        assert_eq!(envelope.binding.crypto_backend_id, "mock-crypto");
828        assert!(envelope.verify_ed25519(&pk).unwrap());
829    }
830
831    #[test]
832    fn test_evaluate_with_signed_proof_v1_ed25519_compiled_path() {
833        let source = r#"
834RULE CRUE_902B VERSION 1.0
835WHEN
836    agent.requests_last_hour >= 50
837THEN
838    BLOCK WITH CODE "VOLUME_EXCEEDED"
839"#;
840        let mut engine = CrueEngine::new();
841        engine.load_rules(RuleRegistry::empty());
842        engine.register_compiled_rule_source(source).unwrap();
843
844        let request = EvaluationRequest {
845            request_id: "req".to_string(),
846            agent_id: "A".to_string(),
847            agent_org: "O".to_string(),
848            agent_level: "L".to_string(),
849            mission_id: None,
850            mission_type: None,
851            query_type: None,
852            justification: Some("sufficient".to_string()),
853            export_format: None,
854            result_limit: None,
855            requests_last_hour: 70,
856            requests_last_24h: 100,
857            results_last_query: 1,
858            account_department: None,
859            allowed_departments: vec![],
860            request_hour: 8,
861            is_within_mission_hours: true,
862        };
863        let kp = crypto_core::signature::Ed25519KeyPair::generate().unwrap();
864        let pk = kp.verifying_key();
865        let (result, envelope) = engine.evaluate_with_signed_proof_v1_ed25519(
866            &request,
867            "mock-crypto",
868            "proof-key-v1",
869            &kp,
870        );
871        assert_eq!(result.decision, Decision::Block);
872        let envelope = envelope.expect("compiled path should produce v1 envelope");
873        assert_eq!(envelope.decision().unwrap(), Decision::Block);
874        assert!(envelope.verify_ed25519(&pk).unwrap());
875        assert!(!envelope.canonical_bytes().unwrap().is_empty());
876    }
877
878    #[cfg(feature = "pq-proof")]
879    #[test]
880    fn test_evaluate_with_signed_proof_hybrid_compiled_path() {
881        let source = r#"
882RULE CRUE_903 VERSION 1.0
883WHEN
884    agent.requests_last_hour >= 50
885THEN
886    BLOCK WITH CODE "VOLUME_EXCEEDED"
887"#;
888        let mut engine = CrueEngine::new();
889        engine.load_rules(RuleRegistry::empty());
890        engine.register_compiled_rule_source(source).unwrap();
891
892        let request = EvaluationRequest {
893            request_id: "req".to_string(),
894            agent_id: "A".to_string(),
895            agent_org: "O".to_string(),
896            agent_level: "L".to_string(),
897            mission_id: None,
898            mission_type: None,
899            query_type: None,
900            justification: Some("sufficient".to_string()),
901            export_format: None,
902            result_limit: None,
903            requests_last_hour: 70,
904            requests_last_24h: 100,
905            results_last_query: 1,
906            account_department: None,
907            allowed_departments: vec![],
908            request_hour: 8,
909            is_within_mission_hours: true,
910        };
911        let signer = pqcrypto::hybrid::HybridSigner::new(pqcrypto::DilithiumLevel::Dilithium2);
912        let keypair = signer.generate_keypair().unwrap();
913        let public_key = keypair.public_key();
914        let (result, envelope) =
915            engine.evaluate_with_signed_proof_hybrid(&request, "pq-proof-key-1", &signer, &keypair);
916        assert_eq!(result.decision, Decision::Block);
917        let envelope = envelope.expect("compiled path should produce signed PQ envelope");
918        assert_eq!(envelope.pq_backend_id, signer.backend_id());
919        assert!(envelope.verify_hybrid(&public_key).unwrap());
920    }
921
922    #[cfg(feature = "pq-proof")]
923    #[test]
924    fn test_evaluate_with_signed_proof_v1_hybrid_compiled_path() {
925        let source = r#"
926RULE CRUE_903B VERSION 1.0
927WHEN
928    agent.requests_last_hour >= 50
929THEN
930    BLOCK WITH CODE "VOLUME_EXCEEDED"
931"#;
932        let mut engine = CrueEngine::new();
933        engine.load_rules(RuleRegistry::empty());
934        engine.register_compiled_rule_source(source).unwrap();
935
936        let request = EvaluationRequest {
937            request_id: "req".to_string(),
938            agent_id: "A".to_string(),
939            agent_org: "O".to_string(),
940            agent_level: "L".to_string(),
941            mission_id: None,
942            mission_type: None,
943            query_type: None,
944            justification: Some("sufficient".to_string()),
945            export_format: None,
946            result_limit: None,
947            requests_last_hour: 70,
948            requests_last_24h: 100,
949            results_last_query: 1,
950            account_department: None,
951            allowed_departments: vec![],
952            request_hour: 8,
953            is_within_mission_hours: true,
954        };
955        let signer = pqcrypto::hybrid::HybridSigner::new(pqcrypto::DilithiumLevel::Dilithium2);
956        let keypair = signer.generate_keypair().unwrap();
957        let public_key = keypair.public_key();
958        let (result, envelope) = engine.evaluate_with_signed_proof_v1_hybrid(
959            &request,
960            "proof-key-v1-pq",
961            &signer,
962            &keypair,
963        );
964        assert_eq!(result.decision, Decision::Block);
965        let envelope = envelope.expect("compiled path should produce v1 hybrid envelope");
966        assert_eq!(envelope.decision().unwrap(), Decision::Block);
967        assert!(envelope.verify_hybrid(&public_key).unwrap());
968        assert!(!envelope.canonical_bytes().unwrap().is_empty());
969    }
970
971    #[test]
972    fn test_compile_action_program_warn_soc() {
973        let program = compile_action_program(&[
974            RuleEffect::Warn {
975                code: "WARN_1".to_string(),
976            },
977            RuleEffect::AlertSoc,
978        ]);
979        let result = ActionVm::execute(&program).unwrap();
980        assert_eq!(result.decision, Decision::Warn);
981        assert_eq!(result.error_code.as_deref(), Some("WARN_1"));
982        assert!(result.alert_soc);
983    }
984
985    #[test]
986    fn test_compiled_rule_prefers_dsl_emitted_action_program() {
987        let source = r#"
988RULE CRUE_904 VERSION 1.0
989WHEN
990    agent.requests_last_hour >= 1
991THEN
992    BLOCK WITH CODE "MANUAL_REVIEW"
993"#;
994        let rule = CompiledPolicyRule::from_source(source).unwrap();
995        assert!(!rule.bytecode.action_instructions.is_empty());
996        let result = ActionVm::execute(&rule.action_program).unwrap();
997        assert_eq!(result.decision, Decision::Block);
998        assert_eq!(result.error_code.as_deref(), Some("MANUAL_REVIEW"));
999    }
1000}