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 }
133 let v2 = self.schema == schema::RUN_V2;
134 match &self.remote_evidence {
135 None if v2 => Err(RunVersionError::Members(
136 "rustev.run/2 needs remote_evidence",
137 )),
138 Some(_) if !v2 => Err(RunVersionError::Members(
139 "remote_evidence is a rustev.run/2 member",
140 )),
141 _ if self.remote_evidence_truncated && !v2 => Err(RunVersionError::Members(
142 "remote_evidence_truncated is a rustev.run/2 member",
143 )),
144 Some(list) if list.len() > MAX_REMOTE_EVIDENCE => {
145 Err(RunVersionError::Members("too many remote evidence entries"))
146 }
147 Some(list) if !list.iter().all(RemoteEvidence::is_bounded) => Err(
148 RunVersionError::Members("a remote evidence entry beyond its bounds"),
149 ),
150 _ => Ok(()),
151 }
152 }
153
154 fn parsed_members_fit(&self) -> Result<(), String> {
159 if let CoreEvidence::Recorded(e) = &self.core {
160 e.screening_version()?;
161 if self.schema != schema::RUN_V3
162 && (e.extraction.is_some()
163 || e.acceptance.is_some()
164 || e.inputs.iter().any(|i| i.producer.is_some())
165 || e.judgment.lineage.producers.is_some())
166 {
167 return Err("extraction evidence requires rustev.run/3".into());
168 }
169 }
170 if let CoreEvidence::Recorded(e) = &self.core
171 && e.generation.is_some()
172 && (self.schema != schema::RUN_V3 || e.schema != schema::EVIDENCE_V2)
173 {
174 return Err("generation evidence requires rustev.run/3 and rustev.evidence/2".into());
175 }
176 if let CoreEvidence::Recorded(e) = &self.core
177 && (self.schema != schema::RUN_V3 || e.schema != schema::EVIDENCE_V2)
178 && (e.acquisitions.is_some() || e.steps.iter().any(|s| s.constructed))
179 {
180 return Err("acquisition evidence requires rustev.evidence/2".into());
181 }
182
183 self.check_version_members().map_err(|e| e.to_string())
184 }
185
186 pub fn project_v1(&self) -> Result<RunRecord, RunVersionError> {
192 if self.schema != schema::RUN_V2 {
193 return Err(RunVersionError::NotVersion2);
194 }
195 self.check_version_members()?;
196 Ok(RunRecord {
197 schema: schema::RUN.into(),
198 remote_evidence: None,
199 remote_evidence_truncated: false,
200 ..self.clone()
201 })
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
210#[serde(deny_unknown_fields)]
211pub struct RemoteEvidence {
212 pub attempt_id: String,
213 pub step: String,
215 pub instance: Vec<String>,
216 pub exchange: ExchangeReference,
217 pub requested_identity: Option<String>,
219 pub served: ServedIdentity,
220 pub binding: ArtifactId,
223 pub protocol: String,
224 pub endpoint_class: String,
225 pub code: Option<RemoteCode>,
227 pub remote_state: RemoteState,
229 pub charge_provenance: ChargeProvenance,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub batch: Option<BatchMembership>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub principal_digest: Option<String>,
236}
237
238impl RemoteEvidence {
239 pub fn is_bounded(&self) -> bool {
243 self.strings_within(MAX_TEXT_BYTES)
244 && crate::canonical::canonical_bytes(self)
245 .is_ok_and(|b| b.len() <= MAX_REMOTE_EVIDENCE_ENTRY_BYTES)
246 }
247
248 fn strings_within(&self, max: usize) -> bool {
249 let t = |s: &str| s.len() <= max;
250 let o = |s: &Option<String>| s.as_deref().is_none_or(t);
251 t(&self.attempt_id)
252 && t(&self.step)
253 && self.instance.len() <= MAX_REMOTE_EVIDENCE_INSTANCE
254 && self.instance.iter().all(|i| t(i))
255 && self.exchange.is_bounded(max)
256 && o(&self.requested_identity)
257 && o(&self.served.identity)
258 && t(&self.protocol)
259 && t(&self.endpoint_class)
260 && self.batch.as_ref().is_none_or(|b| t(&b.batch_id))
261 && o(&self.principal_digest)
262 }
263
264 pub fn bounded(self) -> Self {
268 let e = self.cut(MAX_TEXT_BYTES);
269 if e.is_bounded() { e } else { e.cut(32) }
270 }
271
272 fn cut(mut self, max: usize) -> Self {
273 let c = |s: &mut String| {
274 if s.len() > max {
275 *s = crate::remote::cut_to(s, max);
276 }
277 };
278 c(&mut self.attempt_id);
279 c(&mut self.step);
280 self.instance.truncate(MAX_REMOTE_EVIDENCE_INSTANCE);
281 self.instance.iter_mut().for_each(c);
282 if let ExchangeReference::Retained {
283 digest,
284 schema,
285 locator,
286 ..
287 } = &mut self.exchange
288 {
289 c(digest);
290 c(schema);
291 c(locator);
292 }
293 if let Some(r) = &mut self.requested_identity {
294 c(r);
295 }
296 if let Some(s) = &mut self.served.identity {
297 c(s);
298 }
299 c(&mut self.protocol);
300 c(&mut self.endpoint_class);
301 if let Some(b) = &mut self.batch {
302 c(&mut b.batch_id);
303 }
304 if let Some(p) = &mut self.principal_digest {
305 c(p);
306 }
307 self
308 }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
313#[serde(rename_all = "snake_case", deny_unknown_fields)]
314pub enum ExchangeReference {
315 Retained {
316 digest: String,
318 schema: String,
320 retention: RetentionClass,
321 locator: String,
323 },
324 Unavailable {
325 reason: EvidenceUnavailable,
326 },
327}
328
329impl ExchangeReference {
330 fn is_bounded(&self, max: usize) -> bool {
331 match self {
332 ExchangeReference::Retained {
333 digest,
334 schema,
335 locator,
336 ..
337 } => [digest, schema, locator].iter().all(|s| s.len() <= max),
338 ExchangeReference::Unavailable { .. } => true,
339 }
340 }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
345#[serde(rename_all = "snake_case")]
346pub enum RetentionClass {
347 DigestOnly,
349 BytesRetained,
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
356#[serde(rename_all = "snake_case")]
357pub enum EvidenceUnavailable {
358 NoExchange,
360 Pending,
362 NotDelivered,
364 NoLocator,
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
370#[serde(rename_all = "snake_case")]
371pub enum ChargeProvenance {
372 Reported,
374 Attributed,
376 NothingSent,
378 Unknown,
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
384#[serde(deny_unknown_fields)]
385pub struct BatchMembership {
386 pub batch_id: String,
387 pub index: u32,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
391#[serde(rename_all = "snake_case", deny_unknown_fields)]
392pub enum RecordedExecution {
393 None,
394 Declared(ExecutionPolicyId),
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
398#[serde(rename_all = "snake_case")]
399pub enum Termination {
400 Judged,
402 Cancelled,
404}
405
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
407#[serde(rename_all = "snake_case", deny_unknown_fields)]
408pub enum CoreEvidence {
409 Recorded(Box<EvidenceRecord>),
411 NotProduced,
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
416#[serde(deny_unknown_fields)]
417pub struct Timing {
418 pub queued_ms: u64,
419 pub elapsed_ms: u64,
420 pub deadline_ms: u64,
421 pub deadline_expired: bool,
422}
423
424#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
425#[serde(deny_unknown_fields)]
426pub struct RequestRecord {
427 pub step: String,
428 pub instance: Vec<String>,
429 pub result: RequestResult,
430 pub attempts: Vec<AttemptRecord>,
431 pub transitions: Vec<Transition>,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub optimization: Option<OptimizationRecord>,
435 #[serde(
437 default,
438 skip_serializing_if = "Option::is_none",
439 deserialize_with = "crate::screening::present"
440 )]
441 #[schemars(with = "crate::screening::ScreenRequest")]
442 pub screen: Option<crate::screening::ScreenRequest>,
443}
444
445#[derive(Debug, Clone, Copy, PartialEq)]
447pub struct SuppliedFrom<'r> {
448 pub target: u32,
451 pub attempt: Option<&'r AttemptRecord>,
455 pub reused_from: Option<&'r str>,
459}
460
461impl RequestRecord {
462 pub fn supplied_from(&self) -> Option<SuppliedFrom<'_>> {
465 match &self.result {
466 RequestResult::NotSupplied | RequestResult::Withdrawn => None,
467 RequestResult::Output { target } => Some(SuppliedFrom {
468 target: *target,
469 attempt: self.attempts.last(),
470 reused_from: self
471 .optimization
472 .as_ref()
473 .filter(|o| self.attempts.is_empty() && o.outcome == OptimizationOutcome::Hit)
474 .and_then(|o| o.source_attempt_id.as_deref()),
475 }),
476 RequestResult::Failed(_) => {
477 let target = self
478 .transitions
479 .iter()
480 .rev()
481 .find_map(|t| match t {
482 Transition::Fallback { to, .. } => Some(*to),
483 _ => None,
484 })
485 .unwrap_or(0);
486 let n = self.attempts.len() as u32;
487 let moved_on = self.transitions.iter().any(|t| {
488 matches!(t, Transition::Retry { after, .. } | Transition::Fallback { after, .. } if *after == n)
489 });
490 Some(SuppliedFrom {
491 target,
492 attempt: if moved_on { None } else { self.attempts.last() },
493 reused_from: None,
494 })
495 }
496 }
497 }
498}
499
500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
501#[serde(rename_all = "snake_case", deny_unknown_fields)]
502pub enum RequestResult {
503 Output { target: u32 },
505 Failed(Unresolved),
507 NotSupplied,
509 Withdrawn,
511}
512
513#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
514#[serde(deny_unknown_fields)]
515pub struct AttemptRecord {
516 pub attempt_id: String,
519 pub target: u32,
520 pub backend_id: String,
521 pub artifact: ArtifactId,
522 pub dispatched_ms: u64,
523 pub ended_ms: u64,
524 pub end: AttemptEnd,
525 pub cancellation: Cancellation,
526 pub remote: RemoteState,
527 pub cost: AttemptCost,
528}
529
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
531#[serde(rename_all = "snake_case", deny_unknown_fields)]
532pub enum AttemptEnd {
533 Output,
535 Failed {
536 class: FailureClass,
537 detail: String,
538 },
539 Deadline,
541 Cancelled,
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
547#[serde(rename_all = "snake_case", deny_unknown_fields)]
548pub enum Cancellation {
549 NotRequested,
550 Requested { answer: CancelAnswer },
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
554#[serde(rename_all = "snake_case")]
555pub enum CancelAnswer {
556 Stopped,
558 Unconfirmed,
560 NotObserved,
562}
563
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
565#[serde(rename_all = "snake_case")]
566pub enum RemoteState {
567 Finished,
569 Stopped,
571 PossiblyContinuing,
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
576#[serde(deny_unknown_fields)]
577pub struct AttemptCost {
578 pub bound: CostBound,
579 pub reserved: u64,
580 pub charge: Charge,
581}
582
583#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
585#[serde(rename_all = "snake_case")]
586pub enum CostModel {
587 Bounded,
589 Estimated,
591 Unknown,
592}
593
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
596#[serde(rename_all = "snake_case", deny_unknown_fields)]
597pub enum CostBound {
598 Bounded {
600 max_units: u64,
601 },
602 Estimated {
603 units: u64,
604 },
605 Unknown,
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
610#[serde(rename_all = "snake_case", deny_unknown_fields)]
611pub enum Charge {
612 Observed { units: u64 },
613 Estimated { units: u64 },
614 Unknown,
615}
616
617#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
618#[serde(rename_all = "snake_case", deny_unknown_fields)]
619pub enum Transition {
620 Retry {
622 after: u32,
623 class: FailureClass,
624 delay_ms: u64,
625 },
626 Fallback {
628 after: u32,
629 class: FailureClass,
630 to: u32,
631 },
632 Stop { after: u32, reason: String },
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
637#[serde(rename_all = "snake_case")]
638pub enum CostMode {
639 Unlimited,
640 Hard,
641 Estimated,
642}
643
644#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
646#[serde(deny_unknown_fields)]
647pub struct CostSummary {
648 pub mode: CostMode,
649 pub limit: u64,
651 pub observed: u64,
652 pub estimated: u64,
653 pub liability: u64,
655 pub bound_violations: u64,
657 pub final_cost: FinalCost,
658 pub within_guaranteed_cap: bool,
660}
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
663#[serde(rename_all = "snake_case")]
664pub enum FinalCost {
665 Known,
666 IncludesEstimates,
667 Unknown,
668}