termivar_scanner/decision_runner/execution.rs
1//! Capability-bound executor request and trait contracts.
2
3use super::{
4 async_trait, DecisionActionOrigin, DecisionExecutorError, Deserialize, Evidence,
5 KnowledgeSnapshot, PayloadStrategyRef, Serialize, VerificationCase,
6};
7
8/// Verification stage whose evidence an executor must collect.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11#[non_exhaustive]
12pub enum DecisionExecutionStage {
13 /// Evidence collected by the action selected by planning or adaptation.
14 Passive,
15 /// Fresh evidence collected by an explicit verification probe.
16 Active,
17}
18
19impl std::fmt::Display for DecisionExecutionStage {
20 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 formatter.write_str(match self {
22 Self::Passive => "passive",
23 Self::Active => "active",
24 })
25 }
26}
27
28/// Host-owned resource allowance attached to one isolated execution.
29///
30/// Executors may impose stricter policy limits. The allowance can only reduce
31/// resource use; it never expands an executor's own security policy.
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
33pub struct DecisionExecutionLimits {
34 max_response_body_bytes: Option<u64>,
35}
36
37impl DecisionExecutionLimits {
38 /// Creates an unrestricted per-execution allowance.
39 pub const fn new() -> Self {
40 Self {
41 max_response_body_bytes: None,
42 }
43 }
44
45 /// Restricts the response body buffered by this execution.
46 pub const fn with_max_response_body_bytes(mut self, limit: u64) -> Self {
47 self.max_response_body_bytes = Some(limit);
48 self
49 }
50
51 /// Returns the optional host-owned response buffer allowance.
52 pub const fn max_response_body_bytes(self) -> Option<u64> {
53 self.max_response_body_bytes
54 }
55
56 fn is_unrestricted(&self) -> bool {
57 self.max_response_body_bytes.is_none()
58 }
59}
60
61/// Immutable, transport-neutral request passed to one action executor.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub struct DecisionExecutionRequest {
64 case: VerificationCase,
65 stage: DecisionExecutionStage,
66 origin: Option<DecisionActionOrigin>,
67 delay_ms: Option<u64>,
68 #[serde(skip_serializing_if = "DecisionExecutionLimits::is_unrestricted")]
69 limits: DecisionExecutionLimits,
70}
71
72impl DecisionExecutionRequest {
73 pub(super) fn new(
74 case: VerificationCase,
75 stage: DecisionExecutionStage,
76 origin: Option<DecisionActionOrigin>,
77 delay_ms: Option<u64>,
78 limits: DecisionExecutionLimits,
79 ) -> Self {
80 Self {
81 case,
82 stage,
83 origin,
84 delay_ms,
85 limits,
86 }
87 }
88
89 /// Returns the verification identity attached by the decision loop.
90 pub fn case(&self) -> &VerificationCase {
91 &self.case
92 }
93
94 /// Returns whether passive or active evidence is requested.
95 pub fn stage(&self) -> DecisionExecutionStage {
96 self.stage
97 }
98
99 /// Returns the source of a passive action request.
100 pub fn origin(&self) -> Option<DecisionActionOrigin> {
101 self.origin
102 }
103
104 /// Returns the scheduler delay already honored by the adapter.
105 pub fn delay_ms(&self) -> Option<u64> {
106 self.delay_ms
107 }
108
109 /// Returns host-owned resource allowances for this execution.
110 pub const fn limits(&self) -> DecisionExecutionLimits {
111 self.limits
112 }
113
114 /// Returns the exact planner-selected strategy revision, when present.
115 pub const fn payload_strategy(&self) -> Option<&PayloadStrategyRef> {
116 self.case.payload_strategy()
117 }
118}
119
120/// How a semantic action is executed: by touching the network, or purely from
121/// already-committed immutable knowledge.
122///
123/// The runtime uses this to decide whether transport accounting and HTTP
124/// response telemetry apply. It is declared explicitly by each executor and is
125/// never inferred from executor IDs, action names, request methods, or whether a
126/// broker happened to dispatch.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129#[non_exhaustive]
130pub enum DecisionExecutionClass {
131 /// The executor performs transport I/O (an HTTP probe) to observe evidence.
132 TransportBound,
133 /// The executor performs no transport I/O and derives evidence solely from
134 /// an immutable, subject-scoped knowledge snapshot.
135 LocalKnowledge,
136}
137
138/// Narrow execution API implemented by native collectors and plugin bridges.
139///
140/// The contract for what an executor may read is deliberately minimal:
141///
142/// - A [`DecisionExecutionClass::TransportBound`] executor (the default) runs
143/// through [`execute`](Self::execute) and receives **no** reasoning state — no
144/// `KnowledgeBase`, no snapshot, no decision policy.
145/// - A [`DecisionExecutionClass::LocalKnowledge`] executor runs through
146/// [`execute_with_snapshot`](Self::execute_with_snapshot) and may read **only**
147/// an immutable, subject-scoped [`KnowledgeSnapshot`]. It never receives a
148/// mutable `KnowledgeBase`: the runner remains the sole authority that
149/// validates provenance and atomically commits any derived evidence.
150#[async_trait]
151pub trait DecisionActionExecutor: Send + Sync {
152 /// Returns the stable identity used by planner executor fields and routes.
153 fn id(&self) -> &str;
154
155 /// Returns how this executor is driven. Defaults to
156 /// [`DecisionExecutionClass::TransportBound`] so existing executors keep
157 /// their current transport-accounted execution path unchanged.
158 fn execution_class(&self) -> DecisionExecutionClass {
159 DecisionExecutionClass::TransportBound
160 }
161
162 /// Returns whether this executor can materialize an exact strategy revision.
163 ///
164 /// The fail-closed default prevents a legacy executor from silently
165 /// ignoring planner-selected strategy semantics.
166 fn supports_payload_strategy(&self, _strategy: &PayloadStrategyRef) -> bool {
167 false
168 }
169
170 /// Executes one semantic action request and returns immutable observations only.
171 ///
172 /// Every returned observation must describe `request.case().subject()`,
173 /// identify this executor as its source component, and carry the case ID as
174 /// its source correlation ID. The adapter rejects the complete batch when
175 /// any observation violates that contract.
176 async fn execute(
177 &self,
178 request: &DecisionExecutionRequest,
179 ) -> Result<Vec<Evidence>, DecisionExecutorError>;
180
181 /// Executes a [`DecisionExecutionClass::LocalKnowledge`] action from an
182 /// immutable subject-scoped snapshot, returning immutable observations under
183 /// the same provenance contract as [`execute`](Self::execute).
184 ///
185 /// This is additive: transport-bound executors never receive this call, so
186 /// they need not implement it. It is deliberately **fail-closed** — the
187 /// default returns a deterministic error rather than delegating to
188 /// [`execute`](Self::execute). An executor that declares
189 /// [`DecisionExecutionClass::LocalKnowledge`] but forgets to override this
190 /// method therefore cannot silently run transport work while the runtime has
191 /// already skipped request preflight, response accounting, and HTTP
192 /// telemetry validation for it.
193 async fn execute_with_snapshot(
194 &self,
195 request: &DecisionExecutionRequest,
196 snapshot: &KnowledgeSnapshot,
197 ) -> Result<Vec<Evidence>, DecisionExecutorError> {
198 let _ = (request, snapshot);
199 Err(DecisionExecutorError::new(
200 "local-knowledge executor did not implement snapshot execution",
201 ))
202 }
203}