1use serde::{Deserialize, Serialize};
10
11use crate::evidence::EvidenceRecord;
12use crate::execution::FailureClass;
13use crate::ids::{ArtifactId, ExecutionPolicyId, PlanId};
14use crate::judgment::Unresolved;
15use crate::limits::RECORD_V1;
16use crate::optimization::{OptimizationOutcome, OptimizationRecord};
17use crate::remote::{MAX_TEXT_BYTES, RemoteCode, ServedIdentity};
18use crate::schema;
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
21#[serde(deny_unknown_fields)]
22pub struct RunRecord {
23 pub schema: String,
24 pub decision_id: String,
26 pub plan_id: PlanId,
27 pub execution: RecordedExecution,
28 pub termination: Termination,
29 pub core: CoreEvidence,
30 pub timing: Timing,
31 pub requests: Vec<RequestRecord>,
33 pub cost: CostSummary,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub remote_evidence: Option<Vec<RemoteEvidence>>,
39 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
41 pub remote_evidence_truncated: bool,
42 #[serde(
44 default,
45 skip_serializing_if = "Option::is_none",
46 deserialize_with = "crate::acquisition_record::list"
47 )]
48 #[schemars(
49 with = "Vec<crate::acquisition_record::AcquisitionRecord>",
50 length(min = 1, max = 4096)
51 )]
52 pub acquisitions: Option<Vec<crate::acquisition_record::AcquisitionRecord>>,
53}
54
55crate::document::versioned_document!(
59 RunRecord,
60 [schema::RUN, schema::RUN_V2, schema::RUN_V3],
61 "rustev.run/1 or rustev.run/2 or rustev.run/3",
62 RECORD_V1,
63 check = RunRecord::parsed_members_fit
64);
65
66pub const MAX_REMOTE_EVIDENCE: usize = 256;
68pub const MAX_REMOTE_EVIDENCE_ENTRY_BYTES: usize = 8 * 1024;
71pub const MAX_REMOTE_EVIDENCE_INSTANCE: usize = 8;
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum RunVersionError {
77 NotVersion2,
79 Members(&'static str),
82}
83
84impl std::fmt::Display for RunVersionError {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self {
87 RunVersionError::NotVersion2 => f.write_str("only rustev.run/2 projects to version 1"),
88 RunVersionError::Members(why) => write!(f, "run record members: {why}"),
89 }
90 }
91}
92
93impl std::error::Error for RunVersionError {}
94
95impl RunRecord {
96 pub fn check_version_members(&self) -> Result<(), RunVersionError> {
99 if self.schema != schema::RUN_V3
100 && self
101 .requests
102 .iter()
103 .any(|r| r.screen.is_some() || r.result == RequestResult::Withdrawn)
104 {
105 return Err(RunVersionError::Members("screening requires rustev.run/3"));
106 }
107 if let Some(pages) = &self.acquisitions {
108 if self.schema != schema::RUN_V3 {
109 return Err(RunVersionError::Members(
110 "acquisitions require rustev.run/3",
111 ));
112 }
113 if pages.iter().map(|p| p.attempts.len()).sum::<usize>() > 4096 {
114 return Err(RunVersionError::Members("too many acquisition attempts"));
115 }
116 }
117 let v2 = self.schema == schema::RUN_V2;
118 match &self.remote_evidence {
119 None if v2 => Err(RunVersionError::Members(
120 "rustev.run/2 needs remote_evidence",
121 )),
122 Some(_) if !v2 => Err(RunVersionError::Members(
123 "remote_evidence is a rustev.run/2 member",
124 )),
125 _ if self.remote_evidence_truncated && !v2 => Err(RunVersionError::Members(
126 "remote_evidence_truncated is a rustev.run/2 member",
127 )),
128 Some(list) if list.len() > MAX_REMOTE_EVIDENCE => {
129 Err(RunVersionError::Members("too many remote evidence entries"))
130 }
131 Some(list) if !list.iter().all(RemoteEvidence::is_bounded) => Err(
132 RunVersionError::Members("a remote evidence entry beyond its bounds"),
133 ),
134 _ => Ok(()),
135 }
136 }
137
138 fn parsed_members_fit(&self) -> Result<(), String> {
143 if let CoreEvidence::Recorded(e) = &self.core
144 && e.generation.is_some()
145 && (self.schema != schema::RUN_V3 || e.schema != schema::EVIDENCE_V2)
146 {
147 return Err("generation evidence requires rustev.run/3 and rustev.evidence/2".into());
148 }
149 if let CoreEvidence::Recorded(e) = &self.core
150 && (self.schema != schema::RUN_V3 || e.schema != schema::EVIDENCE_V2)
151 && (e.acquisitions.is_some() || e.steps.iter().any(|s| s.constructed))
152 {
153 return Err("acquisition evidence requires rustev.evidence/2".into());
154 }
155
156 self.check_version_members().map_err(|e| e.to_string())
157 }
158
159 pub fn project_v1(&self) -> Result<RunRecord, RunVersionError> {
165 if self.schema != schema::RUN_V2 {
166 return Err(RunVersionError::NotVersion2);
167 }
168 self.check_version_members()?;
169 Ok(RunRecord {
170 schema: schema::RUN.into(),
171 remote_evidence: None,
172 remote_evidence_truncated: false,
173 ..self.clone()
174 })
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
183#[serde(deny_unknown_fields)]
184pub struct RemoteEvidence {
185 pub attempt_id: String,
186 pub step: String,
188 pub instance: Vec<String>,
189 pub exchange: ExchangeReference,
190 pub requested_identity: Option<String>,
192 pub served: ServedIdentity,
193 pub binding: ArtifactId,
196 pub protocol: String,
197 pub endpoint_class: String,
198 pub code: Option<RemoteCode>,
200 pub remote_state: RemoteState,
202 pub charge_provenance: ChargeProvenance,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub batch: Option<BatchMembership>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub principal_digest: Option<String>,
209}
210
211impl RemoteEvidence {
212 pub fn is_bounded(&self) -> bool {
216 self.strings_within(MAX_TEXT_BYTES)
217 && crate::canonical::canonical_bytes(self)
218 .is_ok_and(|b| b.len() <= MAX_REMOTE_EVIDENCE_ENTRY_BYTES)
219 }
220
221 fn strings_within(&self, max: usize) -> bool {
222 let t = |s: &str| s.len() <= max;
223 let o = |s: &Option<String>| s.as_deref().is_none_or(t);
224 t(&self.attempt_id)
225 && t(&self.step)
226 && self.instance.len() <= MAX_REMOTE_EVIDENCE_INSTANCE
227 && self.instance.iter().all(|i| t(i))
228 && self.exchange.is_bounded(max)
229 && o(&self.requested_identity)
230 && o(&self.served.identity)
231 && t(&self.protocol)
232 && t(&self.endpoint_class)
233 && self.batch.as_ref().is_none_or(|b| t(&b.batch_id))
234 && o(&self.principal_digest)
235 }
236
237 pub fn bounded(self) -> Self {
241 let e = self.cut(MAX_TEXT_BYTES);
242 if e.is_bounded() { e } else { e.cut(32) }
243 }
244
245 fn cut(mut self, max: usize) -> Self {
246 let c = |s: &mut String| {
247 if s.len() > max {
248 *s = crate::remote::cut_to(s, max);
249 }
250 };
251 c(&mut self.attempt_id);
252 c(&mut self.step);
253 self.instance.truncate(MAX_REMOTE_EVIDENCE_INSTANCE);
254 self.instance.iter_mut().for_each(c);
255 if let ExchangeReference::Retained {
256 digest,
257 schema,
258 locator,
259 ..
260 } = &mut self.exchange
261 {
262 c(digest);
263 c(schema);
264 c(locator);
265 }
266 if let Some(r) = &mut self.requested_identity {
267 c(r);
268 }
269 if let Some(s) = &mut self.served.identity {
270 c(s);
271 }
272 c(&mut self.protocol);
273 c(&mut self.endpoint_class);
274 if let Some(b) = &mut self.batch {
275 c(&mut b.batch_id);
276 }
277 if let Some(p) = &mut self.principal_digest {
278 c(p);
279 }
280 self
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
286#[serde(rename_all = "snake_case", deny_unknown_fields)]
287pub enum ExchangeReference {
288 Retained {
289 digest: String,
291 schema: String,
293 retention: RetentionClass,
294 locator: String,
296 },
297 Unavailable {
298 reason: EvidenceUnavailable,
299 },
300}
301
302impl ExchangeReference {
303 fn is_bounded(&self, max: usize) -> bool {
304 match self {
305 ExchangeReference::Retained {
306 digest,
307 schema,
308 locator,
309 ..
310 } => [digest, schema, locator].iter().all(|s| s.len() <= max),
311 ExchangeReference::Unavailable { .. } => true,
312 }
313 }
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
318#[serde(rename_all = "snake_case")]
319pub enum RetentionClass {
320 DigestOnly,
322 BytesRetained,
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
329#[serde(rename_all = "snake_case")]
330pub enum EvidenceUnavailable {
331 NoExchange,
333 Pending,
335 NotDelivered,
337 NoLocator,
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
343#[serde(rename_all = "snake_case")]
344pub enum ChargeProvenance {
345 Reported,
347 Attributed,
349 NothingSent,
351 Unknown,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
357#[serde(deny_unknown_fields)]
358pub struct BatchMembership {
359 pub batch_id: String,
360 pub index: u32,
361}
362
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
364#[serde(rename_all = "snake_case", deny_unknown_fields)]
365pub enum RecordedExecution {
366 None,
367 Declared(ExecutionPolicyId),
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
371#[serde(rename_all = "snake_case")]
372pub enum Termination {
373 Judged,
375 Cancelled,
377}
378
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
380#[serde(rename_all = "snake_case", deny_unknown_fields)]
381pub enum CoreEvidence {
382 Recorded(Box<EvidenceRecord>),
384 NotProduced,
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
389#[serde(deny_unknown_fields)]
390pub struct Timing {
391 pub queued_ms: u64,
392 pub elapsed_ms: u64,
393 pub deadline_ms: u64,
394 pub deadline_expired: bool,
395}
396
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
398#[serde(deny_unknown_fields)]
399pub struct RequestRecord {
400 pub step: String,
401 pub instance: Vec<String>,
402 pub result: RequestResult,
403 pub attempts: Vec<AttemptRecord>,
404 pub transitions: Vec<Transition>,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub optimization: Option<OptimizationRecord>,
408 #[serde(
410 default,
411 skip_serializing_if = "Option::is_none",
412 deserialize_with = "crate::screening::present"
413 )]
414 #[schemars(with = "crate::screening::ScreenRequest")]
415 pub screen: Option<crate::screening::ScreenRequest>,
416}
417
418#[derive(Debug, Clone, Copy, PartialEq)]
420pub struct SuppliedFrom<'r> {
421 pub target: u32,
424 pub attempt: Option<&'r AttemptRecord>,
428 pub reused_from: Option<&'r str>,
432}
433
434impl RequestRecord {
435 pub fn supplied_from(&self) -> Option<SuppliedFrom<'_>> {
438 match &self.result {
439 RequestResult::NotSupplied | RequestResult::Withdrawn => None,
440 RequestResult::Output { target } => Some(SuppliedFrom {
441 target: *target,
442 attempt: self.attempts.last(),
443 reused_from: self
444 .optimization
445 .as_ref()
446 .filter(|o| self.attempts.is_empty() && o.outcome == OptimizationOutcome::Hit)
447 .and_then(|o| o.source_attempt_id.as_deref()),
448 }),
449 RequestResult::Failed(_) => {
450 let target = self
451 .transitions
452 .iter()
453 .rev()
454 .find_map(|t| match t {
455 Transition::Fallback { to, .. } => Some(*to),
456 _ => None,
457 })
458 .unwrap_or(0);
459 let n = self.attempts.len() as u32;
460 let moved_on = self.transitions.iter().any(|t| {
461 matches!(t, Transition::Retry { after, .. } | Transition::Fallback { after, .. } if *after == n)
462 });
463 Some(SuppliedFrom {
464 target,
465 attempt: if moved_on { None } else { self.attempts.last() },
466 reused_from: None,
467 })
468 }
469 }
470 }
471}
472
473#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
474#[serde(rename_all = "snake_case", deny_unknown_fields)]
475pub enum RequestResult {
476 Output { target: u32 },
478 Failed(Unresolved),
480 NotSupplied,
482 Withdrawn,
484}
485
486#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
487#[serde(deny_unknown_fields)]
488pub struct AttemptRecord {
489 pub attempt_id: String,
492 pub target: u32,
493 pub backend_id: String,
494 pub artifact: ArtifactId,
495 pub dispatched_ms: u64,
496 pub ended_ms: u64,
497 pub end: AttemptEnd,
498 pub cancellation: Cancellation,
499 pub remote: RemoteState,
500 pub cost: AttemptCost,
501}
502
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
504#[serde(rename_all = "snake_case", deny_unknown_fields)]
505pub enum AttemptEnd {
506 Output,
508 Failed {
509 class: FailureClass,
510 detail: String,
511 },
512 Deadline,
514 Cancelled,
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
520#[serde(rename_all = "snake_case", deny_unknown_fields)]
521pub enum Cancellation {
522 NotRequested,
523 Requested { answer: CancelAnswer },
524}
525
526#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
527#[serde(rename_all = "snake_case")]
528pub enum CancelAnswer {
529 Stopped,
531 Unconfirmed,
533 NotObserved,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
538#[serde(rename_all = "snake_case")]
539pub enum RemoteState {
540 Finished,
542 Stopped,
544 PossiblyContinuing,
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
549#[serde(deny_unknown_fields)]
550pub struct AttemptCost {
551 pub bound: CostBound,
552 pub reserved: u64,
553 pub charge: Charge,
554}
555
556#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
558#[serde(rename_all = "snake_case")]
559pub enum CostModel {
560 Bounded,
562 Estimated,
564 Unknown,
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
569#[serde(rename_all = "snake_case", deny_unknown_fields)]
570pub enum CostBound {
571 Bounded {
573 max_units: u64,
574 },
575 Estimated {
576 units: u64,
577 },
578 Unknown,
579}
580
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
583#[serde(rename_all = "snake_case", deny_unknown_fields)]
584pub enum Charge {
585 Observed { units: u64 },
586 Estimated { units: u64 },
587 Unknown,
588}
589
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
591#[serde(rename_all = "snake_case", deny_unknown_fields)]
592pub enum Transition {
593 Retry {
595 after: u32,
596 class: FailureClass,
597 delay_ms: u64,
598 },
599 Fallback {
601 after: u32,
602 class: FailureClass,
603 to: u32,
604 },
605 Stop { after: u32, reason: String },
607}
608
609#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
610#[serde(rename_all = "snake_case")]
611pub enum CostMode {
612 Unlimited,
613 Hard,
614 Estimated,
615}
616
617#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
619#[serde(deny_unknown_fields)]
620pub struct CostSummary {
621 pub mode: CostMode,
622 pub limit: u64,
624 pub observed: u64,
625 pub estimated: u64,
626 pub liability: u64,
628 pub bound_violations: u64,
630 pub final_cost: FinalCost,
631 pub within_guaranteed_cap: bool,
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
636#[serde(rename_all = "snake_case")]
637pub enum FinalCost {
638 Known,
639 IncludesEstimates,
640 Unknown,
641}