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 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub decision_cache: Option<crate::optimization::DecisionCacheRecord>,
57}
58
59crate::document::versioned_document!(
63 RunRecord,
64 [schema::RUN, schema::RUN_V2, schema::RUN_V3],
65 "rustev.run/1 or rustev.run/2 or rustev.run/3",
66 RECORD_V1,
67 check = RunRecord::parsed_members_fit
68);
69
70pub const MAX_REMOTE_EVIDENCE: usize = 256;
72pub const MAX_REMOTE_EVIDENCE_ENTRY_BYTES: usize = 8 * 1024;
75pub const MAX_REMOTE_EVIDENCE_INSTANCE: usize = 8;
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum RunVersionError {
81 NotVersion2,
83 Members(&'static str),
86}
87
88impl std::fmt::Display for RunVersionError {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 RunVersionError::NotVersion2 => f.write_str("only rustev.run/2 projects to version 1"),
92 RunVersionError::Members(why) => write!(f, "run record members: {why}"),
93 }
94 }
95}
96
97impl std::error::Error for RunVersionError {}
98
99impl RunRecord {
100 pub fn check_version_members(&self) -> Result<(), RunVersionError> {
103 if self.schema != schema::RUN_V3
104 && self
105 .requests
106 .iter()
107 .any(|r| r.screen.is_some() || r.result == RequestResult::Withdrawn)
108 {
109 return Err(RunVersionError::Members("screening requires rustev.run/3"));
110 }
111 if let Some(pages) = &self.acquisitions {
112 if self.schema != schema::RUN_V3 {
113 return Err(RunVersionError::Members(
114 "acquisitions require rustev.run/3",
115 ));
116 }
117 if pages.iter().map(|p| p.attempts.len()).sum::<usize>() > 4096 {
118 return Err(RunVersionError::Members("too many acquisition attempts"));
119 }
120 }
121 if let Some(d) = &self.decision_cache {
122 if self.schema != schema::RUN_V3 {
123 return Err(RunVersionError::Members(
124 "decision_cache requires rustev.run/3",
125 ));
126 }
127 if d.diagnostics.len() > crate::optimization::MAX_OPTIMIZATION_DIAGNOSTICS {
128 return Err(RunVersionError::Members(
129 "too many decision cache diagnostics",
130 ));
131 }
132 if let Some(a) = &d.abandoned {
133 if a.requests.is_empty() && a.acquisitions.is_empty() {
134 return Err(RunVersionError::Members(
135 "an abandoned run is omitted when empty",
136 ));
137 }
138 if a.acquisitions
139 .iter()
140 .map(|p| p.attempts.len())
141 .sum::<usize>()
142 > 4096
143 {
144 return Err(RunVersionError::Members(
145 "too many abandoned acquisition attempts",
146 ));
147 }
148 }
149 }
150 let v2 = self.schema == schema::RUN_V2;
151 match &self.remote_evidence {
152 None if v2 => Err(RunVersionError::Members(
153 "rustev.run/2 needs remote_evidence",
154 )),
155 Some(_) if !v2 => Err(RunVersionError::Members(
156 "remote_evidence is a rustev.run/2 member",
157 )),
158 _ if self.remote_evidence_truncated && !v2 => Err(RunVersionError::Members(
159 "remote_evidence_truncated is a rustev.run/2 member",
160 )),
161 Some(list) if list.len() > MAX_REMOTE_EVIDENCE => {
162 Err(RunVersionError::Members("too many remote evidence entries"))
163 }
164 Some(list) if !list.iter().all(RemoteEvidence::is_bounded) => Err(
165 RunVersionError::Members("a remote evidence entry beyond its bounds"),
166 ),
167 _ => Ok(()),
168 }
169 }
170
171 fn parsed_members_fit(&self) -> Result<(), String> {
176 if let CoreEvidence::Recorded(e) = &self.core {
177 e.screening_version()?;
178 if self.schema != schema::RUN_V3
179 && (e.extraction.is_some()
180 || e.acceptance.is_some()
181 || e.inputs.iter().any(|i| i.producer.is_some())
182 || e.judgment.lineage.producers.is_some())
183 {
184 return Err("extraction evidence requires rustev.run/3".into());
185 }
186 }
187 if let CoreEvidence::Recorded(e) = &self.core
188 && e.generation.is_some()
189 && (self.schema != schema::RUN_V3 || e.schema != schema::EVIDENCE_V2)
190 {
191 return Err("generation evidence requires rustev.run/3 and rustev.evidence/2".into());
192 }
193 if let CoreEvidence::Recorded(e) = &self.core
194 && (self.schema != schema::RUN_V3 || e.schema != schema::EVIDENCE_V2)
195 && (e.acquisitions.is_some() || e.steps.iter().any(|s| s.constructed))
196 {
197 return Err("acquisition evidence requires rustev.evidence/2".into());
198 }
199
200 self.check_version_members().map_err(|e| e.to_string())
201 }
202
203 pub fn project_v1(&self) -> Result<RunRecord, RunVersionError> {
209 if self.schema != schema::RUN_V2 {
210 return Err(RunVersionError::NotVersion2);
211 }
212 self.check_version_members()?;
213 Ok(RunRecord {
214 schema: schema::RUN.into(),
215 remote_evidence: None,
216 remote_evidence_truncated: false,
217 ..self.clone()
218 })
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
227#[serde(deny_unknown_fields)]
228pub struct RemoteEvidence {
229 pub attempt_id: String,
230 pub step: String,
232 pub instance: Vec<String>,
233 pub exchange: ExchangeReference,
234 pub requested_identity: Option<String>,
236 pub served: ServedIdentity,
237 pub binding: ArtifactId,
240 pub protocol: String,
241 pub endpoint_class: String,
242 pub code: Option<RemoteCode>,
244 pub remote_state: RemoteState,
246 pub charge_provenance: ChargeProvenance,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub batch: Option<BatchMembership>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub principal_digest: Option<String>,
253}
254
255impl RemoteEvidence {
256 pub fn is_bounded(&self) -> bool {
260 self.strings_within(MAX_TEXT_BYTES)
261 && crate::canonical::canonical_bytes(self)
262 .is_ok_and(|b| b.len() <= MAX_REMOTE_EVIDENCE_ENTRY_BYTES)
263 }
264
265 fn strings_within(&self, max: usize) -> bool {
266 let t = |s: &str| s.len() <= max;
267 let o = |s: &Option<String>| s.as_deref().is_none_or(t);
268 t(&self.attempt_id)
269 && t(&self.step)
270 && self.instance.len() <= MAX_REMOTE_EVIDENCE_INSTANCE
271 && self.instance.iter().all(|i| t(i))
272 && self.exchange.is_bounded(max)
273 && o(&self.requested_identity)
274 && o(&self.served.identity)
275 && t(&self.protocol)
276 && t(&self.endpoint_class)
277 && self.batch.as_ref().is_none_or(|b| t(&b.batch_id))
278 && o(&self.principal_digest)
279 }
280
281 pub fn bounded(self) -> Self {
285 let e = self.cut(MAX_TEXT_BYTES);
286 if e.is_bounded() { e } else { e.cut(32) }
287 }
288
289 fn cut(mut self, max: usize) -> Self {
290 let c = |s: &mut String| {
291 if s.len() > max {
292 *s = crate::remote::cut_to(s, max);
293 }
294 };
295 c(&mut self.attempt_id);
296 c(&mut self.step);
297 self.instance.truncate(MAX_REMOTE_EVIDENCE_INSTANCE);
298 self.instance.iter_mut().for_each(c);
299 if let ExchangeReference::Retained {
300 digest,
301 schema,
302 locator,
303 ..
304 } = &mut self.exchange
305 {
306 c(digest);
307 c(schema);
308 c(locator);
309 }
310 if let Some(r) = &mut self.requested_identity {
311 c(r);
312 }
313 if let Some(s) = &mut self.served.identity {
314 c(s);
315 }
316 c(&mut self.protocol);
317 c(&mut self.endpoint_class);
318 if let Some(b) = &mut self.batch {
319 c(&mut b.batch_id);
320 }
321 if let Some(p) = &mut self.principal_digest {
322 c(p);
323 }
324 self
325 }
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
330#[serde(rename_all = "snake_case", deny_unknown_fields)]
331pub enum ExchangeReference {
332 Retained {
333 digest: String,
335 schema: String,
337 retention: RetentionClass,
338 locator: String,
340 },
341 Unavailable {
342 reason: EvidenceUnavailable,
343 },
344}
345
346impl ExchangeReference {
347 fn is_bounded(&self, max: usize) -> bool {
348 match self {
349 ExchangeReference::Retained {
350 digest,
351 schema,
352 locator,
353 ..
354 } => [digest, schema, locator].iter().all(|s| s.len() <= max),
355 ExchangeReference::Unavailable { .. } => true,
356 }
357 }
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
362#[serde(rename_all = "snake_case")]
363pub enum RetentionClass {
364 DigestOnly,
366 BytesRetained,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
373#[serde(rename_all = "snake_case")]
374pub enum EvidenceUnavailable {
375 NoExchange,
377 Pending,
379 NotDelivered,
381 NoLocator,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
387#[serde(rename_all = "snake_case")]
388pub enum ChargeProvenance {
389 Reported,
391 Attributed,
393 NothingSent,
395 Unknown,
397}
398
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
401#[serde(deny_unknown_fields)]
402pub struct BatchMembership {
403 pub batch_id: String,
404 pub index: u32,
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
408#[serde(rename_all = "snake_case", deny_unknown_fields)]
409pub enum RecordedExecution {
410 None,
411 Declared(ExecutionPolicyId),
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
415#[serde(rename_all = "snake_case")]
416pub enum Termination {
417 Judged,
419 Cancelled,
421}
422
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
424#[serde(rename_all = "snake_case", deny_unknown_fields)]
425pub enum CoreEvidence {
426 Recorded(Box<EvidenceRecord>),
428 NotProduced,
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
433#[serde(deny_unknown_fields)]
434pub struct Timing {
435 pub queued_ms: u64,
436 pub elapsed_ms: u64,
437 pub deadline_ms: u64,
438 pub deadline_expired: bool,
439}
440
441#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
442#[serde(deny_unknown_fields)]
443pub struct RequestRecord {
444 pub step: String,
445 pub instance: Vec<String>,
446 pub result: RequestResult,
447 pub attempts: Vec<AttemptRecord>,
448 pub transitions: Vec<Transition>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub optimization: Option<OptimizationRecord>,
452 #[serde(
454 default,
455 skip_serializing_if = "Option::is_none",
456 deserialize_with = "crate::screening::present"
457 )]
458 #[schemars(with = "crate::screening::ScreenRequest")]
459 pub screen: Option<crate::screening::ScreenRequest>,
460}
461
462#[derive(Debug, Clone, Copy, PartialEq)]
464pub struct SuppliedFrom<'r> {
465 pub target: u32,
468 pub attempt: Option<&'r AttemptRecord>,
472 pub reused_from: Option<&'r str>,
476}
477
478impl RequestRecord {
479 pub fn supplied_from(&self) -> Option<SuppliedFrom<'_>> {
482 match &self.result {
483 RequestResult::NotSupplied | RequestResult::Withdrawn => None,
484 RequestResult::Output { target } => Some(SuppliedFrom {
485 target: *target,
486 attempt: self.attempts.last(),
487 reused_from: self
488 .optimization
489 .as_ref()
490 .filter(|o| self.attempts.is_empty() && o.outcome == OptimizationOutcome::Hit)
491 .and_then(|o| o.source_attempt_id.as_deref()),
492 }),
493 RequestResult::Failed(_) => {
494 let target = self
495 .transitions
496 .iter()
497 .rev()
498 .find_map(|t| match t {
499 Transition::Fallback { to, .. } => Some(*to),
500 _ => None,
501 })
502 .unwrap_or(0);
503 let n = self.attempts.len() as u32;
504 let moved_on = self.transitions.iter().any(|t| {
505 matches!(t, Transition::Retry { after, .. } | Transition::Fallback { after, .. } if *after == n)
506 });
507 Some(SuppliedFrom {
508 target,
509 attempt: if moved_on { None } else { self.attempts.last() },
510 reused_from: None,
511 })
512 }
513 }
514 }
515}
516
517#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
518#[serde(rename_all = "snake_case", deny_unknown_fields)]
519pub enum RequestResult {
520 Output { target: u32 },
522 Failed(Unresolved),
524 NotSupplied,
526 Withdrawn,
528}
529
530#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
531#[serde(deny_unknown_fields)]
532pub struct AttemptRecord {
533 pub attempt_id: String,
536 pub target: u32,
537 pub backend_id: String,
538 pub artifact: ArtifactId,
539 pub dispatched_ms: u64,
540 pub ended_ms: u64,
541 pub end: AttemptEnd,
542 pub cancellation: Cancellation,
543 pub remote: RemoteState,
544 pub cost: AttemptCost,
545}
546
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
548#[serde(rename_all = "snake_case", deny_unknown_fields)]
549pub enum AttemptEnd {
550 Output,
552 Failed {
553 class: FailureClass,
554 detail: String,
555 },
556 Deadline,
558 Cancelled,
560}
561
562#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
564#[serde(rename_all = "snake_case", deny_unknown_fields)]
565pub enum Cancellation {
566 NotRequested,
567 Requested { answer: CancelAnswer },
568}
569
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
571#[serde(rename_all = "snake_case")]
572pub enum CancelAnswer {
573 Stopped,
575 Unconfirmed,
577 NotObserved,
579}
580
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
582#[serde(rename_all = "snake_case")]
583pub enum RemoteState {
584 Finished,
586 Stopped,
588 PossiblyContinuing,
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
593#[serde(deny_unknown_fields)]
594pub struct AttemptCost {
595 pub bound: CostBound,
596 pub reserved: u64,
597 pub charge: Charge,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
602#[serde(rename_all = "snake_case")]
603pub enum CostModel {
604 Bounded,
606 Estimated,
608 Unknown,
609}
610
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
613#[serde(rename_all = "snake_case", deny_unknown_fields)]
614pub enum CostBound {
615 Bounded {
617 max_units: u64,
618 },
619 Estimated {
620 units: u64,
621 },
622 Unknown,
623}
624
625#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
627#[serde(rename_all = "snake_case", deny_unknown_fields)]
628pub enum Charge {
629 Observed { units: u64 },
630 Estimated { units: u64 },
631 Unknown,
632}
633
634#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
635#[serde(rename_all = "snake_case", deny_unknown_fields)]
636pub enum Transition {
637 Retry {
639 after: u32,
640 class: FailureClass,
641 delay_ms: u64,
642 },
643 Fallback {
645 after: u32,
646 class: FailureClass,
647 to: u32,
648 },
649 Stop { after: u32, reason: String },
651}
652
653#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
654#[serde(rename_all = "snake_case")]
655pub enum CostMode {
656 Unlimited,
657 Hard,
658 Estimated,
659}
660
661#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
663#[serde(deny_unknown_fields)]
664pub struct CostSummary {
665 pub mode: CostMode,
666 pub limit: u64,
668 pub observed: u64,
669 pub estimated: u64,
670 pub liability: u64,
672 pub bound_violations: u64,
674 pub final_cost: FinalCost,
675 pub within_guaranteed_cap: bool,
677}
678
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
680#[serde(rename_all = "snake_case")]
681pub enum FinalCost {
682 Known,
683 IncludesEstimates,
684 Unknown,
685}