Skip to main content

a3s_runtime/contract/
observation.rs

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