Skip to main content

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}