Skip to main content

rustev_live/
documents.rs

1//! Canonical live-run inputs and durable checkpoints (spec 043, increment 2).
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5use std::fs::{File, OpenOptions};
6use std::io::{Read, Write};
7use std::path::Path;
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use rustev_contract::definition::Operation;
11use rustev_contract::ids::{ArtifactId, ContentDigest, DatasetId, PlanId, SnapshotId};
12use rustev_contract::limits::REPLAY_V1;
13use rustev_contract::{Document, Identified, Timestamp};
14use serde::{Deserialize, Serialize};
15
16pub use rustev_contract::ids::LiveConfigId;
17
18/// A CLI-compatible refusal. Details contain no credentials.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct LiveError {
21    pub status: &'static str,
22    pub subject: &'static str,
23    pub detail: String,
24}
25
26impl LiveError {
27    pub(crate) fn invalid(subject: &'static str, detail: impl fmt::Display) -> Self {
28        Self {
29            status: "invalid_input",
30            subject,
31            detail: detail.to_string(),
32        }
33    }
34
35    fn config(detail: impl fmt::Display) -> Self {
36        Self::invalid("live-config", detail)
37    }
38
39    pub(crate) fn io(detail: impl fmt::Display) -> Self {
40        Self {
41            status: "io_error",
42            subject: "checkpoint",
43            detail: detail.to_string(),
44        }
45    }
46}
47
48impl fmt::Display for LiveError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(f, "{}: {}: {}", self.status, self.subject, self.detail)
51    }
52}
53impl std::error::Error for LiveError {}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Mode {
57    Cli,
58    Library,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum Budget {
64    Testing,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case", deny_unknown_fields)]
69pub enum EvaluationTime {
70    Source,
71    Fixed { ms: Timestamp },
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum CostUnit {
77    NanoUsd,
78    None,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum AcquisitionMode {
84    Live,
85    Captured,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct BindingIdentity {
91    pub backend_id: String,
92    pub artifact: ArtifactId,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct Binding {
98    pub identity: BindingIdentity,
99    pub cost_unit: CostUnit,
100    pub max_in_flight: u64,
101    pub min_interval_ms: u64,
102    pub case_deadline_ms: u64,
103    pub rate_limit_pause_max_ms: u64,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum ProbeProvenance {
109    Synthetic,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct Probe {
115    #[serde(default = "default_probe_calls")]
116    pub calls: u64,
117    pub interval_ms: u64,
118    pub snapshot: String,
119    pub digest: SnapshotId,
120    pub provenance: ProbeProvenance,
121}
122fn default_probe_calls() -> u64 {
123    3
124}
125
126/// Dispatch rules are evidence of the CLI host's choice, never permission
127/// for a downstream action (spec 043, 3.12).
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct AuthorizationRule {
131    pub backend_id: String,
132    pub artifact: ArtifactId,
133    pub operations: Vec<Operation>,
134    pub max_exposure_units: u64,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct LiveConfig {
140    pub schema: String,
141    pub budget: Budget,
142    pub run_cap_units: u64,
143    pub repeats: u64,
144    pub evaluation_time: EvaluationTime,
145    pub bindings: Vec<Binding>,
146    pub acquisitions: BTreeMap<String, AcquisitionMode>,
147    pub probe: Probe,
148    #[serde(
149        default,
150        skip_serializing_if = "Option::is_none",
151        deserialize_with = "present_authorize"
152    )]
153    pub authorize: Option<Vec<AuthorizationRule>>,
154    #[serde(default)]
155    pub compare_with_source: bool,
156}
157
158// A present null is not absence: the CLI requires a rule list and the
159// library requires the member to be absent (spec 043, 3.2.2).
160fn present_authorize<'de, D: serde::Deserializer<'de>>(
161    d: D,
162) -> Result<Option<Vec<AuthorizationRule>>, D::Error> {
163    Vec::deserialize(d).map(Some)
164}
165
166macro_rules! live_document {
167    ($ty:ty, $schema:literal) => {
168        impl Document for $ty {
169            const SCHEMA: &'static str = $schema;
170            const LIMITS: rustev_contract::bounded::ParseLimits = REPLAY_V1;
171            fn schema(&self) -> &str {
172                &self.schema
173            }
174        }
175    };
176}
177live_document!(LiveConfig, "rustev.live-config/1");
178impl Identified for LiveConfig {
179    type Id = LiveConfigId;
180    fn wrap(digest: String) -> Self::Id {
181        LiveConfigId::parse(&digest).expect("canonical digest is a validated identity")
182    }
183}
184
185/// Candidate names and the union of primary and fallback bindings reachable
186/// from each compiled plan. Resolving plans is the caller's responsibility.
187pub type Candidates = BTreeMap<String, Vec<BindingIdentity>>;
188
189impl LiveConfig {
190    /// Bounded parsing with the live input refusal vocabulary.
191    /// A probe whose provenance is absent or not `synthetic` is refused
192    /// `probe-snapshot` (spec 043, 3.7.2); every other defect `live-config`.
193    pub fn parse(bytes: &[u8]) -> Result<Self, LiveError> {
194        <Self as Document>::parse(bytes).map_err(|e| {
195            if probe_provenance_undeclared(bytes) {
196                LiveError::invalid("probe-snapshot", "probe.provenance must be synthetic")
197            } else {
198                LiveError::config(e)
199            }
200        })
201    }
202
203    pub fn check(&self, mode: Mode, candidates: &Candidates) -> Result<(), LiveError> {
204        if self.schema != Self::SCHEMA {
205            return Err(LiveError::config("wrong schema"));
206        }
207        if (mode == Mode::Cli) != self.authorize.is_some() {
208            return Err(LiveError::config("authorize is required only in CLI mode"));
209        }
210        if self.run_cap_units == 0
211            || !(1..=64).contains(&self.repeats)
212            || !(1..=5).contains(&self.probe.calls)
213            || self.probe.interval_ms > 60_000
214        {
215            return Err(LiveError::config("run or probe value out of range"));
216        }
217        let mut names = BTreeSet::new();
218        for name in candidates.keys() {
219            component(name, 32, true)?;
220            if !names.insert(name.to_ascii_lowercase()) {
221                return Err(LiveError::config(
222                    "candidate names collide ignoring ASCII case",
223                ));
224            }
225        }
226        let reached: BTreeSet<_> = candidates.values().flatten().collect();
227        let mut configured = BTreeSet::new();
228        for binding in &self.bindings {
229            if !configured.insert(&binding.identity) {
230                return Err(LiveError::config("duplicate binding"));
231            }
232            if !(1..=64).contains(&binding.max_in_flight)
233                || binding.min_interval_ms > 600_000
234                || !(1..=3_600_000).contains(&binding.case_deadline_ms)
235                || binding.rate_limit_pause_max_ms > 600_000
236            {
237                return Err(LiveError::config("binding value out of range"));
238            }
239        }
240        if configured != reached {
241            return Err(LiveError::config(
242                "configured bindings differ from reachable bindings",
243            ));
244        }
245        Ok(())
246    }
247}
248
249// Consulted only for a refused document within the parse bound, the same
250// bound `LiveConfig::parse` applies (`<LiveConfig as Document>::LIMITS`).
251fn probe_provenance_undeclared(bytes: &[u8]) -> bool {
252    bytes.len() <= <LiveConfig as Document>::LIMITS.max_bytes
253        && serde_json::from_slice::<serde_json::Value>(bytes)
254            .ok()
255            .and_then(|v| {
256                let probe = v.get("probe")?.as_object()?;
257                Some(probe.get("provenance").and_then(|p| p.as_str()) != Some("synthetic"))
258            })
259            .unwrap_or(false)
260}
261
262// Spec 006, 3.9.1. The eval candidate checker is crate-private and the
263// CLI/surface layer is above live, so neither validator is reachable here.
264fn component(value: &str, max: usize, starts_alphanumeric: bool) -> Result<(), LiveError> {
265    if value.is_empty()
266        || value.len() > max
267        || (starts_alphanumeric && !value.as_bytes()[0].is_ascii_alphanumeric())
268        || !value
269            .bytes()
270            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
271    {
272        return Err(LiveError::config("invalid decision id component"));
273    }
274    Ok(())
275}
276
277/// Build a decision id before opening a journal (spec 043, 3.3.4).
278pub fn decision_id(
279    run_id: &str,
280    candidate: &str,
281    repeat: u64,
282    case_id: &str,
283    rerun: Option<u64>,
284) -> Result<String, LiveError> {
285    component(run_id, 64, false)?;
286    component(candidate, 32, true)?;
287    component(case_id, 128, true)?;
288    if !(1..=64).contains(&repeat) || rerun.is_some_and(|k| !(1..=99).contains(&k)) {
289        return Err(LiveError::config("repeat or rerun out of range"));
290    }
291    let mut id = format!("live/{run_id}/{candidate}/{repeat}/{case_id}");
292    if let Some(k) = rerun {
293        id.push_str(&format!("/rerun-{k}"));
294    }
295    Ok(id)
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct CandidateBinding {
301    pub name: String,
302    pub plan_id: PlanId,
303    pub bindings: Vec<BindingIdentity>,
304}
305
306/// Every value whose equality is required before a checkpoint can resume.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(deny_unknown_fields)]
309pub struct CheckpointBindings {
310    pub run_id: String,
311    pub live_config_id: LiveConfigId,
312    pub dataset_id: DatasetId,
313    pub split: String,
314    pub candidates: Vec<CandidateBinding>,
315    pub journal_header_digest: ContentDigest,
316    pub input_digests: BTreeMap<String, ContentDigest>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case", deny_unknown_fields)]
321pub enum ItemState {
322    Pending,
323    Completed {
324        live_document_digest: ContentDigest,
325        run_record_digest: ContentDigest,
326    },
327    Unavailable {
328        reason: String,
329    },
330    Refused {
331        code: String,
332    },
333    Interrupted {
334        attempt_ids: Vec<String>,
335    },
336    /// Persist before the first reservation. Earlier reruns stay in history.
337    Rerun {
338        k: u64,
339    },
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct CheckpointItem {
345    pub candidate: String,
346    pub repeat: u64,
347    pub case_id: String,
348    /// Ordered history preserves every rerun number across interruptions.
349    pub states: Vec<ItemState>,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(deny_unknown_fields)]
354pub struct LiveCheckpoint {
355    pub schema: String,
356    pub bindings: CheckpointBindings,
357    pub items: Vec<CheckpointItem>,
358}
359live_document!(LiveCheckpoint, "rustev.live-checkpoint/1");
360
361/// The companion evidence for one evaluation item or probe (spec 043, 3.9.1).
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(deny_unknown_fields)]
364pub struct LiveCaseDocument {
365    pub schema: String,
366    pub mode: CaseMode,
367    pub purpose: CasePurpose,
368    pub source: CaseSource,
369    pub run: CaseRun,
370    pub evaluation_time: CaseEvaluationTime,
371    pub acquisitions: BTreeMap<String, CaseAcquisition>,
372    pub spend: Vec<CaseSpend>,
373    pub declared_no_spend: Vec<BindingIdentity>,
374    pub live_document_digest: ContentDigest,
375    pub run_record_digest: ContentDigest,
376}
377live_document!(LiveCaseDocument, "rustev.live-case/1");
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum CaseMode {
382    LiveReexecution,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(rename_all = "snake_case")]
387pub enum CasePurpose {
388    Evaluation,
389    Probe,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393#[serde(rename_all = "snake_case", deny_unknown_fields)]
394pub enum CaseSource {
395    None,
396    DatasetCase {
397        dataset_id: DatasetId,
398        case_id: String,
399        split: String,
400    },
401    ReplayBundle {
402        bundle_digest: ContentDigest,
403        decision_id: String,
404    },
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct CaseRun {
410    pub run_id: String,
411    pub live_config_id: LiveConfigId,
412    pub candidate: String,
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub repeat: Option<u64>,
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub rerun: Option<u64>,
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum CaseEvaluationTime {
422    Source,
423    Fixed,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(rename_all = "snake_case", deny_unknown_fields)]
428pub enum CaseAcquisition {
429    Live,
430    Captured { capture_digests: Vec<ContentDigest> },
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(rename_all = "snake_case", deny_unknown_fields)]
435pub enum CaseSettlement {
436    Observed { units: u64 },
437    Estimated { units: u64 },
438    Unknown,
439    Released,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(deny_unknown_fields)]
444pub struct CaseRefusal {
445    pub code: String,
446}
447
448/// Untagged on the wire. Serde does not apply `deny_unknown_fields` to an
449/// untagged enum, so parsing goes through [`CaseSpendWire`], whose variant
450/// structs each refuse unknown fields.
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(untagged, from = "CaseSpendWire")]
453pub enum CaseSpend {
454    Reserved {
455        attempt_id: String,
456        seq: u64,
457        reserved_units: u64,
458        settlement: CaseSettlement,
459    },
460    Refused {
461        attempt_id: String,
462        refused: CaseRefusal,
463    },
464}
465
466#[derive(Deserialize)]
467#[serde(deny_unknown_fields)]
468struct ReservedWire {
469    attempt_id: String,
470    seq: u64,
471    reserved_units: u64,
472    settlement: CaseSettlement,
473}
474
475#[derive(Deserialize)]
476#[serde(deny_unknown_fields)]
477struct RefusedWire {
478    attempt_id: String,
479    refused: CaseRefusal,
480}
481
482#[derive(Deserialize)]
483#[serde(untagged)]
484enum CaseSpendWire {
485    Reserved(ReservedWire),
486    Refused(RefusedWire),
487}
488
489impl From<CaseSpendWire> for CaseSpend {
490    fn from(wire: CaseSpendWire) -> Self {
491        match wire {
492            CaseSpendWire::Reserved(r) => CaseSpend::Reserved {
493                attempt_id: r.attempt_id,
494                seq: r.seq,
495                reserved_units: r.reserved_units,
496                settlement: r.settlement,
497            },
498            CaseSpendWire::Refused(r) => CaseSpend::Refused {
499                attempt_id: r.attempt_id,
500                refused: r.refused,
501            },
502        }
503    }
504}
505
506impl LiveCaseDocument {
507    pub fn check(&self) -> Result<(), LiveError> {
508        use rustev_contract::live::{MAX_EFFECTS, MAX_TEXT_BYTES};
509        let invalid = || LiveError::invalid("live-case", "invalid identity, purpose or bound");
510        let text = |s: &str| !s.is_empty() && s.len() <= MAX_TEXT_BYTES;
511        if self.schema != Self::SCHEMA
512            || self.spend.len() > MAX_EFFECTS
513            || self.acquisitions.len() > MAX_EFFECTS
514            || self.declared_no_spend.len() > MAX_EFFECTS
515            || self.run.repeat.is_some_and(|n| !(1..=64).contains(&n))
516            || self.run.rerun.is_some_and(|n| !(1..=99).contains(&n))
517        {
518            return Err(invalid());
519        }
520        component(&self.run.run_id, 64, false).map_err(|_| invalid())?;
521        component(&self.run.candidate, 32, true).map_err(|_| invalid())?;
522        match (&self.purpose, &self.source) {
523            (CasePurpose::Probe, CaseSource::None)
524                if self.run.repeat.is_none() && self.run.rerun.is_none() => {}
525            (CasePurpose::Evaluation, CaseSource::DatasetCase { case_id, split, .. })
526                if self.run.repeat.is_some() && text(split) =>
527            {
528                component(case_id, 128, true).map_err(|_| invalid())?;
529            }
530            (CasePurpose::Evaluation, CaseSource::ReplayBundle { decision_id, .. })
531                if self.run.repeat.is_some() && text(decision_id) => {}
532            _ => return Err(invalid()),
533        }
534        let mut seqs = BTreeSet::new();
535        let mut refusals = BTreeSet::new();
536        for entry in &self.spend {
537            let attempt = match entry {
538                CaseSpend::Reserved {
539                    attempt_id, seq, ..
540                } => {
541                    if !seqs.insert(seq) {
542                        return Err(invalid());
543                    }
544                    attempt_id
545                }
546                CaseSpend::Refused {
547                    attempt_id,
548                    refused,
549                } => {
550                    if !text(&refused.code) {
551                        return Err(invalid());
552                    }
553                    attempt_id
554                }
555            };
556            if attempt.is_empty() || attempt.len() > 4 * MAX_TEXT_BYTES {
557                return Err(invalid());
558            }
559        }
560        // A refusal is listed once, and never for an attempt with a
561        // reservation. Reservations are keyed by journal sequence: a probe
562        // decision repeated on resume can reserve the same attempt id again.
563        let reserved: BTreeSet<&str> = self
564            .spend
565            .iter()
566            .filter_map(|s| match s {
567                CaseSpend::Reserved { attempt_id, .. } => Some(attempt_id.as_str()),
568                CaseSpend::Refused { .. } => None,
569            })
570            .collect();
571        for entry in &self.spend {
572            if let CaseSpend::Refused { attempt_id, .. } = entry
573                && (reserved.contains(attempt_id.as_str()) || !refusals.insert(attempt_id))
574            {
575                return Err(invalid());
576            }
577        }
578        for (source, acquisition) in &self.acquisitions {
579            if !text(source)
580                || matches!(acquisition, CaseAcquisition::Captured { capture_digests } if capture_digests.len() > MAX_EFFECTS)
581            {
582                return Err(invalid());
583            }
584        }
585        let mut bindings = BTreeSet::new();
586        for binding in &self.declared_no_spend {
587            if !text(&binding.backend_id) || !bindings.insert(binding) {
588                return Err(invalid());
589            }
590        }
591        Ok(())
592    }
593
594    pub(crate) fn from_result(
595        config: &LiveConfig,
596        run: CaseRun,
597        source: CaseSource,
598        journal: &crate::journal::SpendJournal,
599        result: &rustev_runtime::LiveResult,
600        sources: &rustev_runtime::SourceOutcome,
601    ) -> Result<Self, LiveError> {
602        use rustev_contract::live::{AuthorizationOutcome, Dispatch};
603        let mut spend = journal.reservations_detail(&result.document.decision_id);
604        for effect in &result.document.effects {
605            if spend.iter().any(|s| matches!(s, CaseSpend::Reserved { attempt_id, .. } if attempt_id == &effect.attempt_id)) {
606                continue;
607            }
608            let code = match &effect.authorization {
609                AuthorizationOutcome::Refused { .. } => Some("authorization_refused"),
610                AuthorizationOutcome::Allowed { .. }
611                    if effect.dispatch == Dispatch::NotDispatched =>
612                {
613                    result.spend_refusal.as_ref().map(|r| r.code.as_str())
614                }
615                _ => None,
616            };
617            if let Some(code) = code {
618                spend.push(CaseSpend::Refused {
619                    attempt_id: effect.attempt_id.clone(),
620                    refused: CaseRefusal { code: code.into() },
621                });
622            }
623        }
624        spend.extend(
625            sources
626                .refusals
627                .iter()
628                .map(|(attempt_id, code)| CaseSpend::Refused {
629                    attempt_id: attempt_id.clone(),
630                    refused: CaseRefusal { code: code.clone() },
631                }),
632        );
633        let doc = Self {
634            schema: Self::SCHEMA.into(),
635            mode: CaseMode::LiveReexecution,
636            purpose: if source == CaseSource::None {
637                CasePurpose::Probe
638            } else {
639                CasePurpose::Evaluation
640            },
641            source,
642            run,
643            evaluation_time: match config.evaluation_time {
644                EvaluationTime::Source => CaseEvaluationTime::Source,
645                EvaluationTime::Fixed { .. } => CaseEvaluationTime::Fixed,
646            },
647            acquisitions: config
648                .acquisitions
649                .iter()
650                .map(|(source, mode)| {
651                    (
652                        source.clone(),
653                        match mode {
654                            AcquisitionMode::Live => CaseAcquisition::Live,
655                            AcquisitionMode::Captured => CaseAcquisition::Captured {
656                                capture_digests: sources
657                                    .captures
658                                    .get(source)
659                                    .cloned()
660                                    .unwrap_or_default(),
661                            },
662                        },
663                    )
664                })
665                .collect(),
666            spend,
667            declared_no_spend: config
668                .bindings
669                .iter()
670                .filter(|b| b.cost_unit == CostUnit::None)
671                .map(|b| b.identity.clone())
672                .collect(),
673            live_document_digest: result.document.record_digest().map_err(LiveError::io)?,
674            run_record_digest: result.record.record_digest().map_err(LiveError::io)?,
675        };
676        doc.check()?;
677        Ok(doc)
678    }
679
680    pub(crate) fn write_result(
681        &self,
682        stem: &Path,
683        result: &rustev_runtime::LiveResult,
684    ) -> Result<ContentDigest, LiveError> {
685        self.check()?;
686        let bytes = self.record_canonical().map_err(LiveError::io)?;
687        Self::parse(&bytes).map_err(LiveError::io)?;
688        for (suffix, bytes) in [
689            (
690                "live",
691                result.document.record_canonical().map_err(LiveError::io)?,
692            ),
693            (
694                "run",
695                result.record.record_canonical().map_err(LiveError::io)?,
696            ),
697            ("case", bytes),
698        ] {
699            let name = format!(
700                "{}.{suffix}.json",
701                stem.file_name().expect("file stem").to_string_lossy()
702            );
703            write_atomic(&stem.with_file_name(name), &bytes)?;
704        }
705        self.record_digest().map_err(LiveError::io)
706    }
707
708    pub(crate) fn read(path: &Path) -> Result<Self, LiveError> {
709        let mut bytes = Vec::new();
710        File::open(path)
711            .map_err(LiveError::io)?
712            // One byte past the document's own parse bound, so an oversized
713            // file is refused by `parse`, never truncated into a valid one.
714            .take(<Self as Document>::LIMITS.max_bytes as u64 + 1)
715            .read_to_end(&mut bytes)
716            .map_err(LiveError::io)?;
717        let doc = Self::parse(&bytes).map_err(LiveError::io)?;
718        doc.check()?;
719        Ok(doc)
720    }
721}
722
723/// A completed work item's record digests, in work order.
724#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
725#[serde(deny_unknown_fields)]
726pub struct LiveRunCase {
727    pub case_document_digest: ContentDigest,
728    pub candidate: String,
729    pub repeat: u64,
730    pub case_id: String,
731    pub rerun: Option<u64>,
732    pub live_document_digest: ContentDigest,
733    pub run_record_digest: ContentDigest,
734}
735
736/// Record-canonical summary of a live run (spec 043, 3.11.4).
737// Record canonical bytes sort object keys, so declaration order never
738// changes them.
739#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
740#[serde(deny_unknown_fields)]
741pub struct LiveRun {
742    pub schema: String,
743    pub declared_no_spend: Vec<BindingIdentity>,
744    pub identities: CheckpointBindings,
745    pub status: crate::run::RunStatus,
746    pub stop_reason: Option<String>,
747    pub probe: crate::probe::ProbeReport,
748    pub counts: BTreeMap<String, u64>,
749    pub run_spend: crate::journal::RunSpend,
750    pub journal_before: crate::journal::SpendSummary,
751    pub journal_after: crate::journal::SpendSummary,
752    pub cases: Vec<LiveRunCase>,
753    /// Empty until repeat outputs exist.
754    pub repeat_reports: Vec<ContentDigest>,
755}
756live_document!(LiveRun, "rustev.live-run/1");
757
758impl LiveRun {
759    pub fn check(&self) -> Result<(), LiveError> {
760        if self.schema != Self::SCHEMA {
761            return Err(LiveError::invalid("live-run", "wrong schema"));
762        }
763        let mut cases = BTreeSet::new();
764        for case in &self.cases {
765            if !cases.insert((&case.candidate, case.repeat, &case.case_id, case.rerun)) {
766                return Err(LiveError::invalid("live-run", "duplicate case"));
767            }
768        }
769        Ok(())
770    }
771}
772
773impl LiveCheckpoint {
774    pub fn check(&self) -> Result<(), LiveError> {
775        let invalid = |detail: &str| LiveError::invalid("checkpoint-mismatch", detail);
776        if self.schema != Self::SCHEMA {
777            return Err(invalid("wrong checkpoint schema"));
778        }
779        component(&self.bindings.run_id, 64, false).map_err(|e| invalid(&e.detail))?;
780        let mut names = BTreeSet::new();
781        for c in &self.bindings.candidates {
782            component(&c.name, 32, true).map_err(|e| invalid(&e.detail))?;
783            if !names.insert(&c.name) {
784                return Err(invalid("duplicate candidate"));
785            }
786        }
787        let mut items = BTreeSet::new();
788        for item in &self.items {
789            decision_id(
790                &self.bindings.run_id,
791                &item.candidate,
792                item.repeat,
793                &item.case_id,
794                None,
795            )
796            .map_err(|e| invalid(&e.detail))?;
797            if !names.contains(&item.candidate)
798                || item.states.is_empty()
799                || !items.insert((&item.candidate, item.repeat, &item.case_id))
800            {
801                return Err(invalid("invalid or duplicate work item"));
802            }
803            let mut last = 0;
804            for state in &item.states {
805                if let ItemState::Rerun { k } = state {
806                    if *k != last + 1 || *k > 99 {
807                        return Err(invalid("invalid rerun sequence"));
808                    }
809                    last = *k;
810                }
811            }
812        }
813        Ok(())
814    }
815
816    /// Compare all bindings before the caller opens any spend journal.
817    pub fn read(path: impl AsRef<Path>, expected: &CheckpointBindings) -> Result<Self, LiveError> {
818        let mut bytes = Vec::new();
819        File::open(path)
820            .map_err(LiveError::io)?
821            // One byte past the document's own parse bound, so an oversized
822            // file is refused by `parse`, never truncated into a valid one.
823            .take(<Self as Document>::LIMITS.max_bytes as u64 + 1)
824            .read_to_end(&mut bytes)
825            .map_err(LiveError::io)?;
826        let doc = Self::parse(&bytes).map_err(|e| LiveError::invalid("checkpoint-mismatch", e))?;
827        if &doc.bindings != expected {
828            return Err(LiveError::invalid(
829                "checkpoint-mismatch",
830                "checkpoint bindings differ",
831            ));
832        }
833        doc.check()?;
834        Ok(doc)
835    }
836
837    /// Replace atomically on the same filesystem, then persist the directory
838    /// entry. A failed write before rename preserves the previous checkpoint.
839    pub fn write_atomic(&self, path: impl AsRef<Path>) -> Result<(), LiveError> {
840        self.check()?;
841        let bytes = self.canonical().map_err(LiveError::io)?;
842        // Refuse an output our bounded reader could not read.
843        Self::parse(&bytes).map_err(|e| LiveError::invalid("checkpoint-mismatch", e))?;
844        write_atomic(path.as_ref(), &bytes)
845    }
846}
847
848pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), LiveError> {
849    let parent = path
850        .parent()
851        .filter(|p| !p.as_os_str().is_empty())
852        .unwrap_or(Path::new("."));
853    static NEXT: AtomicU64 = AtomicU64::new(0);
854    let (temp, mut file) = loop {
855        let temp = parent.join(format!(
856            ".rustev-checkpoint-{}-{}.tmp",
857            std::process::id(),
858            NEXT.fetch_add(1, Ordering::Relaxed)
859        ));
860        match OpenOptions::new().write(true).create_new(true).open(&temp) {
861            Ok(file) => break (temp, file),
862            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
863            Err(e) => return Err(LiveError::io(e)),
864        }
865    };
866    #[cfg(feature = "fault-injection")]
867    let name = path.file_name().unwrap().to_string_lossy();
868    let written = (|| {
869        #[cfg(feature = "fault-injection")]
870        crate::fault_injection::point("atomic", &name, "before-write")?;
871        file.write_all(bytes)?;
872        file.sync_all()?;
873        drop(file);
874        #[cfg(feature = "fault-injection")]
875        crate::fault_injection::point("atomic", &name, "before-rename")?;
876        std::fs::rename(&temp, path)
877    })();
878    if let Err(e) = written {
879        // Before the rename the previous checkpoint is untouched.
880        let _ = std::fs::remove_file(&temp);
881        return Err(LiveError::io(e));
882    }
883    // After the rename the new checkpoint is in place, but it is not
884    // durable until its directory entry is: a failed directory sync is
885    // still an error, since the caller may not rely on the checkpoint
886    // surviving a crash. There is no temporary file left to remove.
887    #[cfg(feature = "fault-injection")]
888    crate::fault_injection::point("atomic", &name, "before-directory-sync")
889        .map_err(LiveError::io)?;
890    File::open(parent)
891        .and_then(|d| d.sync_all())
892        .map_err(LiveError::io)?;
893    #[cfg(feature = "fault-injection")]
894    crate::fault_injection::point("atomic", &name, "after-directory-sync")
895        .map_err(LiveError::io)?;
896    Ok(())
897}