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