Skip to main content

a3s_runtime/contract/
observation.rs

1use super::{
2    ArtifactRef, IsolationLevel, RuntimeOutputArtifact, RuntimeUnitClass, RuntimeUnitSpec,
3};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum RuntimeUnitState {
10    Accepted,
11    Preparing,
12    Starting,
13    Running,
14    Stopping,
15    Stopped,
16    Succeeded,
17    Failed,
18    Unknown,
19}
20
21impl RuntimeUnitState {
22    pub fn is_terminal(self) -> bool {
23        matches!(self, Self::Stopped | Self::Succeeded | Self::Failed)
24    }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum RuntimeHealthState {
30    Unknown,
31    Starting,
32    Healthy,
33    Unhealthy,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct RuntimeHealthObservation {
39    pub state: RuntimeHealthState,
40    pub checked_at_ms: u64,
41    pub message: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct RuntimeUsage {
47    pub wall_time_ms: u64,
48    pub cpu_time_ms: u64,
49    pub peak_memory_bytes: u64,
50    pub network_rx_bytes: u64,
51    pub network_tx_bytes: u64,
52    pub storage_read_bytes: u64,
53    pub storage_write_bytes: u64,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct RuntimeFailure {
59    pub code: String,
60    pub message: String,
61    pub retryable: bool,
62}
63
64impl RuntimeFailure {
65    fn validate(&self) -> Result<(), String> {
66        super::validate_name("failure code", &self.code)?;
67        super::validate_nonempty("failure message", &self.message, 16 * 1024)
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct RuntimeEvidence {
74    pub provider_build: String,
75    pub spec_digest: String,
76    pub semantics_profile_digest: Option<String>,
77    pub claims: BTreeMap<String, String>,
78}
79
80impl RuntimeEvidence {
81    fn validate(&self) -> Result<(), String> {
82        super::validate_nonempty("provider_build", &self.provider_build, 255)?;
83        super::validate_digest(&self.spec_digest)?;
84        if let Some(digest) = &self.semantics_profile_digest {
85            super::validate_digest(digest)?;
86        }
87        if self.claims.len() > 128
88            || self
89                .claims
90                .iter()
91                .any(|(key, value)| key.len() > 255 || value.len() > 4096)
92        {
93            return Err("Runtime evidence claims exceed protocol limits".into());
94        }
95        Ok(())
96    }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct RuntimeObservation {
102    pub schema: String,
103    pub unit_id: String,
104    pub generation: u64,
105    pub spec_digest: String,
106    pub class: RuntimeUnitClass,
107    pub state: RuntimeUnitState,
108    pub provider_resource_id: Option<String>,
109    pub provider_build: Option<String>,
110    pub observed_at_ms: u64,
111    pub started_at_ms: Option<u64>,
112    pub finished_at_ms: Option<u64>,
113    pub health: Option<RuntimeHealthObservation>,
114    pub outputs: Vec<RuntimeOutputArtifact>,
115    pub usage: Option<RuntimeUsage>,
116    pub evidence: Option<RuntimeEvidence>,
117    pub provider_attestation: Option<ArtifactRef>,
118    pub failure: Option<RuntimeFailure>,
119}
120
121impl RuntimeObservation {
122    pub const SCHEMA: &'static str = "a3s.runtime.observation.v2";
123
124    pub(crate) fn accepted(spec: &RuntimeUnitSpec, observed_at_ms: u64) -> Result<Self, String> {
125        Ok(Self {
126            schema: Self::SCHEMA.into(),
127            unit_id: spec.unit_id.clone(),
128            generation: spec.generation,
129            spec_digest: spec.digest()?,
130            class: spec.class,
131            state: RuntimeUnitState::Accepted,
132            provider_resource_id: None,
133            provider_build: None,
134            observed_at_ms,
135            started_at_ms: None,
136            finished_at_ms: None,
137            health: None,
138            outputs: Vec::new(),
139            usage: None,
140            evidence: None,
141            provider_attestation: None,
142            failure: None,
143        })
144    }
145
146    pub fn validate(&self) -> Result<(), String> {
147        if self.schema != Self::SCHEMA {
148            return Err(format!(
149                "unsupported Runtime observation schema {:?}",
150                self.schema
151            ));
152        }
153        super::validate_id("unit_id", &self.unit_id, 512)?;
154        if self.generation == 0 {
155            return Err("Runtime observation generation must be positive".into());
156        }
157        super::validate_digest(&self.spec_digest)?;
158        if let Some(value) = &self.provider_resource_id {
159            super::validate_nonempty("provider_resource_id", value, 1024)?;
160        }
161        if let Some(value) = &self.provider_build {
162            super::validate_nonempty("provider_build", value, 255)?;
163        }
164        if !matches!(
165            self.state,
166            RuntimeUnitState::Accepted | RuntimeUnitState::Unknown
167        ) && (self.provider_resource_id.is_none() || self.provider_build.is_none())
168        {
169            return Err("provider-backed observations require resource and build identity".into());
170        }
171        if let (Some(started), Some(finished)) = (self.started_at_ms, self.finished_at_ms) {
172            if finished < started {
173                return Err("finished_at_ms precedes started_at_ms".into());
174            }
175        }
176        if self.state.is_terminal() != self.finished_at_ms.is_some() {
177            return Err("terminal state and finished_at_ms do not agree".into());
178        }
179        if self.state == RuntimeUnitState::Failed {
180            self.failure
181                .as_ref()
182                .ok_or_else(|| "failed observation is missing failure".to_string())?
183                .validate()?;
184        } else if self.failure.is_some() {
185            return Err("non-failed observation contains failure".into());
186        }
187        if self.class == RuntimeUnitClass::Service && self.state == RuntimeUnitState::Succeeded {
188            return Err("Service cannot enter succeeded state".into());
189        }
190        if self.class == RuntimeUnitClass::Task && self.health.is_some() {
191            return Err("Task observation cannot contain Service health".into());
192        }
193        if !(self.outputs.is_empty()
194            || self.class == RuntimeUnitClass::Task && self.state == RuntimeUnitState::Succeeded)
195        {
196            return Err("output artifacts require a succeeded Task".into());
197        }
198        let mut output_names = BTreeSet::new();
199        for output in &self.outputs {
200            output.validate()?;
201            if !output_names.insert(&output.name) {
202                return Err(format!("duplicate output artifact {:?}", output.name));
203            }
204        }
205        if let Some(health) = &self.health {
206            if let Some(message) = &health.message {
207                super::validate_nonempty("health message", message, 4096)?;
208            }
209        }
210        if let Some(evidence) = &self.evidence {
211            evidence.validate()?;
212            if evidence.spec_digest != self.spec_digest {
213                return Err("Runtime evidence does not bind the observation spec".into());
214            }
215        }
216        if let Some(attestation) = &self.provider_attestation {
217            attestation.validate()?;
218        }
219        Ok(())
220    }
221
222    pub fn validate_against(&self, spec: &RuntimeUnitSpec) -> Result<(), String> {
223        self.validate()?;
224        spec.validate()?;
225        if self.unit_id != spec.unit_id
226            || self.generation != spec.generation
227            || self.class != spec.class
228            || self.spec_digest != spec.digest()?
229        {
230            return Err("Runtime observation does not match the unit specification".into());
231        }
232        if self.state == RuntimeUnitState::Succeeded {
233            if self.outputs.len() != spec.outputs.len() {
234                return Err("succeeded Task did not report the exact requested outputs".into());
235            }
236            for expected in &spec.outputs {
237                let output = self
238                    .outputs
239                    .iter()
240                    .find(|output| output.name == expected.name)
241                    .ok_or_else(|| format!("succeeded Task omitted output {:?}", expected.name))?;
242                if output.artifact.media_type != expected.media_type {
243                    return Err(format!(
244                        "output {:?} media type does not match its specification",
245                        expected.name
246                    ));
247                }
248                if output.size_bytes > expected.max_bytes {
249                    return Err(format!(
250                        "output {:?} exceeds its maximum size",
251                        expected.name
252                    ));
253                }
254            }
255        } else if !self.outputs.is_empty() {
256            return Err("only a succeeded Task may report outputs".into());
257        }
258        if spec.isolation == IsolationLevel::Confidential
259            && self.provider_resource_id.is_some()
260            && self.provider_attestation.is_none()
261        {
262            return Err(
263                "provider-backed confidential Runtime observation requires attestation".into(),
264            );
265        }
266        Ok(())
267    }
268
269    pub fn converges(&self, spec: &RuntimeUnitSpec) -> bool {
270        if self.validate_against(spec).is_err() {
271            return false;
272        }
273        match spec.class {
274            RuntimeUnitClass::Task => self.state == RuntimeUnitState::Succeeded,
275            RuntimeUnitClass::Service => {
276                self.state == RuntimeUnitState::Running
277                    && spec.health.as_ref().is_none_or(|_| {
278                        self.health
279                            .as_ref()
280                            .is_some_and(|health| health.state == RuntimeHealthState::Healthy)
281                    })
282            }
283        }
284    }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
289pub enum RuntimeInspection {
290    Found {
291        schema: String,
292        observation: Box<RuntimeObservation>,
293    },
294    NotFound {
295        schema: String,
296        unit_id: String,
297        last_generation: Option<u64>,
298    },
299}
300
301impl RuntimeInspection {
302    pub const SCHEMA: &'static str = "a3s.runtime.inspection.v1";
303
304    pub fn validate(&self) -> Result<(), String> {
305        match self {
306            Self::Found {
307                schema,
308                observation,
309            } => {
310                validate_inspection_schema(schema)?;
311                observation.validate()
312            }
313            Self::NotFound {
314                schema,
315                unit_id,
316                last_generation,
317            } => {
318                validate_inspection_schema(schema)?;
319                super::validate_id("unit_id", unit_id, 512)?;
320                if *last_generation == Some(0) {
321                    return Err("last_generation must be positive when present".into());
322                }
323                Ok(())
324            }
325        }
326    }
327}
328
329fn validate_inspection_schema(schema: &str) -> Result<(), String> {
330    if schema != RuntimeInspection::SCHEMA {
331        return Err(format!("unsupported Runtime inspection schema {schema:?}"));
332    }
333    Ok(())
334}