Skip to main content

termivar_scanner/
decision_runner.rs

1//! Runner boundary for executing deterministic decision-loop commands.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** default via `scanning`.
6//! - **Execution:** Surface B (deterministic decision runtime).
7//! - **Default `termivar scan`:** yes, through `StandardWebDecisionRuntime`.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! The decision loop chooses an action; this module resolves its executor,
13//! honors scheduler delays, records native evidence, and submits the resulting
14//! snapshot to the correct verifier. Executors never receive the knowledge
15//! base or decision policy, so plugins cannot bypass provenance checks or
16//! mutate reasoning state directly.
17
18use std::{
19    collections::{BTreeMap, BTreeSet},
20    sync::Arc,
21    time::Duration,
22};
23
24use async_trait::async_trait;
25use serde::{Deserialize, Serialize};
26use termivar_core::{EntityId, Evidence};
27use thiserror::Error;
28
29use crate::decision_loop::{
30    command_requiring_host_policy_context, execution_command_action_id, ActiveEvidenceSnapshots,
31};
32use crate::planner::ActionSuppressionContext;
33use crate::{
34    DecisionActionOrigin, DecisionLoop, DecisionLoopCommand, DecisionLoopError, DecisionLoopState,
35    DecisionOutcomeReport, DecisionPlanningReport, DecisionReasoningCommitReceipt, DecisionSession,
36    ExperienceStore, KnowledgeBase, KnowledgeBaseError, KnowledgeSnapshot, KnowledgeWrite,
37    PayloadStrategyRef, RuntimeLimitExceeded, VerificationCase,
38};
39
40mod execution;
41mod failures;
42mod receipts;
43mod registry;
44
45pub use execution::{
46    DecisionActionExecutor, DecisionExecutionClass, DecisionExecutionLimits,
47    DecisionExecutionRequest, DecisionExecutionStage,
48};
49pub use failures::{DecisionExecutionFailureKind, DecisionExecutorError, DecisionRunnerError};
50pub use receipts::{DecisionEvidenceReceipt, DecisionExecutionFailureReceipt, DecisionRunnerTurn};
51pub use registry::DecisionExecutorRegistry;
52
53struct ExecutionAuthority<'a> {
54    limits: DecisionExecutionLimits,
55    suppressions: &'a ActionSuppressionContext,
56}
57
58impl<'a> ExecutionAuthority<'a> {
59    const fn new(
60        limits: DecisionExecutionLimits,
61        suppressions: &'a ActionSuppressionContext,
62    ) -> Self {
63        Self {
64            limits,
65            suppressions,
66        }
67    }
68}
69
70pub(crate) struct ContinuationAuthority<'a> {
71    evidence: DecisionEvidenceReceipt,
72    suppressions: &'a ActionSuppressionContext,
73}
74
75impl<'a> ContinuationAuthority<'a> {
76    pub(crate) const fn new(
77        evidence: DecisionEvidenceReceipt,
78        suppressions: &'a ActionSuppressionContext,
79    ) -> Self {
80        Self {
81            evidence,
82            suppressions,
83        }
84    }
85}
86
87/// Executes decision commands without moving policy into the runner.
88pub struct DecisionRunnerAdapter {
89    executors: DecisionExecutorRegistry,
90}
91
92impl DecisionRunnerAdapter {
93    /// Creates an adapter backed by the supplied executor registry.
94    pub fn new(executors: DecisionExecutorRegistry) -> Self {
95        Self { executors }
96    }
97
98    /// Returns the configured executor registry.
99    pub fn executors(&self) -> &DecisionExecutorRegistry {
100        &self.executors
101    }
102
103    /// Resolves the execution class of the executor that would run this command,
104    /// using the same registry route authority as execution. Lets a host decide
105    /// which resource-accounting boundary to apply before it reserves resources.
106    pub fn execution_class_for_command(
107        &self,
108        command: &DecisionLoopCommand,
109    ) -> Result<DecisionExecutionClass, DecisionRunnerError> {
110        let (stage, action_id, requested_executor) = match command {
111            DecisionLoopCommand::ExecuteAction { case, executor, .. } => (
112                DecisionExecutionStage::Passive,
113                case.action_id(),
114                executor.as_deref(),
115            ),
116            DecisionLoopCommand::CollectActiveEvidence { case } => {
117                (DecisionExecutionStage::Active, case.action_id(), None)
118            },
119            DecisionLoopCommand::Replan => {
120                return Err(DecisionRunnerError::NonExecutionCommand { command: "replan" })
121            },
122            DecisionLoopCommand::Complete { .. } => {
123                return Err(DecisionRunnerError::NonExecutionCommand {
124                    command: "complete",
125                })
126            },
127            DecisionLoopCommand::AwaitHumanReview { .. } => {
128                return Err(DecisionRunnerError::NonExecutionCommand {
129                    command: "await_human_review",
130                })
131            },
132            DecisionLoopCommand::Halt { .. } => {
133                return Err(DecisionRunnerError::NonExecutionCommand { command: "halt" })
134            },
135        };
136        let (_, executor) = self
137            .executors
138            .resolve(stage, action_id, requested_executor)?;
139        Ok(executor.execution_class())
140    }
141
142    /// Revalidates the current host suppression authority before any executor,
143    /// delay, budget reservation, or transport work.
144    pub(crate) fn validate_command_suppression(
145        &self,
146        command: &DecisionLoopCommand,
147        suppressions: &ActionSuppressionContext,
148    ) -> Result<(), DecisionRunnerError> {
149        let Some(action_id) = execution_command_action_id(command) else {
150            return Ok(());
151        };
152        if suppressions
153            .defense_suppressed_actions()
154            .contains(action_id)
155        {
156            return Err(DecisionRunnerError::ActionSuppressedByDefense {
157                action_id: action_id.to_owned(),
158            });
159        }
160        if suppressions.policy_suppressed_actions().contains(action_id) {
161            return Err(DecisionRunnerError::ActionSuppressedByHostPolicy {
162                action_id: action_id.to_owned(),
163            });
164        }
165        Ok(())
166    }
167
168    /// Moves a newly defense-suppressed outstanding command back to the
169    /// planning boundary without executing it.
170    ///
171    /// This is intentionally distinct from [`Self::validate_command_suppression`]:
172    /// the validator exposes the typed denial for low-level hosts, while the
173    /// composed runner consumes that denial as a safe continuation rather than
174    /// misreporting it as an execution failure.
175    pub(crate) fn replan_defense_suppressed_command(
176        &self,
177        command: &DecisionLoopCommand,
178        session: &mut DecisionSession,
179        suppressions: &ActionSuppressionContext,
180    ) -> Result<bool, DecisionRunnerError> {
181        let Some(action_id) = execution_command_action_id(command) else {
182            return Ok(false);
183        };
184        if !suppressions
185            .defense_suppressed_actions()
186            .contains(action_id)
187        {
188            return Ok(false);
189        }
190        match command {
191            DecisionLoopCommand::ExecuteAction { case, .. } => {
192                validate_session_case(session, DecisionExecutionStage::Passive, case)?;
193            },
194            DecisionLoopCommand::CollectActiveEvidence { case } => {
195                validate_session_case(session, DecisionExecutionStage::Active, case)?;
196            },
197            DecisionLoopCommand::Replan
198            | DecisionLoopCommand::Complete { .. }
199            | DecisionLoopCommand::AwaitHumanReview { .. }
200            | DecisionLoopCommand::Halt { .. } => return Ok(false),
201        }
202        session.replan_after_defense_suppression()?;
203        Ok(true)
204    }
205
206    /// Resolves and executes one evidence-producing command.
207    ///
208    /// The complete evidence batch is validated before it is atomically
209    /// committed. Active requests capture their baseline immediately before
210    /// executor invocation.
211    pub async fn execute_command(
212        &self,
213        command: &DecisionLoopCommand,
214        knowledge: &KnowledgeBase,
215    ) -> Result<DecisionEvidenceReceipt, DecisionRunnerError> {
216        self.execute_command_with_limits(command, knowledge, DecisionExecutionLimits::default())
217            .await
218    }
219
220    /// Resolves and executes one command under a host-owned resource allowance.
221    pub async fn execute_command_with_limits(
222        &self,
223        command: &DecisionLoopCommand,
224        knowledge: &KnowledgeBase,
225        limits: DecisionExecutionLimits,
226    ) -> Result<DecisionEvidenceReceipt, DecisionRunnerError> {
227        let (case, stage, origin, delay_ms, requested_executor) = match command {
228            DecisionLoopCommand::ExecuteAction {
229                case,
230                executor,
231                origin,
232                delay_ms,
233            } => (
234                case,
235                DecisionExecutionStage::Passive,
236                Some(*origin),
237                *delay_ms,
238                executor.as_deref(),
239            ),
240            DecisionLoopCommand::CollectActiveEvidence { case } => {
241                (case, DecisionExecutionStage::Active, None, None, None)
242            },
243            DecisionLoopCommand::Replan => {
244                return Err(DecisionRunnerError::NonExecutionCommand { command: "replan" })
245            },
246            DecisionLoopCommand::Complete { .. } => {
247                return Err(DecisionRunnerError::NonExecutionCommand {
248                    command: "complete",
249                })
250            },
251            DecisionLoopCommand::AwaitHumanReview { .. } => {
252                return Err(DecisionRunnerError::NonExecutionCommand {
253                    command: "await_human_review",
254                })
255            },
256            DecisionLoopCommand::Halt { .. } => {
257                return Err(DecisionRunnerError::NonExecutionCommand { command: "halt" })
258            },
259        };
260
261        let (executor_id, executor) =
262            self.executors
263                .resolve(stage, case.action_id(), requested_executor)?;
264        if let Some(strategy) = case.payload_strategy() {
265            if !executor.supports_payload_strategy(strategy) {
266                return Err(DecisionRunnerError::UnsupportedPayloadStrategy {
267                    executor_id,
268                    strategy: strategy.clone(),
269                });
270            }
271        }
272        if let Some(delay_ms) = delay_ms.filter(|delay| *delay > 0) {
273            tokio::time::sleep(Duration::from_millis(delay_ms)).await;
274        }
275
276        let baseline = (stage == DecisionExecutionStage::Active)
277            .then(|| knowledge.snapshot_for_subject(case.subject()));
278        let request = DecisionExecutionRequest::new(case.clone(), stage, origin, delay_ms, limits);
279        // TransportBound executors observe the network and receive no reasoning
280        // state; LocalKnowledge executors derive evidence from an immutable
281        // subject-scoped snapshot and never touch the network. Either way the
282        // runner remains the sole authority that validates and commits.
283        let evidence = match executor.execution_class() {
284            DecisionExecutionClass::TransportBound => executor.execute(&request).await,
285            DecisionExecutionClass::LocalKnowledge => {
286                let snapshot = knowledge.snapshot_for_subject(case.subject());
287                executor.execute_with_snapshot(&request, &snapshot).await
288            },
289        }
290        .map_err(|source| {
291            let source = source.with_execution_context(request.clone(), executor_id.clone());
292            DecisionRunnerError::Executor {
293                executor_id: executor_id.clone(),
294                source,
295            }
296        })?;
297        validate_evidence(&evidence, case, &executor_id)?;
298        let receipt_evidence = evidence.clone();
299        let writes = knowledge.insert_evidence_batch(evidence)?;
300        let after_execution = knowledge.snapshot_for_subject(case.subject());
301
302        Ok(DecisionEvidenceReceipt {
303            case: case.clone(),
304            stage,
305            executor_id,
306            evidence: receipt_evidence,
307            writes,
308            baseline,
309            after_execution,
310        })
311    }
312
313    /// Executes a command and resumes the matching decision-loop transition.
314    ///
315    /// `ExecuteAction` submits passive evidence, `CollectActiveEvidence`
316    /// submits the captured before/after snapshots, and `Replan` invokes the
317    /// reasoner and utility planner. Terminal commands are returned unchanged.
318    /// Adaptive, retry, active, and replan continuations fail before execution;
319    /// use [`Self::drive_command_with_suppressed_actions`] to reauthorize them.
320    pub async fn drive_command(
321        &self,
322        decision_loop: &DecisionLoop,
323        command: &DecisionLoopCommand,
324        knowledge: &KnowledgeBase,
325        experience: &mut ExperienceStore,
326        session: &mut DecisionSession,
327    ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
328        self.drive_command_with_optional_suppressions(
329            decision_loop,
330            command,
331            knowledge,
332            experience,
333            session,
334            DecisionExecutionLimits::default(),
335            None,
336        )
337        .await
338    }
339
340    /// Executes and resumes a command under explicit current host policy.
341    ///
342    /// Current suppressions are checked before executor work and remain in
343    /// force through verification, adaptive authorization, and replanning.
344    pub async fn drive_command_with_suppressed_actions(
345        &self,
346        decision_loop: &DecisionLoop,
347        command: &DecisionLoopCommand,
348        knowledge: &KnowledgeBase,
349        experience: &mut ExperienceStore,
350        session: &mut DecisionSession,
351        host_suppressed_actions: &BTreeSet<String>,
352    ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
353        let suppressions = ActionSuppressionContext::policy_only(host_suppressed_actions);
354        self.drive_command_with_action_suppressions(
355            decision_loop,
356            command,
357            knowledge,
358            experience,
359            session,
360            &suppressions,
361        )
362        .await
363    }
364
365    pub(crate) async fn drive_command_with_action_suppressions(
366        &self,
367        decision_loop: &DecisionLoop,
368        command: &DecisionLoopCommand,
369        knowledge: &KnowledgeBase,
370        experience: &mut ExperienceStore,
371        session: &mut DecisionSession,
372        suppressions: &ActionSuppressionContext,
373    ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
374        self.drive_command_with_optional_suppressions(
375            decision_loop,
376            command,
377            knowledge,
378            experience,
379            session,
380            DecisionExecutionLimits::default(),
381            Some(suppressions),
382        )
383        .await
384    }
385
386    /// Drives one command under a host-owned execution allowance.
387    pub async fn drive_command_with_limits(
388        &self,
389        decision_loop: &DecisionLoop,
390        command: &DecisionLoopCommand,
391        knowledge: &KnowledgeBase,
392        experience: &mut ExperienceStore,
393        session: &mut DecisionSession,
394        limits: DecisionExecutionLimits,
395    ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
396        self.drive_command_with_optional_suppressions(
397            decision_loop,
398            command,
399            knowledge,
400            experience,
401            session,
402            limits,
403            None,
404        )
405        .await
406    }
407
408    /// Drives one command under explicit host policy and execution allowance.
409    ///
410    /// Current suppressions are checked before executor work and remain in
411    /// force through verification, adaptive authorization, and replanning.
412    #[allow(clippy::too_many_arguments)]
413    pub async fn drive_command_with_limits_and_suppressed_actions(
414        &self,
415        decision_loop: &DecisionLoop,
416        command: &DecisionLoopCommand,
417        knowledge: &KnowledgeBase,
418        experience: &mut ExperienceStore,
419        session: &mut DecisionSession,
420        limits: DecisionExecutionLimits,
421        host_suppressed_actions: &BTreeSet<String>,
422    ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
423        let suppressions = ActionSuppressionContext::policy_only(host_suppressed_actions);
424        self.drive_command_with_limits_and_action_suppressions(
425            decision_loop,
426            command,
427            knowledge,
428            experience,
429            session,
430            ExecutionAuthority::new(limits, &suppressions),
431        )
432        .await
433    }
434
435    async fn drive_command_with_limits_and_action_suppressions(
436        &self,
437        decision_loop: &DecisionLoop,
438        command: &DecisionLoopCommand,
439        knowledge: &KnowledgeBase,
440        experience: &mut ExperienceStore,
441        session: &mut DecisionSession,
442        authority: ExecutionAuthority<'_>,
443    ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
444        self.drive_command_with_optional_suppressions(
445            decision_loop,
446            command,
447            knowledge,
448            experience,
449            session,
450            authority.limits,
451            Some(authority.suppressions),
452        )
453        .await
454    }
455
456    #[allow(clippy::too_many_arguments)]
457    async fn drive_command_with_optional_suppressions(
458        &self,
459        decision_loop: &DecisionLoop,
460        command: &DecisionLoopCommand,
461        knowledge: &KnowledgeBase,
462        experience: &mut ExperienceStore,
463        session: &mut DecisionSession,
464        limits: DecisionExecutionLimits,
465        host_suppressions: Option<&ActionSuppressionContext>,
466    ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
467        if host_suppressions.is_none() {
468            if let Some(command) = command_requiring_host_policy_context(command) {
469                return Err(DecisionRunnerError::HostPolicyContextRequired { command });
470            }
471        }
472        if let Some(suppressions) = host_suppressions {
473            match self.validate_command_suppression(command, suppressions) {
474                Err(source @ DecisionRunnerError::ActionSuppressedByDefense { .. }) => {
475                    decision_loop.validate_execution_command_authority(knowledge, command)?;
476                    if !self.replan_defense_suppressed_command(command, session, suppressions)? {
477                        return Err(source);
478                    }
479                    let planning = decision_loop.plan_next_with_action_suppressions(
480                        knowledge,
481                        experience,
482                        session,
483                        suppressions,
484                    )?;
485                    return Ok(DecisionRunnerTurn::Planning(Box::new(planning)));
486                },
487                Err(source) => return Err(source),
488                Ok(()) => {},
489            }
490        }
491        decision_loop.validate_execution_command_authority(knowledge, command)?;
492        match command {
493            DecisionLoopCommand::ExecuteAction { .. }
494            | DecisionLoopCommand::CollectActiveEvidence { .. } => {
495                let evidence = self
496                    .execute_session_command_with_limits(command, knowledge, session, limits)
497                    .await?;
498                self.resume_session_command_with_optional_suppressions(
499                    decision_loop,
500                    command,
501                    knowledge,
502                    experience,
503                    session,
504                    evidence,
505                    host_suppressions,
506                )
507            },
508            DecisionLoopCommand::Replan => {
509                let planning = match host_suppressions {
510                    Some(suppressions) => decision_loop.plan_next_with_action_suppressions(
511                        knowledge,
512                        experience,
513                        session,
514                        suppressions,
515                    )?,
516                    None => decision_loop.plan_next(knowledge, experience, session)?,
517                };
518                Ok(DecisionRunnerTurn::Planning(Box::new(planning)))
519            },
520            DecisionLoopCommand::Complete { .. }
521            | DecisionLoopCommand::AwaitHumanReview { .. }
522            | DecisionLoopCommand::Halt { .. } => Ok(DecisionRunnerTurn::Terminal(command.clone())),
523        }
524    }
525
526    pub(crate) async fn execute_session_command_with_limits(
527        &self,
528        command: &DecisionLoopCommand,
529        knowledge: &KnowledgeBase,
530        session: &DecisionSession,
531        limits: DecisionExecutionLimits,
532    ) -> Result<DecisionEvidenceReceipt, DecisionRunnerError> {
533        match command {
534            DecisionLoopCommand::ExecuteAction { case, .. } => {
535                validate_session_case(session, DecisionExecutionStage::Passive, case)?;
536            },
537            DecisionLoopCommand::CollectActiveEvidence { case } => {
538                validate_session_case(session, DecisionExecutionStage::Active, case)?;
539            },
540            DecisionLoopCommand::Replan => {
541                return Err(DecisionRunnerError::NonExecutionCommand { command: "replan" });
542            },
543            DecisionLoopCommand::Complete { .. } => {
544                return Err(DecisionRunnerError::NonExecutionCommand {
545                    command: "complete",
546                });
547            },
548            DecisionLoopCommand::AwaitHumanReview { .. } => {
549                return Err(DecisionRunnerError::NonExecutionCommand {
550                    command: "await_human_review",
551                });
552            },
553            DecisionLoopCommand::Halt { .. } => {
554                return Err(DecisionRunnerError::NonExecutionCommand { command: "halt" });
555            },
556        }
557        self.execute_command_with_limits(command, knowledge, limits)
558            .await
559    }
560
561    #[cfg(test)]
562    pub(crate) fn resume_session_command(
563        &self,
564        decision_loop: &DecisionLoop,
565        command: &DecisionLoopCommand,
566        knowledge: &KnowledgeBase,
567        experience: &mut ExperienceStore,
568        session: &mut DecisionSession,
569        evidence: DecisionEvidenceReceipt,
570    ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
571        self.resume_session_command_with_optional_suppressions(
572            decision_loop,
573            command,
574            knowledge,
575            experience,
576            session,
577            evidence,
578            None,
579        )
580    }
581
582    pub(crate) fn resume_session_command_with_action_suppressions(
583        &self,
584        decision_loop: &DecisionLoop,
585        command: &DecisionLoopCommand,
586        knowledge: &KnowledgeBase,
587        experience: &mut ExperienceStore,
588        session: &mut DecisionSession,
589        continuation: ContinuationAuthority<'_>,
590    ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
591        self.resume_session_command_with_optional_suppressions(
592            decision_loop,
593            command,
594            knowledge,
595            experience,
596            session,
597            continuation.evidence,
598            Some(continuation.suppressions),
599        )
600    }
601
602    #[allow(clippy::too_many_arguments)]
603    fn resume_session_command_with_optional_suppressions(
604        &self,
605        decision_loop: &DecisionLoop,
606        command: &DecisionLoopCommand,
607        knowledge: &KnowledgeBase,
608        experience: &mut ExperienceStore,
609        session: &mut DecisionSession,
610        evidence: DecisionEvidenceReceipt,
611        host_suppressions: Option<&ActionSuppressionContext>,
612    ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
613        let decision = (|| -> Result<Box<DecisionOutcomeReport>, DecisionRunnerError> {
614            match command {
615                DecisionLoopCommand::ExecuteAction { case, .. } => {
616                    validate_session_case(session, DecisionExecutionStage::Passive, case)?;
617                    let report = match host_suppressions {
618                        Some(suppressions) => decision_loop
619                            .submit_passive_with_action_suppressions(
620                                knowledge,
621                                experience,
622                                session,
623                                suppressions,
624                            ),
625                        None => decision_loop.submit_passive(knowledge, experience, session),
626                    }?;
627                    Ok(Box::new(report))
628                },
629                DecisionLoopCommand::CollectActiveEvidence { case } => {
630                    validate_session_case(session, DecisionExecutionStage::Active, case)?;
631                    let baseline = evidence
632                        .baseline()
633                        .ok_or(DecisionRunnerError::MissingActiveBaseline)?;
634                    let report = match host_suppressions {
635                        Some(suppressions) => decision_loop.submit_active_with_action_suppressions(
636                            knowledge,
637                            experience,
638                            session,
639                            ActiveEvidenceSnapshots::new(baseline, evidence.after_execution()),
640                            suppressions,
641                        ),
642                        None => decision_loop.submit_active(
643                            knowledge,
644                            experience,
645                            session,
646                            baseline,
647                            evidence.after_execution(),
648                        ),
649                    }?;
650                    Ok(Box::new(report))
651                },
652                DecisionLoopCommand::Replan => {
653                    Err(DecisionRunnerError::NonExecutionCommand { command: "replan" })
654                },
655                DecisionLoopCommand::Complete { .. } => {
656                    Err(DecisionRunnerError::NonExecutionCommand {
657                        command: "complete",
658                    })
659                },
660                DecisionLoopCommand::AwaitHumanReview { .. } => {
661                    Err(DecisionRunnerError::NonExecutionCommand {
662                        command: "await_human_review",
663                    })
664                },
665                DecisionLoopCommand::Halt { .. } => {
666                    Err(DecisionRunnerError::NonExecutionCommand { command: "halt" })
667                },
668            }
669        })();
670
671        match decision {
672            Ok(decision) => Ok(DecisionRunnerTurn::Outcome {
673                evidence: Box::new(evidence),
674                decision,
675            }),
676            Err(source) => Err(DecisionRunnerError::OutcomeAfterEvidenceCommit {
677                receipt: Box::new(evidence),
678                source: Box::new(source),
679            }),
680        }
681    }
682}
683
684fn validate_session_case(
685    session: &DecisionSession,
686    stage: DecisionExecutionStage,
687    command_case: &VerificationCase,
688) -> Result<(), DecisionRunnerError> {
689    let outstanding = match (stage, session.state()) {
690        (DecisionExecutionStage::Passive, DecisionLoopState::AwaitingPassive { case })
691        | (DecisionExecutionStage::Active, DecisionLoopState::AwaitingActive { case }) => case,
692        (_, state) => {
693            return Err(DecisionRunnerError::UnexpectedSessionState {
694                expected: stage,
695                actual: session_state_name(state),
696            })
697        },
698    };
699    if outstanding != command_case {
700        return Err(DecisionRunnerError::CommandCaseMismatch {
701            expected: outstanding.id().to_owned(),
702            actual: command_case.id().to_owned(),
703        });
704    }
705    Ok(())
706}
707
708fn session_state_name(state: &DecisionLoopState) -> &'static str {
709    match state {
710        DecisionLoopState::Ready => "ready",
711        DecisionLoopState::AwaitingPassive { .. } => "awaiting_passive",
712        DecisionLoopState::AwaitingActive { .. } => "awaiting_active",
713        DecisionLoopState::Completed => "completed",
714        DecisionLoopState::Halted { .. } => "halted",
715    }
716}
717
718fn validate_evidence(
719    evidence: &[Evidence],
720    case: &VerificationCase,
721    executor_id: &str,
722) -> Result<(), DecisionRunnerError> {
723    for observation in evidence {
724        if observation.subject() != case.subject() {
725            return Err(DecisionRunnerError::EvidenceSubjectMismatch {
726                evidence_id: observation.id().to_string(),
727                expected: case.subject().clone(),
728                actual: observation.subject().clone(),
729            });
730        }
731        if observation.source().component() != executor_id {
732            return Err(DecisionRunnerError::EvidenceSourceMismatch {
733                evidence_id: observation.id().to_string(),
734                expected: executor_id.to_owned(),
735                actual: observation.source().component().to_owned(),
736            });
737        }
738        if observation.source().correlation_id() != Some(case.id()) {
739            return Err(DecisionRunnerError::EvidenceCorrelationMismatch {
740                evidence_id: observation.id().to_string(),
741                expected: case.id().to_owned(),
742                actual: observation.source().correlation_id().map(str::to_owned),
743            });
744        }
745    }
746    Ok(())
747}
748
749/// Host policy that creates one capability-bound plugin invocation.
750///
751/// The provider receives the complete immutable decision request so it can bind
752/// the plugin request to the exact evidence subject and verification case while
753/// selecting host-owned origin, broker, input, budget, cancellation, redaction,
754/// and reliability policy.
755#[cfg(feature = "plugins")]
756pub trait PluginExecutionRequestProvider: Send + Sync {
757    /// Produces a host-owned plugin request without performing plugin work.
758    fn request_for(
759        &self,
760        request: &DecisionExecutionRequest,
761    ) -> Result<crate::PluginExecutionRequest, DecisionExecutorError>;
762}
763
764#[cfg(feature = "plugins")]
765impl<F> PluginExecutionRequestProvider for F
766where
767    F: Fn(
768            &DecisionExecutionRequest,
769        ) -> Result<crate::PluginExecutionRequest, DecisionExecutorError>
770        + Send
771        + Sync,
772{
773    fn request_for(
774        &self,
775        request: &DecisionExecutionRequest,
776    ) -> Result<crate::PluginExecutionRequest, DecisionExecutorError> {
777        self(request)
778    }
779}
780
781/// Bridge from the source-level [`crate::PluginRegistry`] to native evidence.
782///
783/// The request provider remains host-owned because an action ID is neither an
784/// authorization grant nor plugin input. The registry returns only recorder-
785/// owned evidence, and the regular adapter provenance checks still apply before
786/// any knowledge write. A successful plugin invocation is not an outcome or a
787/// finding.
788#[cfg(feature = "plugins")]
789pub struct PluginDecisionExecutor {
790    registry: Arc<crate::PluginRegistry>,
791    plugin_id: String,
792    requests: Arc<dyn PluginExecutionRequestProvider>,
793}
794
795#[cfg(feature = "plugins")]
796impl PluginDecisionExecutor {
797    /// Creates a bridge for one registered plugin identity.
798    pub fn new(
799        registry: Arc<crate::PluginRegistry>,
800        plugin_id: impl Into<String>,
801        requests: Arc<dyn PluginExecutionRequestProvider>,
802    ) -> Result<Self, DecisionExecutorError> {
803        let plugin_id = plugin_id.into();
804        if plugin_id.trim().is_empty() {
805            return Err(DecisionExecutorError::new("plugin id must not be empty"));
806        }
807        Ok(Self {
808            registry,
809            plugin_id,
810            requests,
811        })
812    }
813}
814
815#[cfg(feature = "plugins")]
816#[async_trait]
817impl DecisionActionExecutor for PluginDecisionExecutor {
818    fn id(&self) -> &str {
819        &self.plugin_id
820    }
821
822    async fn execute(
823        &self,
824        request: &DecisionExecutionRequest,
825    ) -> Result<Vec<Evidence>, DecisionExecutorError> {
826        let mut plugin_request = self.requests.request_for(request)?;
827        if plugin_request.subject() != request.case().subject() {
828            return Err(DecisionExecutorError::with_kind(
829                DecisionExecutionFailureKind::BlockedByPolicy,
830                "plugin request subject does not match the decision case",
831            ));
832        }
833        if plugin_request.case_id() != request.case().id() {
834            return Err(DecisionExecutorError::with_kind(
835                DecisionExecutionFailureKind::BlockedByPolicy,
836                "plugin request correlation does not match the decision case",
837            ));
838        }
839        if let Some(maximum) = request.limits().max_response_body_bytes() {
840            plugin_request = plugin_request.restrict_response_body_bytes(maximum);
841        }
842        self.registry
843            .execute(&self.plugin_id, plugin_request)
844            .await
845            .map(crate::PluginExecutionResult::into_observations)
846            .map_err(plugin_executor_error)
847    }
848}
849
850#[cfg(feature = "plugins")]
851fn plugin_executor_error(error: crate::PluginError) -> DecisionExecutorError {
852    use crate::PluginError;
853    let kind = match &error {
854        PluginError::Disabled
855        | PluginError::Cancelled
856        | PluginError::InputBudgetExceeded { .. }
857        | PluginError::RequestBudgetExceeded
858        | PluginError::ResponseBodyBudgetExceeded { .. }
859        | PluginError::ResponseBodyBudgetUnavailable
860        | PluginError::CumulativeBodyBudgetExceeded
861        | PluginError::ObservationBudgetExceeded
862        | PluginError::ObservationBytesBudgetExceeded
863        | PluginError::ScopeViolation
864        | PluginError::ContextSealed => DecisionExecutionFailureKind::BlockedByPolicy,
865        PluginError::BrokerFailure(_) => DecisionExecutionFailureKind::TransportFailure,
866        PluginError::RequestTimeout | PluginError::WallTimeExceeded => {
867            DecisionExecutionFailureKind::RequestTimeout
868        },
869        _ => DecisionExecutionFailureKind::ExecutorFailure,
870    };
871    DecisionExecutorError::with_kind(kind, error.to_string())
872}
873
874#[cfg(test)]
875#[path = "decision_runner/decision_runner_tests.rs"]
876mod tests;