Skip to main content

agent_sdk_core/application/
isolation.rs

1//! Isolation lifecycle coordination over host-provided runtimes. Use this module when
2//! a workload requires environment preparation, process execution, signaling, stats,
3//! or cleanup. All concrete container or VM behavior remains adapter-owned.
4//!
5use std::sync::Arc;
6
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    domain::{
11        AgentError, AgentErrorKind, AgentId, PrivacyClass, RetryClassification, RunId, SourceRef,
12    },
13    effect::{EffectResult, EffectTerminalStatus},
14    journal::{
15        JournalRecord, JournalRecordBase, JournalRecordKind, JournalRecordPayload,
16        PendingSideEffect, RecoveryMarker,
17    },
18    journal_ports::RunJournal,
19    package::{RuntimePackage, RuntimePackageFingerprint},
20    package_isolation::{
21        CleanupPlanRef, ExecutionEnvironment, IsolatedProcessSpec, IsolationCapability,
22        IsolationClass, IsolationFallback, IsolationRuntimeRef, IsolationTrustField,
23        PolicyDecisionRef,
24    },
25    ports_isolation::{
26        CleanupRequest, CleanupResult, CleanupStatus, IsolationCapabilityReport, IsolationRuntime,
27        IsolationRuntimeHealth, IsolationRuntimeRegistry, ProcessStartRequest, ProcessStartResult,
28    },
29    records_isolation::{
30        IsolationCapabilityMatchRecord, IsolationCleanupIntentRecord, IsolationCleanupResultRecord,
31        IsolationDowngradeDecisionRecord, IsolationProcessStartIntentRecord,
32        IsolationProcessStartResultRecord, IsolationRecord,
33    },
34};
35
36#[derive(Clone)]
37/// Holds isolation lifecycle coordinator application-layer state or configuration.
38/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
39pub struct IsolationLifecycleCoordinator {
40    journal: Arc<dyn RunJournal>,
41    registry: IsolationRuntimeRegistry,
42}
43
44impl IsolationLifecycleCoordinator {
45    /// Creates a new application::isolation value with explicit
46    /// caller-provided inputs. This constructor is data-only and
47    /// performs no I/O or external side effects.
48    pub fn new(journal: Arc<dyn RunJournal>, registry: IsolationRuntimeRegistry) -> Self {
49        Self { journal, registry }
50    }
51
52    /// Selects an isolation runtime for a package environment.
53    /// This validates package-sidecar requirements and reads the runtime registry capability
54    /// report; it does not start a process or append journal evidence.
55    pub fn select_environment(
56        &self,
57        package: &RuntimePackage,
58        environment: &ExecutionEnvironment,
59        context: IsolationLifecycleContext,
60        approval: Option<IsolationDowngradeApproval>,
61    ) -> Result<IsolationSelectionOutcome, AgentError> {
62        self.validate_package_sidecar(package, environment)?;
63        let (runtime, report) = self.selected_runtime(environment)?;
64        Ok(evaluate_report(
65            environment,
66            &context,
67            runtime.runtime_ref(),
68            report,
69            approval,
70        ))
71    }
72
73    /// Starts one isolated process after package and downgrade policy checks.
74    /// This prepares the environment, appends lifecycle evidence, and delegates
75    /// only the adapter process start to the selected isolation runtime.
76    pub fn start_process(
77        &self,
78        package: &RuntimePackage,
79        environment: ExecutionEnvironment,
80        process: IsolatedProcessSpec,
81        context: IsolationLifecycleContext,
82        approval: Option<IsolationDowngradeApproval>,
83    ) -> Result<IsolationProcessOutcome, AgentError> {
84        process.validate()?;
85        let selection =
86            self.select_environment(package, &environment, context.clone(), approval)?;
87        if !selection.status.allows_adapter_call() {
88            let status = selection.status;
89            self.append_selection_record(&selection, &environment, &context, 1)?;
90            return Ok(IsolationProcessOutcome {
91                selection,
92                status,
93                intent_record: None,
94                result_record: None,
95                io_frames: Vec::new(),
96                recovery_required: false,
97                terminal_error: None,
98            });
99        }
100        let runtime_ref = selection
101            .selected_runtime_ref
102            .clone()
103            .ok_or_else(|| missing_runtime_error("isolation selection has no runtime ref"))?;
104        let runtime = self
105            .registry
106            .get(&runtime_ref)
107            .ok_or_else(|| missing_runtime_error("selected isolation runtime disappeared"))?;
108
109        let effect_intent = process.effect_intent(&environment);
110        let intent_record =
111            IsolationRecord::ProcessStartIntent(IsolationProcessStartIntentRecord {
112                environment_id: environment.environment_id.clone(),
113                process_ref: crate::IsolatedProcessRef::new(format!(
114                    "process.ref.{}",
115                    process.process_id.as_str()
116                )),
117                effect_intent: effect_intent.clone(),
118                redacted_summary: "start isolated process with redacted argv and environment"
119                    .to_string(),
120            });
121        let intent_journal = JournalRecord::effect_intent(
122            context.record_base(1, "isolation.process.intent", &environment),
123            effect_intent.clone(),
124        );
125        self.journal
126            .append(intent_journal)
127            .map_err(journal_failure)?;
128        self.append_isolation_record(
129            &context,
130            2,
131            "isolation.process.intent",
132            &environment,
133            intent_record.clone(),
134        )?;
135        self.append_selection_record(&selection, &environment, &context, 3)?;
136
137        let start_result = runtime.start_process(ProcessStartRequest {
138            environment: environment.clone(),
139            process,
140            effect_intent: effect_intent.clone(),
141        })?;
142        let effect_result = process_start_effect_result(&effect_intent, &start_result);
143        let result_record =
144            IsolationRecord::ProcessStartResult(IsolationProcessStartResultRecord {
145                environment_id: environment.environment_id.clone(),
146                process_ref: start_result.process_ref.clone(),
147                effect_result: effect_result.clone(),
148                content_refs: effect_result.content_refs.clone(),
149                redacted_summary: start_result.redacted_summary.clone(),
150            });
151        let result_journal = JournalRecord::effect_result(
152            context.record_base(4, "isolation.process.result", &environment),
153            effect_result.clone(),
154        );
155
156        match self.journal.append(result_journal) {
157            Ok(_) => {
158                self.append_isolation_record(
159                    &context,
160                    5,
161                    "isolation.process.result.record",
162                    &environment,
163                    result_record.clone(),
164                )?;
165                Ok(IsolationProcessOutcome {
166                    status: selection.status,
167                    selection,
168                    intent_record: Some(intent_record),
169                    result_record: Some(result_record),
170                    io_frames: start_result.io_frames,
171                    recovery_required: false,
172                    terminal_error: None,
173                })
174            }
175            Err(result_error) => {
176                let recovery = RecoveryMarker {
177                    unsafe_pending: vec![PendingSideEffect {
178                        effect_id: effect_intent.effect_id.clone(),
179                        intent_record_id: "journal.isolation.process.intent".to_string(),
180                        idempotency_key: effect_intent.idempotency_key.clone(),
181                        dedupe_key: effect_intent.dedupe_key.clone(),
182                        unsafe_pending_reason:
183                            "isolated process may have started before terminal result append"
184                                .to_string(),
185                    }],
186                    recovery_reason: format!(
187                        "isolated process terminal result append failed: {}",
188                        result_error.context().message
189                    ),
190                    policy_refs: effect_intent.policy_refs.clone(),
191                };
192                let recovery_record = JournalRecord::recovery(
193                    context.record_base(5, "isolation.process.recovery", &environment),
194                    recovery,
195                );
196                self.journal
197                    .append(recovery_record)
198                    .map_err(|recovery_error| {
199                        AgentError::new(
200                            AgentErrorKind::RecoveryRepairNeeded,
201                            RetryClassification::RepairNeeded,
202                            format!(
203                                "process result append failed and recovery append failed: {}",
204                                recovery_error.context().message
205                            ),
206                        )
207                    })?;
208                Ok(IsolationProcessOutcome {
209                    status: selection.status,
210                    selection,
211                    intent_record: Some(intent_record),
212                    result_record: Some(result_record),
213                    io_frames: start_result.io_frames,
214                    recovery_required: true,
215                    terminal_error: Some(AgentError::new(
216                        AgentErrorKind::RecoveryRepairNeeded,
217                        RetryClassification::RepairNeeded,
218                        "isolated process terminal result append failed; recovery required",
219                    )),
220                })
221            }
222        }
223    }
224
225    /// Cleanup environment.
226    /// This calls the selected isolation cleanup adapter for environment resources that the
227    /// runtime already prepared.
228    pub fn cleanup_environment(
229        &self,
230        environment: ExecutionEnvironment,
231        context: IsolationLifecycleContext,
232    ) -> Result<IsolationCleanupOutcome, AgentError> {
233        let runtime = self
234            .registry
235            .first()
236            .ok_or_else(|| missing_runtime_error("no isolation runtime is registered"))?;
237        let cleanup_plan_ref =
238            CleanupPlanRef::new(format!("cleanup.{}", environment.environment_id.as_str()));
239        let mut intent = crate::EffectIntent::new(
240            crate::EffectId::new(format!("effect.{}", cleanup_plan_ref.as_str())),
241            crate::EffectKind::ChildArtifactShutdown,
242            environment.subject_ref(),
243            environment.source.clone(),
244            "cleanup isolated environment and child artifacts",
245        );
246        intent.destination = Some(environment.destination.clone());
247
248        let intent_record = JournalRecord::effect_intent(
249            context.record_base(1, "isolation.cleanup.intent", &environment),
250            intent.clone(),
251        );
252        self.journal
253            .append(intent_record)
254            .map_err(journal_failure)?;
255        let isolation_intent = IsolationRecord::CleanupIntent(IsolationCleanupIntentRecord {
256            environment_id: environment.environment_id.clone(),
257            cleanup_plan_ref: cleanup_plan_ref.clone(),
258            effect_intent: intent.clone(),
259            redacted_summary: "cleanup isolated environment and child artifacts".to_string(),
260        });
261        self.append_isolation_record(
262            &context,
263            2,
264            "isolation.cleanup.intent.record",
265            &environment,
266            isolation_intent,
267        )?;
268
269        let cleanup = runtime.cleanup(CleanupRequest {
270            environment: environment.clone(),
271            cleanup_plan_ref: cleanup_plan_ref.clone(),
272        })?;
273        let terminal_status = match cleanup.status {
274            CleanupStatus::Completed => EffectTerminalStatus::Completed,
275            CleanupStatus::RepairNeeded => EffectTerminalStatus::Failed,
276        };
277        let result = EffectResult {
278            effect_id: intent.effect_id.clone(),
279            terminal_status,
280            external_operation_id: cleanup.external_operation_id.clone(),
281            reconciliation_ref: None,
282            error_ref: (cleanup.status == CleanupStatus::RepairNeeded)
283                .then(|| "cleanup.repair_needed".to_string()),
284            content_refs: Vec::new(),
285            redacted_summary: cleanup.redacted_summary.clone(),
286        };
287        let result_record = JournalRecord::effect_result(
288            context.record_base(3, "isolation.cleanup.result", &environment),
289            result.clone(),
290        );
291        self.journal
292            .append(result_record)
293            .map_err(journal_failure)?;
294        let isolation_result = IsolationRecord::CleanupResult(IsolationCleanupResultRecord {
295            environment_id: environment.environment_id.clone(),
296            cleanup_plan_ref,
297            status: cleanup.status,
298            effect_result: result,
299            redacted_summary: cleanup.redacted_summary.clone(),
300        });
301        self.append_isolation_record(
302            &context,
303            4,
304            "isolation.cleanup.result.record",
305            &environment,
306            isolation_result,
307        )?;
308
309        Ok(IsolationCleanupOutcome {
310            status: cleanup.status,
311            cleanup_result: cleanup,
312        })
313    }
314
315    fn validate_package_sidecar(
316        &self,
317        package: &RuntimePackage,
318        environment: &ExecutionEnvironment,
319    ) -> Result<(), AgentError> {
320        let snapshot = package
321            .isolation_requirements
322            .iter()
323            .find(|snapshot| snapshot.requirement_ref == environment.requirement_ref)
324            .ok_or_else(|| {
325                AgentError::new(
326                    AgentErrorKind::IsolationFailure,
327                    RetryClassification::HostConfigurationNeeded,
328                    "runtime package is missing the requested isolation sidecar",
329                )
330            })?;
331        snapshot.validate()
332    }
333
334    fn selected_runtime(
335        &self,
336        environment: &ExecutionEnvironment,
337    ) -> Result<(Arc<dyn IsolationRuntime>, IsolationCapabilityReport), AgentError> {
338        let runtime = environment
339            .spec
340            .requirement
341            .preferred_adapters
342            .iter()
343            .find_map(|runtime_ref| self.registry.get(runtime_ref))
344            .or_else(|| self.registry.first())
345            .ok_or_else(|| missing_runtime_error("no isolation runtime is registered"))?;
346        let report = runtime.capability_report()?;
347        Ok((runtime, report))
348    }
349
350    fn append_selection_record(
351        &self,
352        selection: &IsolationSelectionOutcome,
353        environment: &ExecutionEnvironment,
354        context: &IsolationLifecycleContext,
355        journal_seq: u64,
356    ) -> Result<(), AgentError> {
357        let record = selection
358            .match_record
359            .clone()
360            .map(IsolationRecord::CapabilityMatch)
361            .or_else(|| {
362                selection
363                    .downgrade_record
364                    .clone()
365                    .map(IsolationRecord::DowngradeDecision)
366            });
367        if let Some(record) = record {
368            self.append_isolation_record(
369                context,
370                journal_seq,
371                "isolation.selection",
372                environment,
373                record,
374            )?;
375        }
376        Ok(())
377    }
378
379    fn append_isolation_record(
380        &self,
381        context: &IsolationLifecycleContext,
382        journal_seq: u64,
383        record_id: &str,
384        environment: &ExecutionEnvironment,
385        record: IsolationRecord,
386    ) -> Result<(), AgentError> {
387        let base = context.record_base(journal_seq, record_id, environment);
388        self.journal
389            .append(JournalRecord::feature_record(
390                base,
391                JournalRecordKind::Isolation,
392                "isolation",
393                isolation_event_kind(&record),
394                environment.subject_ref(),
395                Vec::new(),
396                isolation_content_refs(&record),
397                JournalRecordPayload::Isolation(record),
398            ))
399            .map(|_| ())
400            .map_err(journal_failure)
401    }
402}
403
404#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
405/// Holds isolation lifecycle context application-layer state or configuration.
406/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
407pub struct IsolationLifecycleContext {
408    /// Run identifier used for lineage, filtering, replay, and dedupe.
409    pub run_id: RunId,
410    /// Agent identifier used for lineage, filtering, and ownership checks.
411    pub agent_id: AgentId,
412    /// Source label or ref for this item; it is metadata and does not fetch
413    /// content by itself.
414    pub source: SourceRef,
415    /// Fingerprint of the runtime package snapshot in force when this value was produced.
416    /// Use it for replay, dedupe, and package-lineage checks; the field is evidence and does
417    /// not execute package behavior.
418    pub runtime_package_fingerprint: RuntimePackageFingerprint,
419    /// Privacy class used for projection, telemetry, and raw-content access
420    /// decisions.
421    pub privacy: PrivacyClass,
422    /// Stable redaction policy id used for typed lineage, lookup, or dedupe.
423    pub redaction_policy_id: String,
424    /// Readiness state for a capability or package feature.
425    /// Launch and package validation use it to distinguish active, reserved, and blocked
426    /// surfaces.
427    pub readiness_profile: IsolationReadinessProfile,
428}
429
430impl IsolationLifecycleContext {
431    /// Builds the test value.
432    /// This is data construction and performs no I/O, journal append, event publication, or
433    /// process work.
434    pub fn test(runtime_package_fingerprint: RuntimePackageFingerprint) -> Self {
435        Self {
436            run_id: RunId::new("run.isolation.contract"),
437            agent_id: AgentId::new("agent.isolation.contract"),
438            source: SourceRef::with_kind(crate::SourceKind::Sdk, "source.sdk.isolation"),
439            runtime_package_fingerprint,
440            privacy: PrivacyClass::ContentRefsOnly,
441            redaction_policy_id: "policy.redaction.isolation".to_string(),
442            readiness_profile: IsolationReadinessProfile::TestOnly,
443        }
444    }
445
446    fn record_base(
447        &self,
448        journal_seq: u64,
449        record_id: &str,
450        environment: &ExecutionEnvironment,
451    ) -> JournalRecordBase {
452        let mut base = JournalRecordBase::new(
453            journal_seq,
454            format!("journal.{record_id}"),
455            self.run_id.clone(),
456            self.agent_id.clone(),
457            self.source.clone(),
458        );
459        base.destination = Some(environment.destination.clone());
460        base.runtime_package_fingerprint = self.runtime_package_fingerprint.as_str().to_string();
461        base.privacy = self.privacy;
462        base.redaction_policy_id = self.redaction_policy_id.clone();
463        base.tags = vec!["isolation".to_string()];
464        base
465    }
466}
467
468#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
469#[serde(rename_all = "snake_case")]
470/// Enumerates the finite isolation readiness profile cases.
471/// Serialized names are part of the SDK contract; update fixtures when variants change.
472pub enum IsolationReadinessProfile {
473    /// Use this variant when the contract needs to represent production; selecting it has no side effect by itself.
474    Production,
475    /// Use this variant when the contract needs to represent test only; selecting it has no side effect by itself.
476    TestOnly,
477}
478
479#[derive(Clone, Debug)]
480/// Holds isolation selection outcome application-layer state or configuration.
481/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
482pub struct IsolationSelectionOutcome {
483    /// Finite status for this record or lifecycle stage.
484    pub status: IsolationMatchStatus,
485    /// Typed selected runtime ref reference. Resolving or executing it is a
486    /// separate policy-gated step.
487    pub selected_runtime_ref: Option<IsolationRuntimeRef>,
488    /// Optional capability report value.
489    /// When absent, callers should use the documented default or skip that optional behavior.
490    pub capability_report: Option<IsolationCapabilityReport>,
491    /// Optional match record value.
492    /// When absent, callers should use the documented default or skip that optional behavior.
493    pub match_record: Option<IsolationCapabilityMatchRecord>,
494    /// Optional downgrade record value.
495    /// When absent, callers should use the documented default or skip that optional behavior.
496    pub downgrade_record: Option<IsolationDowngradeDecisionRecord>,
497    /// Optional terminal error value.
498    /// When absent, callers should use the documented default or skip that optional behavior.
499    pub terminal_error: Option<AgentError>,
500}
501
502#[derive(Clone, Debug)]
503/// Holds isolation process outcome application-layer state or configuration.
504/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
505pub struct IsolationProcessOutcome {
506    /// Selection used by this record or request.
507    pub selection: IsolationSelectionOutcome,
508    /// Finite status for this record or lifecycle stage.
509    pub status: IsolationMatchStatus,
510    /// Optional intent record value.
511    /// When absent, callers should use the documented default or skip that optional behavior.
512    pub intent_record: Option<IsolationRecord>,
513    /// Optional result record value.
514    /// When absent, callers should use the documented default or skip that optional behavior.
515    pub result_record: Option<IsolationRecord>,
516    /// Collection of io frames values.
517    /// Ordering and membership should be treated as part of the serialized contract when
518    /// relevant.
519    pub io_frames: Vec<crate::ProcessIoFrame>,
520    /// Whether recovery required is enabled.
521    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
522    pub recovery_required: bool,
523    /// Optional terminal error value.
524    /// When absent, callers should use the documented default or skip that optional behavior.
525    pub terminal_error: Option<AgentError>,
526}
527
528#[derive(Clone, Debug)]
529/// Holds isolation cleanup outcome application-layer state or configuration.
530/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
531pub struct IsolationCleanupOutcome {
532    /// Finite status for this record or lifecycle stage.
533    pub status: CleanupStatus,
534    /// Cleanup result used by this record or request.
535    pub cleanup_result: CleanupResult,
536}
537
538#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
539/// Enumerates the finite isolation match status cases.
540/// Serialized names are part of the SDK contract; update fixtures when variants change.
541pub enum IsolationMatchStatus {
542    /// Use this variant when the contract needs to represent matched; selecting it has no side effect by itself.
543    Matched,
544    /// Use this variant when the contract needs to represent downgrade approved; selecting it has no side effect by itself.
545    DowngradeApproved,
546    /// Use this variant when the contract needs to represent downgrade denied; selecting it has no side effect by itself.
547    DowngradeDenied,
548    /// Use this variant when the contract needs to represent unsupported host; selecting it has no side effect by itself.
549    UnsupportedHost,
550}
551
552impl IsolationMatchStatus {
553    fn allows_adapter_call(self) -> bool {
554        matches!(self, Self::Matched | Self::DowngradeApproved)
555    }
556}
557
558#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
559/// Holds isolation downgrade approval application-layer state or configuration.
560/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
561pub struct IsolationDowngradeApproval {
562    /// Typed decision ref reference. Resolving or executing it is a separate
563    /// policy-gated step.
564    pub decision_ref: PolicyDecisionRef,
565    /// Scope used by this record or request.
566    pub scope: PolicyDecisionScope,
567    #[serde(default, skip_serializing_if = "Vec::is_empty")]
568    /// Classification selectors for approved classes.
569    /// Policy and projection paths use them for finite routing decisions.
570    pub approved_classes: Vec<IsolationClass>,
571    #[serde(default, skip_serializing_if = "Vec::is_empty")]
572    /// Capability downgrades explicitly approved for this isolation decision.
573    /// Adapters may rely on these approvals only for the request and package fingerprint they
574    /// reference.
575    pub approved_capability_downgrades: Vec<IsolationCapability>,
576    #[serde(default, skip_serializing_if = "Vec::is_empty")]
577    /// Collection of approved trust downgrades values.
578    /// Ordering and membership should be treated as part of the serialized contract when
579    /// relevant.
580    pub approved_trust_downgrades: Vec<IsolationTrustField>,
581}
582
583impl IsolationDowngradeApproval {
584    /// Builds the approved for isolation value.
585    /// This is data construction and performs no I/O, journal append, event publication, or
586    /// process work.
587    pub fn approved_for_isolation(decision_ref: impl Into<PolicyDecisionRef>) -> Self {
588        Self {
589            decision_ref: decision_ref.into(),
590            scope: PolicyDecisionScope::IsolationDowngrade,
591            approved_classes: Vec::new(),
592            approved_capability_downgrades: Vec::new(),
593            approved_trust_downgrades: Vec::new(),
594        }
595    }
596
597    /// Builds the approved for tool value.
598    /// This is data construction and performs no I/O, journal append, event publication, or
599    /// process work.
600    pub fn approved_for_tool(decision_ref: impl Into<PolicyDecisionRef>) -> Self {
601        Self {
602            decision_ref: decision_ref.into(),
603            scope: PolicyDecisionScope::ToolApproval,
604            approved_classes: Vec::new(),
605            approved_capability_downgrades: Vec::new(),
606            approved_trust_downgrades: Vec::new(),
607        }
608    }
609
610    /// Returns an updated value with approve capability configured.
611    /// This evaluates or builds isolation policy state in memory and does not call the
612    /// isolation adapter by itself.
613    pub fn approve_capability(mut self, capability: IsolationCapability) -> Self {
614        self.approved_capability_downgrades.push(capability);
615        self
616    }
617
618    /// Returns an updated value with approve trust configured.
619    /// This evaluates or builds isolation policy state in memory and does not call the
620    /// isolation adapter by itself.
621    pub fn approve_trust(mut self, field: IsolationTrustField) -> Self {
622        self.approved_trust_downgrades.push(field);
623        self
624    }
625}
626
627#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
628/// Enumerates the finite policy decision scope cases.
629/// Serialized names are part of the SDK contract; update fixtures when variants change.
630pub enum PolicyDecisionScope {
631    /// Use this variant when the contract needs to represent isolation downgrade; selecting it has no side effect by itself.
632    IsolationDowngrade,
633    /// Use this variant when the contract needs to represent tool approval; selecting it has no side effect by itself.
634    ToolApproval,
635}
636
637fn evaluate_report(
638    environment: &ExecutionEnvironment,
639    context: &IsolationLifecycleContext,
640    selected_runtime_ref: &IsolationRuntimeRef,
641    report: IsolationCapabilityReport,
642    approval: Option<IsolationDowngradeApproval>,
643) -> IsolationSelectionOutcome {
644    let selected_class = report
645        .supported_classes
646        .first()
647        .copied()
648        .unwrap_or(IsolationClass::HostProcess);
649    let requested_class = environment.spec.requirement.minimum_class;
650    let capability_gaps = environment
651        .spec
652        .requirement
653        .required_capabilities
654        .missing_from(&report.capabilities);
655    let trust_gaps = environment
656        .spec
657        .requirement
658        .trust
659        .gaps_against(&report.trust);
660    let class_gap = !class_satisfies(requested_class, selected_class);
661
662    if matches!(
663        report.health,
664        IsolationRuntimeHealth::UnsupportedHost { .. }
665    ) {
666        return denied_outcome(
667            IsolationMatchStatus::UnsupportedHost,
668            environment,
669            selected_runtime_ref,
670            requested_class,
671            selected_class,
672            capability_gaps,
673            trust_gaps,
674            approval,
675            Some(report),
676            "isolation runtime reports unsupported host",
677        );
678    }
679
680    if !class_gap && capability_gaps.is_empty() && trust_gaps.is_empty() {
681        let match_record = IsolationCapabilityMatchRecord {
682            environment_id: environment.environment_id.clone(),
683            adapter_ref: selected_runtime_ref.clone(),
684            requested_class,
685            selected_class,
686            missing_capabilities: Vec::new(),
687            trust_gaps: Vec::new(),
688            redacted_summary: "isolation capability report satisfies requirement".to_string(),
689        };
690        return IsolationSelectionOutcome {
691            status: IsolationMatchStatus::Matched,
692            selected_runtime_ref: Some(selected_runtime_ref.clone()),
693            capability_report: Some(report),
694            match_record: Some(match_record),
695            downgrade_record: None,
696            terminal_error: None,
697        };
698    }
699
700    if fallback_allows(
701        &environment.spec.requirement.fallback,
702        context,
703        selected_class,
704        class_gap,
705        &capability_gaps,
706        &trust_gaps,
707        approval.as_ref(),
708    ) {
709        return IsolationSelectionOutcome {
710            status: IsolationMatchStatus::DowngradeApproved,
711            selected_runtime_ref: Some(selected_runtime_ref.clone()),
712            capability_report: Some(report),
713            match_record: None,
714            downgrade_record: Some(downgrade_record(
715                environment,
716                selected_runtime_ref,
717                requested_class,
718                selected_class,
719                capability_gaps,
720                trust_gaps,
721                true,
722                approval,
723                "isolation downgrade approved by package and isolation policy decision",
724            )),
725            terminal_error: None,
726        };
727    }
728
729    denied_outcome(
730        IsolationMatchStatus::DowngradeDenied,
731        environment,
732        selected_runtime_ref,
733        requested_class,
734        selected_class,
735        capability_gaps,
736        trust_gaps,
737        approval,
738        Some(report),
739        "isolation downgrade denied before adapter execution",
740    )
741}
742
743fn class_satisfies(requested: IsolationClass, selected: IsolationClass) -> bool {
744    if requested == selected || requested == IsolationClass::HostProcess {
745        return true;
746    }
747    matches!(
748        (requested, selected),
749        (
750            IsolationClass::Sandbox,
751            IsolationClass::Container
752                | IsolationClass::LightweightVm
753                | IsolationClass::RemoteSandbox
754        )
755    )
756}
757
758fn fallback_allows(
759    fallback: &IsolationFallback,
760    context: &IsolationLifecycleContext,
761    selected_class: IsolationClass,
762    class_gap: bool,
763    capability_gaps: &[IsolationCapability],
764    trust_gaps: &[IsolationTrustField],
765    approval: Option<&IsolationDowngradeApproval>,
766) -> bool {
767    match fallback {
768        IsolationFallback::Deny => false,
769        IsolationFallback::TestOnlyHostProcess => {
770            selected_class == IsolationClass::HostProcess
771                && context.readiness_profile == IsolationReadinessProfile::TestOnly
772        }
773        IsolationFallback::AllowIfPackageAndPolicyApprove {
774            accepted_classes,
775            accepted_capability_downgrades,
776            accepted_trust_downgrades,
777            ..
778        } => {
779            let Some(approval) = approval else {
780                return false;
781            };
782            if approval.scope != PolicyDecisionScope::IsolationDowngrade {
783                return false;
784            }
785            if class_gap && !accepted_classes.contains(&selected_class) {
786                return false;
787            }
788            if class_gap
789                && !approval.approved_classes.is_empty()
790                && !approval.approved_classes.contains(&selected_class)
791            {
792                return false;
793            }
794            capability_gaps.iter().all(|gap| {
795                accepted_capability_downgrades.contains(gap)
796                    && approval.approved_capability_downgrades.contains(gap)
797            }) && trust_gaps.iter().all(|gap| {
798                accepted_trust_downgrades.contains(gap)
799                    && approval.approved_trust_downgrades.contains(gap)
800            })
801        }
802    }
803}
804
805#[expect(
806    clippy::too_many_arguments,
807    reason = "private isolation helper keeps downgrade evidence explicit; grouping belongs with a broader selection-outcome builder"
808)]
809fn denied_outcome(
810    status: IsolationMatchStatus,
811    environment: &ExecutionEnvironment,
812    selected_runtime_ref: &IsolationRuntimeRef,
813    requested_class: IsolationClass,
814    selected_class: IsolationClass,
815    capability_gaps: Vec<IsolationCapability>,
816    trust_gaps: Vec<IsolationTrustField>,
817    approval: Option<IsolationDowngradeApproval>,
818    report: Option<IsolationCapabilityReport>,
819    summary: &str,
820) -> IsolationSelectionOutcome {
821    IsolationSelectionOutcome {
822        status,
823        selected_runtime_ref: Some(selected_runtime_ref.clone()),
824        capability_report: report,
825        match_record: None,
826        downgrade_record: Some(downgrade_record(
827            environment,
828            selected_runtime_ref,
829            requested_class,
830            selected_class,
831            capability_gaps,
832            trust_gaps,
833            false,
834            approval,
835            summary,
836        )),
837        terminal_error: None,
838    }
839}
840
841#[expect(
842    clippy::too_many_arguments,
843    reason = "private isolation helper mirrors the durable downgrade decision record fields for auditability"
844)]
845fn downgrade_record(
846    environment: &ExecutionEnvironment,
847    selected_runtime_ref: &IsolationRuntimeRef,
848    requested_class: IsolationClass,
849    selected_class: IsolationClass,
850    capability_gaps: Vec<IsolationCapability>,
851    trust_gaps: Vec<IsolationTrustField>,
852    approved: bool,
853    approval: Option<IsolationDowngradeApproval>,
854    summary: &str,
855) -> IsolationDowngradeDecisionRecord {
856    IsolationDowngradeDecisionRecord {
857        environment_id: environment.environment_id.clone(),
858        adapter_ref: selected_runtime_ref.clone(),
859        requested_class,
860        selected_class,
861        capability_gaps,
862        trust_gaps,
863        approved,
864        policy_decision_scope: approval.as_ref().map(|approval| approval.scope),
865        policy_decision_refs: approval
866            .map(|approval| vec![approval.decision_ref])
867            .unwrap_or_default(),
868        redacted_summary: summary.to_string(),
869    }
870}
871
872fn process_start_effect_result(
873    intent: &crate::EffectIntent,
874    result: &ProcessStartResult,
875) -> EffectResult {
876    EffectResult {
877        effect_id: intent.effect_id.clone(),
878        terminal_status: result.terminal_status.clone(),
879        external_operation_id: result.external_operation_id.clone(),
880        reconciliation_ref: None,
881        error_ref: None,
882        content_refs: result
883            .io_frames
884            .iter()
885            .flat_map(|frame| frame.content_refs.clone())
886            .collect(),
887        redacted_summary: result.redacted_summary.clone(),
888    }
889}
890
891fn isolation_event_kind(record: &IsolationRecord) -> &'static str {
892    match record {
893        IsolationRecord::Requested(_) => "isolation_requested",
894        IsolationRecord::AdapterCapabilityReported(_) => "isolation_adapter_health_checked",
895        IsolationRecord::CapabilityMatch(_) => "isolation_capability_matched",
896        IsolationRecord::DowngradeDecision(record) if record.approved => {
897            "isolation_downgrade_approved"
898        }
899        IsolationRecord::DowngradeDecision(_) => "isolation_downgrade_denied",
900        IsolationRecord::EnvironmentPrepareIntent(_)
901        | IsolationRecord::EnvironmentPrepareResult(_) => "isolation_environment_prepared",
902        IsolationRecord::ProcessStartIntent(_) | IsolationRecord::ProcessStartResult(_) => {
903            "isolation_process_started"
904        }
905        IsolationRecord::ProcessIoFrame(_) => "isolation_process_io_captured",
906        IsolationRecord::ProcessStatsSnapshot(_) => "isolation_process_stats_recorded",
907        IsolationRecord::CleanupIntent(_) => "isolation_cleanup_started",
908        IsolationRecord::CleanupResult(record) if record.status == CleanupStatus::Completed => {
909            "isolation_cleanup_completed"
910        }
911        IsolationRecord::CleanupResult(_) => "isolation_cleanup_failed",
912        IsolationRecord::Failed(_) => "isolation_failed",
913    }
914}
915
916fn isolation_content_refs(record: &IsolationRecord) -> Vec<crate::domain::ContentRef> {
917    match record {
918        IsolationRecord::ProcessStartResult(record) => record.content_refs.clone(),
919        IsolationRecord::CleanupResult(record) => record.effect_result.content_refs.clone(),
920        _ => Vec::new(),
921    }
922}
923
924fn missing_runtime_error(message: impl Into<String>) -> AgentError {
925    AgentError::new(
926        AgentErrorKind::IsolationFailure,
927        RetryClassification::HostConfigurationNeeded,
928        message,
929    )
930}
931
932fn journal_failure(error: AgentError) -> AgentError {
933    AgentError::new(
934        AgentErrorKind::JournalFailure,
935        RetryClassification::RepairNeeded,
936        error.context().message,
937    )
938}