Skip to main content

appcore_core/
decision.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: decision.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/05/29 20:47:35 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/06/04 11:51:28 by dnettoRaw
8//      ###########      S: 0.6.0
9// =============================================================================
10
11//! Decision contracts, name catalog, and execution engine.
12
13use std::fmt;
14
15use crate::context::RuntimeContext;
16use crate::envelope::CommandEnvelope;
17use crate::error::RuntimeResult;
18use crate::registry::NameRegistry;
19
20/// Decision result produced by a node.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum DecisionOutcome {
23    /// Continue evaluation or allow execution.
24    Allow,
25    /// Reject execution with a controlled reason.
26    Deny(String),
27    /// Defer execution with a controlled reason.
28    Defer(String),
29}
30
31/// Policy decision node contract.
32pub trait DecisionNode: Send + Sync {
33    /// Returns the stable policy node name.
34    fn name(&self) -> &str;
35    /// Evaluates one command without mutating Runtime infrastructure.
36    fn decide(
37        &self,
38        command: &CommandEnvelope,
39        context: &dyn RuntimeContext,
40    ) -> RuntimeResult<DecisionOutcome>;
41}
42
43/// Ordered catalog of declared policy node names.
44#[derive(Debug, Default)]
45pub struct DecisionRegistry {
46    names: NameRegistry<String>,
47}
48
49impl DecisionRegistry {
50    /// Creates an empty decision registry.
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    /// Registers the name exposed by a decision node.
56    pub fn register<N>(&mut self, node: &N) -> RuntimeResult<()>
57    where
58        N: DecisionNode,
59    {
60        self.register_name(node.name())
61    }
62
63    /// Registers a decision node name directly.
64    pub fn register_name(&mut self, name: &str) -> RuntimeResult<()> {
65        self.names.register(name.to_string(), "decision")
66    }
67
68    /// Reports whether a decision node name is registered.
69    pub fn contains(&self, name: &str) -> bool {
70        self.names.list().iter().any(|item| item == name)
71    }
72
73    /// Returns the number of registered node names.
74    pub fn len(&self) -> usize {
75        self.names.len()
76    }
77
78    /// Reports whether no node names are registered.
79    pub fn is_empty(&self) -> bool {
80        self.names.is_empty()
81    }
82
83    /// Returns node names in registration order.
84    pub fn list(&self) -> &[String] {
85        self.names.list()
86    }
87}
88
89/// Sequential policy engine that short-circuits on deny or defer.
90#[derive(Default)]
91pub struct DecisionEngine {
92    nodes: Vec<Box<dyn DecisionNode + Send + Sync>>,
93    node_names: Vec<String>,
94}
95
96impl fmt::Debug for DecisionEngine {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.debug_struct("DecisionEngine")
99            .field("node_count", &self.nodes.len())
100            .field("node_names", &self.node_names)
101            .finish()
102    }
103}
104
105impl DecisionEngine {
106    /// Creates an empty decision engine that allows by default.
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Appends one decision node.
112    pub fn register_node<N: DecisionNode + 'static>(&mut self, node: N) -> RuntimeResult<()> {
113        self.node_names.push(node.name().to_string());
114        self.nodes.push(Box::new(node));
115        Ok(())
116    }
117
118    /// Returns the number of decision nodes.
119    pub fn len(&self) -> usize {
120        self.nodes.len()
121    }
122
123    /// Reports whether no decision nodes are configured.
124    pub fn is_empty(&self) -> bool {
125        self.nodes.is_empty()
126    }
127
128    /// Returns decision node names in evaluation order.
129    pub fn node_names(&self) -> &[String] {
130        &self.node_names
131    }
132
133    /// Evaluates nodes in order and returns the first non-allow outcome.
134    pub fn evaluate(
135        &self,
136        command: &CommandEnvelope,
137        context: &dyn RuntimeContext,
138    ) -> RuntimeResult<DecisionOutcome> {
139        if self.nodes.is_empty() {
140            return Ok(DecisionOutcome::Allow);
141        }
142
143        for node in &self.nodes {
144            match node.decide(command, context)? {
145                DecisionOutcome::Allow => {}
146                DecisionOutcome::Deny(message) => return Ok(DecisionOutcome::Deny(message)),
147                DecisionOutcome::Defer(message) => return Ok(DecisionOutcome::Defer(message)),
148            }
149        }
150
151        Ok(DecisionOutcome::Allow)
152    }
153}
154
155#[cfg(test)]
156#[path = "decision_tests.rs"]
157mod tests;