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/07/23 23:50:45 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
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::{RuntimeError, RuntimeResult};
18use crate::registry::NameRegistry;
19
20/// Maximum decision nodes or declared decision names in one owner.
21pub const MAX_DECISION_NODES: usize = 4096;
22/// Maximum UTF-8 bytes in one decision name.
23pub const MAX_DECISION_NAME_BYTES: usize = 256;
24
25/// Decision result produced by a node.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum DecisionOutcome {
28    /// Continue evaluation or allow execution.
29    Allow,
30    /// Reject execution with a controlled reason.
31    Deny(String),
32    /// Defer execution with a controlled reason.
33    Defer(String),
34}
35
36/// Policy decision node contract.
37pub trait DecisionNode: Send + Sync {
38    /// Returns the stable policy node name.
39    fn name(&self) -> &str;
40    /// Evaluates one command without mutating Runtime infrastructure.
41    fn decide(
42        &self,
43        command: &CommandEnvelope,
44        context: &dyn RuntimeContext,
45    ) -> RuntimeResult<DecisionOutcome>;
46}
47
48/// Ordered catalog of declared policy node names.
49#[derive(Debug, Default)]
50pub struct DecisionRegistry {
51    names: NameRegistry<String>,
52}
53
54impl DecisionRegistry {
55    /// Creates an empty decision registry.
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Registers the name exposed by a decision node.
61    pub fn register<N>(&mut self, node: &N) -> RuntimeResult<()>
62    where
63        N: DecisionNode,
64    {
65        self.register_name(node.name())
66    }
67
68    /// Registers a decision node name directly.
69    pub fn register_name(&mut self, name: &str) -> RuntimeResult<()> {
70        validate_registration(name, self.names.list())?;
71        self.names.register(name.to_string(), "decision")
72    }
73
74    /// Reports whether a decision node name is registered.
75    pub fn contains(&self, name: &str) -> bool {
76        self.names.list().iter().any(|item| item == name)
77    }
78
79    /// Returns the number of registered node names.
80    pub fn len(&self) -> usize {
81        self.names.len()
82    }
83
84    /// Reports whether no node names are registered.
85    pub fn is_empty(&self) -> bool {
86        self.names.is_empty()
87    }
88
89    /// Returns node names in registration order.
90    pub fn list(&self) -> &[String] {
91        self.names.list()
92    }
93}
94
95/// Sequential policy engine that short-circuits on deny or defer.
96#[derive(Default)]
97pub struct DecisionEngine {
98    nodes: Vec<Box<dyn DecisionNode + Send + Sync>>,
99    node_names: Vec<String>,
100}
101
102impl fmt::Debug for DecisionEngine {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.debug_struct("DecisionEngine")
105            .field("node_count", &self.nodes.len())
106            .field("node_names", &self.node_names)
107            .finish()
108    }
109}
110
111impl DecisionEngine {
112    /// Creates an empty decision engine that allows by default.
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Appends one uniquely named node within the count and name-byte limits.
118    /// Rejection leaves evaluation order and existing nodes unchanged.
119    pub fn register_node<N: DecisionNode + 'static>(&mut self, node: N) -> RuntimeResult<()> {
120        let name = node.name();
121        validate_registration(name, &self.node_names)?;
122        self.node_names.push(name.to_string());
123        self.nodes.push(Box::new(node));
124        Ok(())
125    }
126
127    /// Returns the number of decision nodes.
128    pub fn len(&self) -> usize {
129        self.nodes.len()
130    }
131
132    /// Reports whether no decision nodes are configured.
133    pub fn is_empty(&self) -> bool {
134        self.nodes.is_empty()
135    }
136
137    /// Returns decision node names in evaluation order.
138    pub fn node_names(&self) -> &[String] {
139        &self.node_names
140    }
141
142    /// Evaluates nodes in order and returns the first non-allow outcome.
143    pub fn evaluate(
144        &self,
145        command: &CommandEnvelope,
146        context: &dyn RuntimeContext,
147    ) -> RuntimeResult<DecisionOutcome> {
148        if self.nodes.is_empty() {
149            return Ok(DecisionOutcome::Allow);
150        }
151
152        for node in &self.nodes {
153            match node.decide(command, context)? {
154                DecisionOutcome::Allow => {}
155                DecisionOutcome::Deny(message) => return Ok(DecisionOutcome::Deny(message)),
156                DecisionOutcome::Defer(message) => return Ok(DecisionOutcome::Defer(message)),
157            }
158        }
159
160        Ok(DecisionOutcome::Allow)
161    }
162}
163
164fn validate_registration(name: &str, existing: &[String]) -> RuntimeResult<()> {
165    if name.is_empty() || name.len() > MAX_DECISION_NAME_BYTES {
166        return Err(RuntimeError::InvalidRequest {
167            kind: "decision",
168            reason: "decision name must contain 1..=256 UTF-8 bytes",
169        });
170    }
171    if existing.iter().any(|registered| registered == name) {
172        return Err(RuntimeError::DuplicateRegistryItem {
173            kind: "decision".into(),
174        });
175    }
176    if existing.len() >= MAX_DECISION_NODES {
177        return Err(RuntimeError::InvalidRequest {
178            kind: "decision",
179            reason: "decision registration capacity exceeded",
180        });
181    }
182    Ok(())
183}
184
185#[cfg(test)]
186#[path = "decision_tests.rs"]
187mod tests;