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}