Skip to main content

a3s_box_runtime/
box_record.rs

1//! Canonical persisted metadata schema for local box executions.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use a3s_box_core::config::ResourceLimits;
7use a3s_box_core::log::LogConfig;
8use a3s_box_core::{
9    CreateExecutionRequest, ExecutionGeneration, ExecutionIsolation, ExecutionResourceUpdate,
10    ExecutionSnapshotId, NetworkMode, OperationId, ResolvedExecutionPlan,
11};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16pub use a3s_box_core::ExecutionHealthCheck as HealthCheck;
17
18/// Metadata record for a single local box execution.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct BoxRecord {
21    /// Full UUID.
22    pub id: String,
23    /// First 12 hex characters of the UUID, without dashes.
24    pub short_id: String,
25    /// User-assigned or generated name.
26    pub name: String,
27    /// OCI image reference.
28    pub image: String,
29    /// Requested execution isolation. Records written before this field default to MicroVM.
30    #[serde(default)]
31    pub isolation: ExecutionIsolation,
32    /// Runtime lifecycle identity and recoverable creation intent.
33    ///
34    /// Legacy CLI-created records omit this field. Managed executions persist
35    /// it before launch so an operation can be reconciled after a service
36    /// restart without creating a second execution.
37    #[serde(default)]
38    pub managed_execution: Option<ManagedExecutionMetadata>,
39    /// Persisted lifecycle state.
40    ///
41    /// Legacy records use `created`, `running`, `paused`, `stopped`, and
42    /// `dead`. Managed executions additionally use the durable transition
43    /// states defined by [`ManagedExecutionState`].
44    pub status: String,
45    /// Host-visible runtime PID while the execution is active.
46    ///
47    /// OCI SDK routes populate this only for shared-host-kernel isolation;
48    /// guest and VM runtime PIDs are never persisted as host identities.
49    pub pid: Option<u32>,
50    /// Start-time identity token used to reject a reused PID.
51    #[serde(default)]
52    pub pid_start_time: Option<u64>,
53    /// Number of virtual CPUs.
54    pub cpus: u32,
55    /// Memory in MiB.
56    pub memory_mb: u32,
57    /// Volume mounts encoded as host-to-guest pairs.
58    pub volumes: Vec<String>,
59    /// virtio-fs cache mode for host directory volumes.
60    #[serde(default)]
61    pub virtiofs_cache: Option<String>,
62    /// Environment variables.
63    pub env: HashMap<String, String>,
64    /// Command override.
65    pub cmd: Vec<String>,
66    /// Entrypoint override.
67    #[serde(default)]
68    pub entrypoint: Option<Vec<String>>,
69    /// Host-side execution directory.
70    pub box_dir: PathBuf,
71    /// Path to the exec socket.
72    #[serde(default)]
73    pub exec_socket_path: PathBuf,
74    /// Path to the console log.
75    pub console_log: PathBuf,
76    /// Creation timestamp.
77    pub created_at: DateTime<Utc>,
78    /// Start timestamp for the current runtime incarnation.
79    ///
80    /// A managed restart advances this value while preserving the Box ID.
81    pub started_at: Option<DateTime<Utc>>,
82    /// Whether the execution is removed automatically after it stops.
83    pub auto_remove: bool,
84    /// Custom hostname.
85    #[serde(default)]
86    pub hostname: Option<String>,
87    /// User inside the workload.
88    #[serde(default)]
89    pub user: Option<String>,
90    /// Working directory inside the workload.
91    #[serde(default)]
92    pub workdir: Option<String>,
93    /// Restart policy.
94    #[serde(default = "default_restart_policy")]
95    pub restart_policy: String,
96    /// Port mappings.
97    #[serde(default)]
98    pub port_map: Vec<String>,
99    /// User-defined labels.
100    #[serde(default)]
101    pub labels: HashMap<String, String>,
102    /// Whether the execution was explicitly stopped by a user.
103    #[serde(default)]
104    pub stopped_by_user: bool,
105    /// Automatic restart count.
106    #[serde(default)]
107    pub restart_count: u32,
108    /// Maximum restart count for a bounded on-failure policy.
109    #[serde(default)]
110    pub max_restart_count: u32,
111    /// Last captured exit code.
112    #[serde(default)]
113    pub exit_code: Option<i32>,
114    /// Health-check configuration.
115    #[serde(default)]
116    pub health_check: Option<HealthCheck>,
117    /// Whether an image-defined health check was disabled explicitly.
118    #[serde(default)]
119    pub healthcheck_disabled: bool,
120    /// Current health state.
121    #[serde(default = "default_health_status")]
122    pub health_status: String,
123    /// Consecutive health-check failures.
124    #[serde(default)]
125    pub health_retries: u32,
126    /// Timestamp of the most recent health check.
127    #[serde(default)]
128    pub health_last_check: Option<DateTime<Utc>>,
129    /// Network mode.
130    #[serde(default)]
131    pub network_mode: NetworkMode,
132    /// Attached bridge network name.
133    #[serde(default)]
134    pub network_name: Option<String>,
135    /// Attached named volumes.
136    #[serde(default)]
137    pub volume_names: Vec<String>,
138    /// tmpfs mounts.
139    #[serde(default)]
140    pub tmpfs: Vec<String>,
141    /// Anonymous volumes materialized from OCI declarations.
142    #[serde(default)]
143    pub anonymous_volumes: Vec<String>,
144    /// Host resource controls.
145    #[serde(default)]
146    pub resource_limits: ResourceLimits,
147    /// Logging configuration.
148    #[serde(default)]
149    pub log_config: LogConfig,
150    /// Custom host-to-IP mappings.
151    #[serde(default)]
152    pub add_host: Vec<String>,
153    /// Target OCI platform.
154    #[serde(default)]
155    pub platform: Option<String>,
156    /// Whether to run an init process as PID 1.
157    #[serde(default)]
158    pub init: bool,
159    /// Whether the root filesystem is read-only.
160    #[serde(default)]
161    pub read_only: bool,
162    /// Added Linux capabilities.
163    #[serde(default)]
164    pub cap_add: Vec<String>,
165    /// Dropped Linux capabilities.
166    #[serde(default)]
167    pub cap_drop: Vec<String>,
168    /// OCI security options.
169    #[serde(default)]
170    pub security_opt: Vec<String>,
171    /// Whether extended privileges are enabled.
172    #[serde(default)]
173    pub privileged: bool,
174    /// Device mappings.
175    #[serde(default)]
176    pub devices: Vec<String>,
177    /// GPU selection.
178    #[serde(default)]
179    pub gpus: Option<String>,
180    /// Shared-memory size in bytes.
181    #[serde(default)]
182    pub shm_size: Option<u64>,
183    /// Signal used for graceful stop.
184    #[serde(default)]
185    pub stop_signal: Option<String>,
186    /// Graceful stop timeout in seconds.
187    #[serde(default)]
188    pub stop_timeout: Option<u64>,
189    /// Whether the OOM killer is disabled.
190    #[serde(default)]
191    pub oom_kill_disable: bool,
192    /// Host OOM score adjustment.
193    #[serde(default)]
194    pub oom_score_adj: Option<i32>,
195}
196
197impl BoxRecord {
198    /// Generate the stable short ID used by local CLI and SDK lookup.
199    pub fn make_short_id(id: &str) -> String {
200        id.replace('-', "").chars().take(12).collect()
201    }
202
203    /// Whether the persisted lifecycle state represents an active execution.
204    pub fn is_active(&self) -> bool {
205        if self.managed_execution.is_some() {
206            return self
207                .managed_state()
208                .is_ok_and(|state| state.is_some_and(ManagedExecutionState::keeps_resources));
209        }
210        matches!(self.status.as_str(), "running" | "paused")
211    }
212
213    /// Parse the lifecycle state of a managed execution.
214    ///
215    /// Legacy records return `None`. Unknown managed states fail closed so a
216    /// runtime service cannot operate on a record written by incompatible
217    /// code.
218    pub fn managed_state(&self) -> a3s_box_core::Result<Option<ManagedExecutionState>> {
219        let Some(metadata) = self.managed_execution.as_ref() else {
220            return Ok(None);
221        };
222        let state = ManagedExecutionState::from_status(&self.status)?;
223        validate_pending_operation(state, metadata)?;
224        Ok(Some(state))
225    }
226
227    /// Render a concise lifecycle status with health, exit, and restart annotations.
228    pub fn status_summary(&self) -> String {
229        let mut annotations = Vec::new();
230        if self.is_active() && self.health_check.is_some() && self.health_status != "none" {
231            annotations.push(self.health_status.clone());
232        }
233        if matches!(self.status.as_str(), "stopped" | "dead") {
234            if let Some(exit_code) = self.exit_code {
235                annotations.push(format!("Exit {exit_code}"));
236            }
237        }
238        if self.restart_count > 0 {
239            annotations.push(format!("Restarts: {}", self.restart_count));
240        }
241        if annotations.is_empty() {
242            self.status.clone()
243        } else {
244            format!("{} ({})", self.status, annotations.join(", "))
245        }
246    }
247}
248
249/// Durable lifecycle state for an execution owned by `ExecutionManager`.
250///
251/// Transitional states are persisted before backend side effects. This lets
252/// a restarted manager distinguish work that was never claimed from work that
253/// may already have reached the runtime.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum ManagedExecutionState {
257    Creating,
258    Created,
259    Starting,
260    Running,
261    Pausing,
262    Paused,
263    Resuming,
264    UpdatingResources,
265    Snapshotting,
266    Killing,
267    RestartStopping,
268    RestartStarting,
269    Removing,
270    Stopped,
271    Failed,
272}
273
274impl ManagedExecutionState {
275    /// Canonical value written to [`BoxRecord::status`].
276    pub const fn as_status(self) -> &'static str {
277        match self {
278            Self::Creating => "creating",
279            Self::Created => "created",
280            Self::Starting => "starting",
281            Self::Running => "running",
282            Self::Pausing => "pausing",
283            Self::Paused => "paused",
284            Self::Resuming => "resuming",
285            Self::UpdatingResources => "updating_resources",
286            Self::Snapshotting => "snapshotting",
287            Self::Killing => "killing",
288            Self::RestartStopping => "restart_stopping",
289            Self::RestartStarting => "restart_starting",
290            Self::Removing => "removing",
291            Self::Stopped => "stopped",
292            Self::Failed => "failed",
293        }
294    }
295
296    /// Parse a persisted managed lifecycle state.
297    pub fn from_status(status: &str) -> a3s_box_core::Result<Self> {
298        match status {
299            "creating" => Ok(Self::Creating),
300            "created" => Ok(Self::Created),
301            "starting" => Ok(Self::Starting),
302            "running" => Ok(Self::Running),
303            "pausing" => Ok(Self::Pausing),
304            "paused" => Ok(Self::Paused),
305            "resuming" => Ok(Self::Resuming),
306            "updating_resources" => Ok(Self::UpdatingResources),
307            "snapshotting" => Ok(Self::Snapshotting),
308            "killing" => Ok(Self::Killing),
309            "restart_stopping" => Ok(Self::RestartStopping),
310            "restart_starting" => Ok(Self::RestartStarting),
311            "removing" => Ok(Self::Removing),
312            "stopped" => Ok(Self::Stopped),
313            "dead" | "failed" => Ok(Self::Failed),
314            other => Err(a3s_box_core::BoxError::StateError(format!(
315                "unknown managed execution state: {other}"
316            ))),
317        }
318    }
319
320    /// Whether host resources may still belong to this execution.
321    pub const fn keeps_resources(self) -> bool {
322        !matches!(
323            self,
324            Self::Creating | Self::Created | Self::Stopped | Self::Failed
325        )
326    }
327
328    /// Whether no further lifecycle operation can revive this execution.
329    pub const fn is_terminal(self) -> bool {
330        matches!(self, Self::Stopped | Self::Failed)
331    }
332}
333
334impl std::fmt::Display for ManagedExecutionState {
335    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        formatter.write_str(self.as_status())
337    }
338}
339
340/// Durable lifecycle metadata for an execution owned by [`ExecutionManager`].
341///
342/// [`ExecutionManager`]: a3s_box_core::ExecutionManager
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct ManagedExecutionMetadata {
345    /// Idempotency key of the create operation.
346    pub operation_id: OperationId,
347    /// Immutable digest of the original create request.
348    ///
349    /// Live resource updates change `request` because it is also the restart
350    /// source of truth. This separate identity keeps retries of the original
351    /// create operation idempotent after later mutable policy changes.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub creation_intent_digest: Option<String>,
354    /// Runtime generation used to reject stale lifecycle requests.
355    pub generation: ExecutionGeneration,
356    /// Full creation intent required to recover an interrupted launch.
357    pub request: CreateExecutionRequest,
358    /// Exact runtime identity returned by A3S OCI Runtime. Product and runtime
359    /// generations remain separate and this field is cleared after teardown.
360    #[serde(default)]
361    pub oci_runtime: Option<crate::local_execution::OciRuntimeBinding>,
362    /// Product-selected lifecycle route for every generation of this record.
363    ///
364    /// `Unspecified` is retained only for records written before production
365    /// routing existed. New concrete backends persist an exact route before
366    /// the reservation is published.
367    #[serde(default, skip_serializing_if = "ManagedRuntimeRoute::is_unspecified")]
368    pub runtime_route: ManagedRuntimeRoute,
369    /// Backend resolution validated before any launch side effects.
370    pub plan: ResolvedExecutionPlan,
371    /// Lifecycle side effect claimed before calling the backend.
372    #[serde(default)]
373    pub pending_operation: Option<ManagedExecutionOperation>,
374    /// Most recent completed restart retained for idempotent response replay.
375    #[serde(default)]
376    pub last_restart: Option<ManagedRestartCompletion>,
377    /// Most recent completed live resource update retained for keyed replay.
378    #[serde(default)]
379    pub last_resource_update: Option<ManagedResourceUpdateCompletion>,
380    /// Provider terminal timestamp retained for deterministic observation replay.
381    #[serde(default)]
382    pub finished_at: Option<DateTime<Utc>>,
383    /// Whether a paused execution still owns a live, memory-preserved runtime.
384    ///
385    /// Records written before filesystem-only pause support always represented
386    /// warm pauses, so the backwards-compatible default is `true`.
387    #[serde(default = "default_paused_with_memory")]
388    pub paused_with_memory: bool,
389}
390
391/// Durable Box-side selection of the lifecycle implementation for one record.
392#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
393#[serde(rename_all = "snake_case")]
394pub enum ManagedRuntimeRoute {
395    /// Compatibility marker for records created before explicit routing.
396    #[default]
397    Unspecified,
398    /// Box's existing in-process VM/Sandbox ownership path.
399    BoxVm,
400    /// The public A3S OCI SDK and its out-of-process host service.
401    OciSdk,
402}
403
404impl ManagedRuntimeRoute {
405    pub const fn is_unspecified(&self) -> bool {
406        matches!(self, Self::Unspecified)
407    }
408}
409
410/// Recoverable backend operation associated with a transitional state.
411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412#[serde(tag = "kind", rename_all = "snake_case")]
413pub enum ManagedExecutionOperation {
414    Start,
415    Pause {
416        keep_memory: bool,
417        /// Stable backend mutation identity for this exact pause claim.
418        #[serde(default, skip_serializing_if = "Option::is_none")]
419        operation_id: Option<OperationId>,
420    },
421    Resume {
422        /// Stable backend mutation identity for this exact resume claim.
423        #[serde(default, skip_serializing_if = "Option::is_none")]
424        operation_id: Option<OperationId>,
425    },
426    UpdateResources {
427        operation_id: OperationId,
428        update: ExecutionResourceUpdate,
429    },
430    Snapshot {
431        snapshot_id: ExecutionSnapshotId,
432        source_state: ManagedExecutionState,
433        /// Stable backend mutation identity for this exact snapshot attempt.
434        ///
435        /// One snapshot claim can drive both a pause and a resume. The backend
436        /// operation name keeps those mutations distinct while this seed makes
437        /// crash recovery replay each mutation exactly once. Older records did
438        /// not persist the seed, so the field remains optional for recovery.
439        #[serde(default, skip_serializing_if = "Option::is_none")]
440        operation_id: Option<OperationId>,
441        /// The runtime freeze was confirmed before snapshot capture began.
442        ///
443        /// This phase fence distinguishes an initial running claim from a
444        /// container that was already thawed after capture. Without it, crash
445        /// recovery could replay a completed pause journal entry while the
446        /// actual container remained running.
447        #[serde(default)]
448        freezer_applied: bool,
449    },
450    Kill {
451        #[serde(default)]
452        signal: Option<i32>,
453        #[serde(default)]
454        timeout_secs: Option<u64>,
455    },
456    Remove,
457    Restart {
458        operation_id: OperationId,
459        source_generation: ExecutionGeneration,
460        source_state: ManagedExecutionState,
461        #[serde(default)]
462        stop_timeout_secs: Option<u64>,
463    },
464}
465
466/// Durable result of the most recent restart operation.
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(rename_all = "snake_case")]
469pub enum ManagedRestartOutcome {
470    Running,
471    Stopped,
472    Failed,
473}
474
475/// Restart identity retained after its transitional state has completed.
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477pub struct ManagedRestartCompletion {
478    pub operation_id: OperationId,
479    pub source_generation: ExecutionGeneration,
480    pub target_generation: ExecutionGeneration,
481    pub outcome: ManagedRestartOutcome,
482    #[serde(default)]
483    pub stop_timeout_secs: Option<u64>,
484}
485
486/// Completed resource mutation retained after its transitional claim clears.
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488pub struct ManagedResourceUpdateCompletion {
489    pub operation_id: OperationId,
490    pub generation: ExecutionGeneration,
491    pub update: ExecutionResourceUpdate,
492}
493
494impl ManagedExecutionMetadata {
495    /// Build validated recovery metadata from one creation request.
496    pub fn new(
497        operation_id: OperationId,
498        generation: ExecutionGeneration,
499        request: CreateExecutionRequest,
500    ) -> a3s_box_core::Result<Self> {
501        if request.external_sandbox_id.trim().is_empty() {
502            return Err(a3s_box_core::BoxError::ConfigError(
503                "external sandbox ID cannot be empty".to_string(),
504            ));
505        }
506        let plan = a3s_box_core::resolve_execution(&request.config)?;
507        let creation_intent_digest = Some(digest_creation_request(&request)?);
508        Ok(Self {
509            operation_id,
510            creation_intent_digest,
511            generation,
512            request,
513            oci_runtime: None,
514            runtime_route: ManagedRuntimeRoute::Unspecified,
515            plan,
516            pending_operation: None,
517            last_restart: None,
518            last_resource_update: None,
519            finished_at: None,
520            paused_with_memory: true,
521        })
522    }
523
524    /// Whether this durable record must dispatch through A3S OCI Runtime.
525    ///
526    /// Records written before `runtime_route` was introduced are identified by
527    /// their exact OCI binding. Callers must never fall back to a Box-owned
528    /// socket after either form of durable evidence selects OCI.
529    #[must_use]
530    pub fn is_oci_routed(&self) -> bool {
531        self.runtime_route == ManagedRuntimeRoute::OciSdk || self.oci_runtime.is_some()
532    }
533
534    /// Validate deserialized metadata before it participates in reconciliation.
535    pub fn validate(&self) -> a3s_box_core::Result<()> {
536        if self.request.external_sandbox_id.trim().is_empty() {
537            return Err(a3s_box_core::BoxError::StateError(
538                "managed execution has an empty external sandbox ID".to_string(),
539            ));
540        }
541        let resolved = a3s_box_core::resolve_execution(&self.request.config)?;
542        if let Some(digest) = self.creation_intent_digest.as_deref() {
543            validate_creation_intent_digest(digest)?;
544        }
545        if !execution_plan_matches(&resolved, &self.plan) {
546            return Err(a3s_box_core::BoxError::StateError(
547                "managed execution plan does not match its persisted creation request".to_string(),
548            ));
549        }
550        if let Some(binding) = &self.oci_runtime {
551            if self.runtime_route == ManagedRuntimeRoute::BoxVm {
552                return Err(a3s_box_core::BoxError::StateError(
553                    "Box VM-routed execution contains an A3S OCI binding".to_string(),
554                ));
555            }
556            binding
557                .validate()
558                .map_err(|error| a3s_box_core::BoxError::StateError(error.to_string()))?;
559            let expected_isolation =
560                crate::local_execution::oci_isolation_request(self.request.config.isolation)
561                    .class();
562            if binding.isolation != expected_isolation {
563                return Err(a3s_box_core::BoxError::StateError(
564                    "A3S OCI binding weakens or changes the requested isolation".to_string(),
565                ));
566            }
567        }
568        if let Some(completed) = &self.last_restart {
569            let expected_target = next_generation(completed.source_generation)?;
570            if completed.target_generation != expected_target {
571                return Err(a3s_box_core::BoxError::StateError(format!(
572                    "completed restart {} has inconsistent generations",
573                    completed.operation_id
574                )));
575            }
576            validate_stop_timeout(completed.stop_timeout_secs)?;
577        }
578        if let Some(completed) = &self.last_resource_update {
579            completed.update.validate().map_err(|error| {
580                a3s_box_core::BoxError::StateError(format!(
581                    "completed resource update {} is invalid: {error}",
582                    completed.operation_id
583                ))
584            })?;
585            if completed.generation > self.generation {
586                return Err(a3s_box_core::BoxError::StateError(format!(
587                    "completed resource update {} belongs to future generation {}",
588                    completed.operation_id,
589                    completed.generation.get()
590                )));
591            }
592        }
593        Ok(())
594    }
595}
596
597fn digest_creation_request(request: &CreateExecutionRequest) -> a3s_box_core::Result<String> {
598    let value = serde_json::to_value(request).map_err(|error| {
599        a3s_box_core::BoxError::ConfigError(format!(
600            "failed to encode managed creation intent: {error}"
601        ))
602    })?;
603    let encoded = serde_json::to_vec(&value).map_err(|error| {
604        a3s_box_core::BoxError::ConfigError(format!(
605            "failed to canonicalize managed creation intent: {error}"
606        ))
607    })?;
608    Ok(format!("sha256:{}", hex::encode(Sha256::digest(encoded))))
609}
610
611fn validate_creation_intent_digest(digest: &str) -> a3s_box_core::Result<()> {
612    let valid = digest.strip_prefix("sha256:").is_some_and(|value| {
613        value.len() == 64
614            && value
615                .bytes()
616                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
617    });
618    if valid {
619        Ok(())
620    } else {
621        Err(a3s_box_core::BoxError::StateError(
622            "managed creation intent digest is invalid".to_string(),
623        ))
624    }
625}
626
627fn execution_plan_matches(
628    resolved: &ResolvedExecutionPlan,
629    persisted: &ResolvedExecutionPlan,
630) -> bool {
631    resolved == persisted
632}
633
634fn validate_pending_operation(
635    state: ManagedExecutionState,
636    metadata: &ManagedExecutionMetadata,
637) -> a3s_box_core::Result<()> {
638    let operation = metadata.pending_operation.as_ref();
639    let consistent = matches!(
640        (state, operation),
641        (
642            ManagedExecutionState::Starting,
643            Some(ManagedExecutionOperation::Start)
644        ) | (
645            ManagedExecutionState::Pausing,
646            Some(ManagedExecutionOperation::Pause { .. })
647        ) | (
648            ManagedExecutionState::Resuming,
649            Some(ManagedExecutionOperation::Resume { .. })
650        ) | (
651            ManagedExecutionState::UpdatingResources,
652            Some(ManagedExecutionOperation::UpdateResources { .. })
653        ) | (
654            ManagedExecutionState::Snapshotting,
655            Some(ManagedExecutionOperation::Snapshot { .. })
656        ) | (
657            ManagedExecutionState::Killing,
658            Some(ManagedExecutionOperation::Kill { .. })
659        ) | (
660            ManagedExecutionState::Removing,
661            Some(ManagedExecutionOperation::Remove)
662        ) | (
663            ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting,
664            Some(ManagedExecutionOperation::Restart { .. })
665        ) | (
666            ManagedExecutionState::Creating
667                | ManagedExecutionState::Created
668                | ManagedExecutionState::Running
669                | ManagedExecutionState::Paused
670                | ManagedExecutionState::Stopped
671                | ManagedExecutionState::Failed,
672            None
673        )
674    );
675    if !consistent {
676        return Err(a3s_box_core::BoxError::StateError(format!(
677            "managed execution state {state} has inconsistent pending operation"
678        )));
679    }
680
681    if let Some(ManagedExecutionOperation::Restart {
682        source_generation,
683        source_state,
684        stop_timeout_secs,
685        ..
686    }) = operation
687    {
688        if !matches!(
689            source_state,
690            ManagedExecutionState::Created
691                | ManagedExecutionState::Running
692                | ManagedExecutionState::Paused
693                | ManagedExecutionState::Stopped
694                | ManagedExecutionState::Failed
695        ) {
696            return Err(a3s_box_core::BoxError::StateError(
697                "restart source state is not stable".to_string(),
698            ));
699        }
700        let expected = match state {
701            ManagedExecutionState::RestartStopping => *source_generation,
702            ManagedExecutionState::RestartStarting => next_generation(*source_generation)?,
703            _ => {
704                return Err(a3s_box_core::BoxError::StateError(
705                    "restart operation is attached to a non-restart state".to_string(),
706                ))
707            }
708        };
709        if metadata.generation != expected {
710            return Err(a3s_box_core::BoxError::StateError(format!(
711                "restart state {state} has generation {}, expected {}",
712                metadata.generation.get(),
713                expected.get()
714            )));
715        }
716        validate_stop_timeout(*stop_timeout_secs)?;
717    }
718    if let Some(ManagedExecutionOperation::Snapshot {
719        source_state,
720        freezer_applied,
721        ..
722    }) = operation
723    {
724        if state != ManagedExecutionState::Snapshotting
725            || !matches!(
726                source_state,
727                ManagedExecutionState::Running | ManagedExecutionState::Paused
728            )
729        {
730            return Err(a3s_box_core::BoxError::StateError(
731                "snapshot operation has an invalid source state".to_string(),
732            ));
733        }
734        if *freezer_applied && *source_state != ManagedExecutionState::Running {
735            return Err(a3s_box_core::BoxError::StateError(
736                "paused-source snapshot cannot carry a runtime freezer phase".to_string(),
737            ));
738        }
739    }
740    if let Some(ManagedExecutionOperation::UpdateResources { update, .. }) = operation {
741        if state != ManagedExecutionState::UpdatingResources {
742            return Err(a3s_box_core::BoxError::StateError(
743                "resource update operation is attached to a non-update state".to_string(),
744            ));
745        }
746        update.validate().map_err(|error| {
747            a3s_box_core::BoxError::StateError(format!(
748                "persisted resource update is invalid: {error}"
749            ))
750        })?;
751    }
752    if let Some(ManagedExecutionOperation::Kill {
753        signal,
754        timeout_secs,
755    }) = operation
756    {
757        if signal.is_some_and(|signal| signal <= 0 || 128_i32.checked_add(signal).is_none()) {
758            return Err(a3s_box_core::BoxError::StateError(
759                "kill signal must be positive and representable as a Box exit code".to_string(),
760            ));
761        }
762        validate_stop_timeout(*timeout_secs)?;
763    }
764    if !metadata.paused_with_memory {
765        let valid_cold_pause_state = match (state, operation) {
766            (
767                ManagedExecutionState::Pausing,
768                Some(ManagedExecutionOperation::Pause { keep_memory, .. }),
769            ) => !keep_memory,
770            (ManagedExecutionState::Paused | ManagedExecutionState::Resuming, _) => true,
771            (
772                ManagedExecutionState::Snapshotting,
773                Some(ManagedExecutionOperation::Snapshot { source_state, .. }),
774            ) => *source_state == ManagedExecutionState::Paused,
775            // These transitions may be claimed from a cold-paused execution.
776            (ManagedExecutionState::Killing | ManagedExecutionState::Removing, _) => true,
777            (
778                ManagedExecutionState::RestartStopping,
779                Some(ManagedExecutionOperation::Restart { source_state, .. }),
780            ) => *source_state == ManagedExecutionState::Paused,
781            _ => false,
782        };
783        if !valid_cold_pause_state {
784            return Err(a3s_box_core::BoxError::StateError(format!(
785                "managed execution state {state} cannot retain a filesystem-only pause"
786            )));
787        }
788    }
789    Ok(())
790}
791
792fn validate_stop_timeout(timeout_secs: Option<u64>) -> a3s_box_core::Result<()> {
793    if timeout_secs.is_some_and(|timeout| timeout.checked_mul(1_000).is_none()) {
794        Err(a3s_box_core::BoxError::StateError(
795            "managed stop timeout is too large".to_string(),
796        ))
797    } else {
798        Ok(())
799    }
800}
801
802fn next_generation(generation: ExecutionGeneration) -> a3s_box_core::Result<ExecutionGeneration> {
803    let value = generation.get().checked_add(1).ok_or_else(|| {
804        a3s_box_core::BoxError::StateError("execution generation is exhausted".to_string())
805    })?;
806    ExecutionGeneration::new(value).map_err(|error| {
807        a3s_box_core::BoxError::StateError(format!("invalid execution generation: {error}"))
808    })
809}
810
811fn default_restart_policy() -> String {
812    "no".to_string()
813}
814
815fn default_health_status() -> String {
816    "none".to_string()
817}
818
819const fn default_paused_with_memory() -> bool {
820    true
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826
827    fn minimal_record() -> serde_json::Value {
828        serde_json::json!({
829            "id": "11111111-1111-4111-8111-111111111111",
830            "short_id": "111111111111",
831            "name": "fixture",
832            "image": "alpine:latest",
833            "status": "created",
834            "pid": null,
835            "cpus": 1,
836            "memory_mb": 128,
837            "volumes": [],
838            "env": {},
839            "cmd": ["sh"],
840            "box_dir": "/tmp/fixture",
841            "console_log": "/tmp/fixture/console.log",
842            "created_at": "2026-07-14T12:00:00Z",
843            "started_at": null,
844            "auto_remove": false
845        })
846    }
847
848    #[test]
849    fn legacy_records_default_without_losing_runtime_fields() {
850        let mut value = minimal_record();
851        value["virtiofs_cache"] = serde_json::json!("always");
852        let record: BoxRecord = serde_json::from_value(value).unwrap();
853
854        assert_eq!(record.isolation, ExecutionIsolation::Microvm);
855        assert!(record.managed_execution.is_none());
856        assert_eq!(record.virtiofs_cache.as_deref(), Some("always"));
857        assert_eq!(record.restart_policy, "no");
858        assert_eq!(record.health_status, "none");
859        assert_eq!(
860            serde_json::to_value(record).unwrap()["virtiofs_cache"],
861            "always"
862        );
863    }
864
865    #[test]
866    fn managed_execution_metadata_round_trips_recovery_intent() {
867        let mut config = a3s_box_core::BoxConfig {
868            image: "alpine:latest".to_string(),
869            isolation: ExecutionIsolation::Sandbox,
870            ..Default::default()
871        };
872        config.resources.vcpus = 1;
873        config.resources.memory_mb = 128;
874        let metadata = ManagedExecutionMetadata::new(
875            OperationId::new("create-op-1").unwrap(),
876            ExecutionGeneration::INITIAL,
877            CreateExecutionRequest {
878                external_sandbox_id: "sandbox-1".to_string(),
879                config,
880                labels: Default::default(),
881                policy: Default::default(),
882                rootfs_snapshot_id: None,
883            },
884        )
885        .unwrap();
886        let mut value = minimal_record();
887        value["managed_execution"] = serde_json::to_value(metadata).unwrap();
888        value["managed_execution"]
889            .as_object_mut()
890            .unwrap()
891            .remove("paused_with_memory");
892
893        let record: BoxRecord = serde_json::from_value(value).unwrap();
894        let encoded = serde_json::to_value(&record).unwrap();
895        assert_eq!(
896            record.managed_state().unwrap(),
897            Some(ManagedExecutionState::Created)
898        );
899        assert!(!record.is_active());
900        let managed = record.managed_execution.unwrap();
901
902        assert_eq!(managed.operation_id.as_str(), "create-op-1");
903        assert_eq!(managed.generation, ExecutionGeneration::INITIAL);
904        assert_eq!(managed.request.external_sandbox_id, "sandbox-1");
905        assert!(managed.paused_with_memory);
906        assert_eq!(
907            managed.request.config.isolation,
908            ExecutionIsolation::Sandbox
909        );
910        assert_eq!(encoded["managed_execution"]["generation"], 1);
911        assert_eq!(encoded["managed_execution"]["paused_with_memory"], true);
912        assert!(encoded["managed_execution"].get("runtime_route").is_none());
913        assert_eq!(managed.runtime_route, ManagedRuntimeRoute::Unspecified);
914    }
915
916    #[test]
917    fn managed_runtime_route_is_exact_and_legacy_compatible() {
918        let mut metadata = ManagedExecutionMetadata::new(
919            OperationId::new("create-op-route").unwrap(),
920            ExecutionGeneration::INITIAL,
921            CreateExecutionRequest {
922                external_sandbox_id: "sandbox-route".to_string(),
923                config: a3s_box_core::BoxConfig {
924                    image: "alpine:latest".to_string(),
925                    isolation: ExecutionIsolation::Sandbox,
926                    ..Default::default()
927                },
928                labels: Default::default(),
929                policy: Default::default(),
930                rootfs_snapshot_id: None,
931            },
932        )
933        .unwrap();
934        metadata.runtime_route = ManagedRuntimeRoute::OciSdk;
935        assert!(metadata.is_oci_routed());
936
937        let encoded = serde_json::to_value(&metadata).unwrap();
938        assert_eq!(encoded["runtime_route"], "oci_sdk");
939        let decoded: ManagedExecutionMetadata = serde_json::from_value(encoded).unwrap();
940        assert_eq!(decoded.runtime_route, ManagedRuntimeRoute::OciSdk);
941
942        let mut legacy = serde_json::to_value(decoded).unwrap();
943        legacy.as_object_mut().unwrap().remove("runtime_route");
944        let decoded: ManagedExecutionMetadata = serde_json::from_value(legacy).unwrap();
945        assert_eq!(decoded.runtime_route, ManagedRuntimeRoute::Unspecified);
946        assert!(!decoded.is_oci_routed());
947    }
948
949    #[test]
950    fn legacy_kill_operation_defaults_new_termination_options() {
951        let operation: ManagedExecutionOperation =
952            serde_json::from_value(serde_json::json!({ "kind": "kill" })).unwrap();
953
954        assert_eq!(
955            operation,
956            ManagedExecutionOperation::Kill {
957                signal: None,
958                timeout_secs: None,
959            }
960        );
961    }
962
963    #[test]
964    fn legacy_freezer_operations_default_claim_identity() {
965        let pause: ManagedExecutionOperation = serde_json::from_value(serde_json::json!({
966            "kind": "pause",
967            "keep_memory": true
968        }))
969        .unwrap();
970        let resume: ManagedExecutionOperation =
971            serde_json::from_value(serde_json::json!({ "kind": "resume" })).unwrap();
972        let snapshot: ManagedExecutionOperation = serde_json::from_value(serde_json::json!({
973            "kind": "snapshot",
974            "snapshot_id": "legacy-snapshot",
975            "source_state": "running"
976        }))
977        .unwrap();
978
979        assert_eq!(
980            pause,
981            ManagedExecutionOperation::Pause {
982                keep_memory: true,
983                operation_id: None,
984            }
985        );
986        assert_eq!(
987            resume,
988            ManagedExecutionOperation::Resume { operation_id: None }
989        );
990        assert_eq!(
991            snapshot,
992            ManagedExecutionOperation::Snapshot {
993                snapshot_id: ExecutionSnapshotId::new("legacy-snapshot").unwrap(),
994                source_state: ManagedExecutionState::Running,
995                operation_id: None,
996                freezer_applied: false,
997            }
998        );
999    }
1000
1001    #[test]
1002    fn managed_execution_rejects_a_cold_pause_marker_in_running_state() {
1003        let config = a3s_box_core::BoxConfig {
1004            image: "alpine:latest".to_string(),
1005            isolation: ExecutionIsolation::Sandbox,
1006            ..Default::default()
1007        };
1008        let mut metadata = ManagedExecutionMetadata::new(
1009            OperationId::new("create-op-cold-invalid").unwrap(),
1010            ExecutionGeneration::INITIAL,
1011            CreateExecutionRequest {
1012                external_sandbox_id: "sandbox-cold-invalid".to_string(),
1013                config,
1014                labels: Default::default(),
1015                policy: Default::default(),
1016                rootfs_snapshot_id: None,
1017            },
1018        )
1019        .unwrap();
1020        metadata.paused_with_memory = false;
1021        let mut value = minimal_record();
1022        value["status"] = serde_json::json!("running");
1023        value["managed_execution"] = serde_json::to_value(metadata).unwrap();
1024        let record: BoxRecord = serde_json::from_value(value).unwrap();
1025
1026        assert!(record.managed_state().is_err());
1027    }
1028
1029    #[test]
1030    fn managed_execution_validation_rejects_plan_drift() {
1031        let config = a3s_box_core::BoxConfig {
1032            image: "alpine:latest".to_string(),
1033            isolation: ExecutionIsolation::Sandbox,
1034            ..Default::default()
1035        };
1036        let mut metadata = ManagedExecutionMetadata::new(
1037            OperationId::new("create-op-1").unwrap(),
1038            ExecutionGeneration::INITIAL,
1039            CreateExecutionRequest {
1040                external_sandbox_id: "sandbox-1".to_string(),
1041                config,
1042                labels: Default::default(),
1043                policy: Default::default(),
1044                rootfs_snapshot_id: None,
1045            },
1046        )
1047        .unwrap();
1048        metadata.plan =
1049            a3s_box_core::resolve_execution(&a3s_box_core::BoxConfig::default()).unwrap();
1050
1051        assert!(metadata.validate().is_err());
1052    }
1053}