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}