Skip to main content

hermit/
canonical_verdict.rs

1//! Machine-readable verification reports shared by the Hermit producer and its consumers.
2
3use serde::Deserialize;
4use serde::Serialize;
5
6/// The verification verdict, independent of the guest's exit status.
7///
8/// A guest that deterministically exits nonzero can still match, while a guest
9/// that exits zero can still diverge. Consumers must read this value rather than
10/// infer the verdict from either the process status or human-readable stderr.
11#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum Verdict {
14    /// The two runs matched on every compared dimension.
15    Matched,
16    /// The two runs diverged; verification failed.
17    Diverged,
18    /// Verification stopped before it could reach a verdict.
19    NoResult,
20    /// Verification completed enough work to name an understood machine or
21    /// harness failure, so the receipt is neither a product verdict nor an
22    /// unknown no-result.
23    InfrastructureError,
24}
25
26impl std::fmt::Display for Verdict {
27    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        formatter.write_str(match self {
29            Self::Matched => "matched",
30            Self::Diverged => "diverged",
31            Self::NoResult => "no_result",
32            Self::InfrastructureError => "infrastructure_error",
33        })
34    }
35}
36
37/// Why verification did not reach a verdict.
38///
39/// This distinction is functional evidence. `FirstRunRejected` means the guest
40/// ran and its disposition was rejected by `--verify-allow`; `NotRun` is the
41/// pre-run stamp left when the invocation died before recording a more specific
42/// result. Collapsing both into `no_result` made a completed guest refusal and a
43/// container or launcher failure indistinguishable to the result reader.
44/// `RunFailed` means Hermit started a verification run and a typed failure of
45/// that run stopped it: before it existed, such a run left the `NotRun` stamp,
46/// so a guest that ran (often to a clean exit) was recorded as never run.
47#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(rename_all = "snake_case", tag = "kind")]
49pub enum NoResultReason {
50    /// The invocation did not replace the pre-run stamp with a more specific
51    /// outcome.
52    NotRun,
53    /// Run 1 completed and its exit status was rejected by `--verify-allow`.
54    FirstRunRejected {
55        exit_code: Option<i32>,
56        signal: Option<i32>,
57        stdout_bytes: u64,
58        stderr_bytes: u64,
59    },
60    /// The log comparator refused to claim either agreement or divergence.
61    ComparisonRefused { detail: String },
62    /// A sandbox exited before returning its guest Output. This is an
63    /// attempted execution failure, with no guest disposition or comparison.
64    ContainerFailed(ContainerFailure),
65    /// Hermit started verification run `run`, and a typed failure of that run
66    /// stopped it before any comparison. `detail` is the failure's message.
67    RunFailed {
68        run: VerificationRun,
69        failure: RunFailure,
70        detail: String,
71    },
72}
73
74/// What stopped a verification run that Hermit had started.
75///
76/// Each is a property of Hermit or its backend running this guest, so a
77/// result reader counts it as a failure of the cell on that backend, with the
78/// detail as its reason. An error that is none of these (a bad flag, an
79/// unreadable path) is not typed here and leaves the `NotRun` stamp.
80#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
81#[serde(rename_all = "snake_case")]
82pub enum RunFailure {
83    /// The backend failed while running the guest: the DBT evidence check, a
84    /// KVM execution failure, a SaBRe guest that never reached Detcore.
85    BackendFailure,
86    /// The run recorded a determinism loss, so its records are not compared.
87    DeterminismLoss,
88    /// A fail-closed policy refused this guest during the run: an unsupported
89    /// system call, or a guest the backend cannot instrument.
90    PolicyRefusal,
91    /// Detcore's scheduler found every remaining thread permanently blocked
92    /// (its "Deadlock detected" report) and ended the run.
93    Deadlock,
94}
95
96impl RunFailure {
97    pub fn as_str(self) -> &'static str {
98        match self {
99            Self::BackendFailure => "backend_failure",
100            Self::DeterminismLoss => "determinism_loss",
101            Self::PolicyRefusal => "policy_refusal",
102            Self::Deadlock => "deadlock",
103        }
104    }
105}
106
107#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
108#[serde(rename_all = "snake_case")]
109pub enum VerificationRun {
110    Run1,
111    Run2,
112}
113
114#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
115#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
116pub enum ContainerDisposition {
117    /// Zero is possible when the child exits without returning its result.
118    Exited {
119        code: i32,
120    },
121    Signaled {
122        signal: i32,
123        core_dumped: bool,
124    },
125}
126
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128#[serde(deny_unknown_fields)]
129pub struct ContainerFailure {
130    pub run: VerificationRun,
131    pub disposition: ContainerDisposition,
132}
133
134impl ContainerFailure {
135    /// `main` returns 125 for ContainerChildExit. This is the enclosing Hermit
136    /// process's status, distinct from the container disposition above.
137    pub fn require_reporter_exit(
138        &self,
139        status: Option<i32>,
140        signal: Option<i32>,
141        timed_out: bool,
142    ) -> Result<(), String> {
143        if status == Some(125) && signal.is_none() && !timed_out {
144            Ok(())
145        } else {
146            Err(format!(
147                "container_failed contradicts Hermit process disposition: status={status:?} signal={signal:?} timed_out={timed_out} (expected exit 125 without timeout)"
148            ))
149        }
150    }
151}
152
153/// The understood infrastructure failure recorded by verification.
154#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
155#[serde(rename_all = "snake_case", tag = "kind")]
156pub enum InfrastructureError {
157    /// A precise PMU timer event (ptrace or KVM) arrived after its target.
158    /// Both runs and their comparison may still be retained, but the result
159    /// is not admitted.
160    SkidOvershoot { count: u64 },
161    /// The two runs diverged, and a host file the guest opened changed during
162    /// one of them: `run` opened `path` again and found the host file `after`
163    /// where it had found `before`, while the other run found the same file at
164    /// that open (see `detcore_model::host_input`). Hermit numbers inodes in
165    /// the order it first sees them, so a file replaced mid-run alone shifts
166    /// every later inode number of that run. The comparison is retained, but
167    /// the divergence is not attributed to Hermit.
168    HostInputChanged {
169        run: VerificationRun,
170        path: String,
171        before: detcore_model::host_input::HostFileIdentity,
172        after: detcore_model::host_input::HostFileIdentity,
173    },
174}
175
176impl std::fmt::Display for InfrastructureError {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        match self {
179            Self::SkidOvershoot { count } => {
180                write!(f, "{count} precise-timer skid overshoot(s)")
181            }
182            Self::HostInputChanged {
183                run,
184                path,
185                before,
186                after,
187            } => write!(
188                f,
189                "host input changed during run {}: {path} ({before} -> {after})",
190                match run {
191                    VerificationRun::Run1 => 1,
192                    VerificationRun::Run2 => 2,
193                }
194            ),
195        }
196    }
197}
198
199#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
200pub struct RuntimeStats {
201    pub scheduler_turns: u64,
202    pub virtual_nanoseconds: u64,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub syscalls: Option<u64>,
205    /// The run's backend dispatch record, copied from its summary. Absent for
206    /// a backend without one (KVM) and in reports written before the record.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub dispatch: Option<detcore_model::summary::DispatchStats>,
209    /// The run's syscall histogram and its observation scope, from its
210    /// summary, in the compact form machine records keep (each cell's result
211    /// row copies this runtime). Absent in reports written before the
212    /// histogram existed.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub syscall_histogram: Option<detcore_model::syscall_histogram::CompactSyscallHistogram>,
215}
216
217#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
218pub struct VerificationRuntime {
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub run1: Option<RuntimeStats>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub run2: Option<RuntimeStats>,
223}
224
225#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
226pub struct DbtCountedBranchComparison {
227    pub left: u64,
228    pub right: u64,
229}
230
231impl DbtCountedBranchComparison {
232    #[allow(dead_code)] // path-included consumers do not all construct DBT outcomes
233    pub fn matched(self) -> bool {
234        self.left == self.right
235    }
236}
237
238#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
239#[serde(rename_all = "snake_case")]
240pub enum LogCompareStrictness {
241    Stripped,
242    Canonical,
243}
244
245impl std::fmt::Display for LogCompareStrictness {
246    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        formatter.write_str(match self {
248            Self::Stripped => "stripped",
249            Self::Canonical => "canonical",
250        })
251    }
252}
253
254#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
255#[serde(rename_all = "snake_case")]
256pub enum ComparedLogScope {
257    Deterministic,
258    Info,
259    FullTrace,
260}
261
262#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
263#[serde(rename_all = "snake_case")]
264pub enum RecordEnvelopeReport {
265    AllRecordsV1,
266    DbtEvidenceTransportV1,
267    CrossBackendDetcoreV1,
268    CallerDefined,
269}
270
271impl RecordEnvelopeReport {
272    pub fn is_canonical(self) -> bool {
273        matches!(self, Self::AllRecordsV1 | Self::DbtEvidenceTransportV1)
274    }
275
276    pub fn as_str(self) -> &'static str {
277        match self {
278            Self::AllRecordsV1 => "all_records_v1",
279            Self::DbtEvidenceTransportV1 => "dbt_evidence_transport_v1",
280            Self::CrossBackendDetcoreV1 => "cross_backend_detcore_v1",
281            Self::CallerDefined => "caller_defined",
282        }
283    }
284}
285
286/// The comparison fields serialized beside a verification verdict.
287///
288/// Fields added after the original report are optional here so retained reports
289/// remain readable. A current producer writes every one of them, and
290/// [`VerificationReport::from_current_json_value`] checks their presence.
291#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
292pub struct ComparisonReport {
293    pub strictness: LogCompareStrictness,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub display_name: Option<String>,
296    pub compare_logs: bool,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub compare_io_buffers: Option<bool>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub log_scope: Option<ComparedLogScope>,
301    pub record_envelope: RecordEnvelopeReport,
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub virtualize_time: Option<bool>,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub strip_lines: Option<bool>,
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub canonicalize_addresses: Option<bool>,
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub full_trace: Option<bool>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub exact_remainder: Option<bool>,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub stripped_prefixes: Option<Vec<String>>,
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub canonicalizations: Option<Vec<String>>,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub ignore_lines: Option<bool>,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub skip_commit: Option<bool>,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub skip_detlog: Option<bool>,
322}
323
324#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
325pub struct ComparedLogMessages {
326    pub left: u64,
327    pub right: u64,
328}
329
330/// Content identity and disposition of one guest execution.
331///
332/// The bytes themselves remain in the invoking process or retained artifacts;
333/// these fields make the exact stdout/stderr/status comparison independently
334/// checkable without attempting to recover guest stderr from controller logs.
335#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
336#[serde(deny_unknown_fields)]
337pub struct ComparedOutput {
338    pub exit_code: Option<i32>,
339    pub signal: Option<i32>,
340    pub stdout_sha256: String,
341    pub stdout_bytes: u64,
342    pub stderr_sha256: String,
343    pub stderr_bytes: u64,
344}
345
346#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
347#[serde(deny_unknown_fields)]
348pub struct ComparedOutputs {
349    pub left: ComparedOutput,
350    pub right: ComparedOutput,
351}
352
353impl ComparedOutputs {
354    pub fn require_exact_match(&self) -> Result<(), String> {
355        if self.left == self.right {
356            Ok(())
357        } else {
358            Err("verification operands differ in status, stdout, or stderr".into())
359        }
360    }
361}
362
363/// The complete machine-readable report written by `--verify-json`.
364///
365/// This type is shared by the Hermit producer, the manifest runner, the
366/// compatibility pressure test, and the scorecard. The serialized field order
367/// is retained because existing report bytes are hashed as evidence.
368#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
369pub struct VerificationReport {
370    pub verified: bool,
371    pub bitwise_parity: bool,
372    pub verdict: Verdict,
373    #[serde(default)]
374    pub no_result_reason: Option<NoResultReason>,
375    #[serde(default)]
376    pub infrastructure_error: Option<InfrastructureError>,
377    /// `None` when the producer reached no verdict. This is a DOCUMENTED
378    /// producer state, not a malformed report: `VerificationReport::no_result()`
379    /// in hermit-cli sets it, and its doc says "`null` when no verdict was
380    /// reached". Declaring it non-optional made a legitimate no-result
381    /// deserialise as "unreadable report", which reported an infrastructure
382    /// fault where the truth was that nothing had been recorded.
383    #[serde(deserialize_with = "present_but_nullable_comparison")]
384    pub comparison: Option<ComparisonReport>,
385    pub compared_log_messages: Option<ComparedLogMessages>,
386    /// Exact output/status evidence for both executions. Optional only so
387    /// retained reports written before this field remain readable.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub compared_outputs: Option<ComparedOutputs>,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub dbt_counted_branches: Option<DbtCountedBranchComparison>,
392    /// Runtime totals for the two compared executions when the producer could
393    /// read both run summaries.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub runtime: Option<VerificationRuntime>,
396    /// The guest's exit code when an exited status was available to the
397    /// producer. `None` means either that `guest_signal` records a signal
398    /// disposition, or that execution did not return a guest status at all;
399    /// current producers must not leave this null for `ExitStatus::Exited`.
400    #[serde(default)]
401    pub guest_exit_code: Option<i32>,
402    /// The guest's signal when a signaled status was available to the producer.
403    /// When both disposition fields are null, the writer had no guest status.
404    #[serde(default)]
405    pub guest_signal: Option<i32>,
406    /// WHERE the divergence began, in scheduler-turn units. `None` when the
407    /// logs matched, when no comparison ran, or when the report predates the
408    /// field.
409    ///
410    /// `#[serde(default)]` is deliberate here and is the OPPOSITE choice from
411    /// `ComparisonReport::record_envelope` above. That field is an admission
412    /// gate, so a missing value must never be supplied on the producer's
413    /// behalf. This one is a diagnostic position: absent means "not located",
414    /// which is exactly what `None` says. Requiring it would turn every
415    /// retained pre-field report into an unreadable-report ERROR and
416    /// misclassify old evidence as an infrastructure fault -- the same trap
417    /// documented on `comparison` just above.
418    #[serde(default)]
419    pub first_divergent_scheduler_turn: Option<u64>,
420    /// WHERE the divergence began, in virtual-nanosecond units. Same
421    /// tolerant-default rationale as the field above.
422    ///
423    /// NOTE both of these are the position of the PRECEDING scheduler COMMIT,
424    /// so when no COMMIT precedes the differing record they collapse to the
425    /// origin and BOUND the divergence rather than locating it. The field below
426    /// is the one that locates it.
427    #[serde(default)]
428    pub first_divergent_virtual_nanoseconds: Option<u64>,
429    /// 1-based index of the first differing compared record -- the LOCATION of
430    /// the divergence rather than a bound on it, and the only one of the three
431    /// that is a true log prefix. It shares a unit with
432    /// `compared_log_messages`, so `record / compared` is the fraction of the
433    /// log that was deterministic.
434    ///
435    /// `null` and `0` are not the same claim: null is "no divergence located",
436    /// while 0 would mean the very first record differed. Nothing writes 0 --
437    /// the index is 1-based. Same tolerant-default rationale as its siblings.
438    #[serde(default)]
439    pub first_divergent_record: Option<u64>,
440    /// How many syscalls the guest had COMPLETED when the divergence appeared,
441    /// from detcore's own `finish syscall #N` counter.
442    ///
443    /// NOT interchangeable with `first_divergent_record`: one counts guest work
444    /// and the other counts compared log records, and they move at completely
445    /// different rates. Measured on one real divergence: record 98, syscall 37.
446    /// `null` also covers a genuine state -- a run that diverged before any
447    /// syscall completed.
448    #[serde(default)]
449    pub first_divergent_syscall: Option<u64>,
450    /// First differing compared message from the left execution, with the
451    /// separately recorded syscall number, scheduler turn, and committed time
452    /// removed. Older reports omit it and remain readable.
453    #[serde(default)]
454    pub first_divergent_left_message: Option<String>,
455    /// Corresponding first differing compared message from the right execution.
456    #[serde(default)]
457    pub first_divergent_right_message: Option<String>,
458    /// Whether the retired-branch counter that this run's virtual clock and
459    /// preemption points came from was exact on the producing host: the same
460    /// verdict `hermit host-capabilities` reports as `exact_branch_counter`
461    /// (<https://github.com/rrnewton/hermit/issues/3794>). Present for a run
462    /// on a backend that reads that counter (ptrace, e9patch, and `record
463    /// start`); absent for another backend, and in a report written before
464    /// this field.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub exact_branch_counter: Option<BranchCounterVerdict>,
467}
468
469/// [`VerificationReport::exact_branch_counter`]: present, and the producer's
470/// evidence (the CPU and, when absent, the kind of validation failure).
471#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
472#[serde(deny_unknown_fields)]
473pub struct BranchCounterVerdict {
474    pub present: bool,
475    pub evidence: String,
476}
477
478/// Accept `null` but not a MISSING field. serde maps an absent `Option` field to
479/// `None` by default, which would let a report that never mentioned a comparison
480/// read as a legitimate no-result. Naming a `deserialize_with` makes the field
481/// required again while still admitting an explicit null.
482fn present_but_nullable_comparison<'de, D>(
483    deserializer: D,
484) -> Result<Option<ComparisonReport>, D::Error>
485where
486    D: serde::Deserializer<'de>,
487{
488    Option::<ComparisonReport>::deserialize(deserializer)
489}
490
491impl VerificationReport {
492    #[allow(dead_code)] // path-included retained-report readers do not produce reports
493    pub fn no_result() -> Self {
494        Self {
495            verified: false,
496            bitwise_parity: false,
497            verdict: Verdict::NoResult,
498            no_result_reason: Some(NoResultReason::NotRun),
499            infrastructure_error: None,
500            comparison: None,
501            compared_log_messages: None,
502            compared_outputs: None,
503            dbt_counted_branches: None,
504            runtime: None,
505            guest_exit_code: None,
506            guest_signal: None,
507            first_divergent_scheduler_turn: None,
508            first_divergent_virtual_nanoseconds: None,
509            first_divergent_record: None,
510            first_divergent_syscall: None,
511            first_divergent_left_message: None,
512            first_divergent_right_message: None,
513            exact_branch_counter: None,
514        }
515    }
516
517    /// `null` is legal only when no comparison completed: either the producer
518    /// reached no verdict, or it named an infrastructure error before it could
519    /// compare. A null comparison beside a product verdict is contradictory
520    /// and stays refused at parse.
521    fn require_consistent_outcome_fields(self) -> Result<Self, String> {
522        if let Some(NoResultReason::RunFailed { detail, .. }) = &self.no_result_reason {
523            if detail.trim().is_empty() {
524                return Err("incomplete verification report: run_failed omitted its detail".into());
525            }
526            // The failed run returned no Output and was not compared; like
527            // container_failed, it names no guest disposition.
528            if self.verdict != Verdict::NoResult
529                || self.verified
530                || self.bitwise_parity
531                || self.infrastructure_error.is_some()
532                || self.comparison.is_some()
533                || self.compared_log_messages.is_some()
534                || self.compared_outputs.is_some()
535                || self.dbt_counted_branches.is_some()
536                || self.runtime.is_some()
537                || self.guest_exit_code.is_some()
538                || self.guest_signal.is_some()
539                || self.first_divergent_scheduler_turn.is_some()
540                || self.first_divergent_virtual_nanoseconds.is_some()
541                || self.first_divergent_record.is_some()
542                || self.first_divergent_syscall.is_some()
543                || self.first_divergent_left_message.is_some()
544                || self.first_divergent_right_message.is_some()
545            {
546                return Err(
547                    "run_failed contradicts its no-comparison, unknown-guest outcome".into(),
548                );
549            }
550        }
551        if let Some(NoResultReason::ContainerFailed(failure)) = &self.no_result_reason {
552            let valid_status = match failure.disposition {
553                // classify_container_result reserves these normal exit codes
554                // for policy refusal, guest deadlock, deadline, and mapped
555                // signal stops.
556                ContainerDisposition::Exited { code } => {
557                    // The deadline constant currently lives in hermit-cli,
558                    // which this shared schema's manifest reader cannot import.
559                    const DEADLINE_EXIT: i32 = 124;
560                    (0..=255).contains(&code)
561                        && code != detcore_model::HERMIT_POLICY_REFUSAL_EXIT
562                        && code != detcore_model::HERMIT_GUEST_DEADLOCK_EXIT
563                        && code != DEADLINE_EXIT
564                        && detcore_model::signal_from_exit_status(code).is_none()
565                }
566                ContainerDisposition::Signaled { signal, .. } => (1..=64).contains(&signal),
567            };
568            if !valid_status {
569                return Err("invalid container_failed disposition".into());
570            }
571            // No Output returned from the failed run. In particular, run 1's
572            // status must not be relabelled as the failed run 2's guest status.
573            if self.verdict != Verdict::NoResult
574                || self.verified
575                || self.bitwise_parity
576                || self.infrastructure_error.is_some()
577                || self.comparison.is_some()
578                || self.compared_log_messages.is_some()
579                || self.compared_outputs.is_some()
580                || self.dbt_counted_branches.is_some()
581                || self.runtime.is_some()
582                || self.guest_exit_code.is_some()
583                || self.guest_signal.is_some()
584                || self.first_divergent_scheduler_turn.is_some()
585                || self.first_divergent_virtual_nanoseconds.is_some()
586                || self.first_divergent_record.is_some()
587                || self.first_divergent_syscall.is_some()
588                || self.first_divergent_left_message.is_some()
589                || self.first_divergent_right_message.is_some()
590            {
591                return Err(
592                    "container_failed contradicts its no-comparison, unknown-guest outcome".into(),
593                );
594            }
595        }
596        if self.comparison.is_none()
597            && !matches!(
598                self.verdict,
599                Verdict::NoResult | Verdict::InfrastructureError
600            )
601        {
602            return Err(format!(
603                "incomplete verification report: comparison is null but verdict is {}; null is legal only for no_result or infrastructure_error",
604                self.verdict
605            ));
606        }
607        match (&self.verdict, &self.infrastructure_error) {
608            (Verdict::InfrastructureError, Some(InfrastructureError::SkidOvershoot { count }))
609                if *count > 0 => {}
610            (Verdict::InfrastructureError, Some(InfrastructureError::SkidOvershoot { .. })) => {
611                return Err(
612                    "incomplete verification report: skid_overshoot count must be positive".into(),
613                );
614            }
615            // Named only for a divergence: the comparison that found it is
616            // part of the evidence.
617            (Verdict::InfrastructureError, Some(InfrastructureError::HostInputChanged { .. }))
618                if self.comparison.is_some() => {}
619            (Verdict::InfrastructureError, Some(InfrastructureError::HostInputChanged { .. })) => {
620                return Err(
621                    "incomplete verification report: host_input_changed requires the comparison that found the divergence"
622                        .into(),
623                );
624            }
625            (Verdict::InfrastructureError, None) => {
626                return Err(
627                    "incomplete verification report: infrastructure_error verdict omitted infrastructure_error"
628                        .into(),
629                );
630            }
631            (_, Some(_)) => {
632                return Err(format!(
633                    "inconsistent verification report: verdict={} carries infrastructure_error",
634                    self.verdict
635                ));
636            }
637            (_, None) => {}
638        }
639        Ok(self)
640    }
641
642    /// Parse the complete typed receipt. A top-level JSON object is not enough:
643    /// every nested field used by admission must deserialize successfully.
644    #[allow(dead_code)] // this file is path-included by consumers that use one parse form
645    pub fn from_json_slice(bytes: &[u8]) -> Result<Self, String> {
646        serde_json::from_slice::<Self>(bytes)
647            .map_err(|error| format!("incomplete verification report: {error}"))?
648            .require_consistent_outcome_fields()
649    }
650
651    #[allow(dead_code)] // this file is path-included by consumers that use one parse form
652    pub fn from_json_value(value: serde_json::Value) -> Result<Self, String> {
653        serde_json::from_value::<Self>(value)
654            .map_err(|error| format!("incomplete verification report: {error}"))?
655            .require_consistent_outcome_fields()
656    }
657
658    /// Parse a report written by the current producer and require every field
659    /// it promises to emit, preserving duplicate-field refusal from the original
660    /// bytes. Parsing only a Value would silently collapse duplicate object keys.
661    #[allow(dead_code)] // path-included readers use different parse forms
662    pub fn from_current_json_slice(bytes: &[u8]) -> Result<Self, String> {
663        let report = Self::from_json_slice(bytes)?;
664        let value = serde_json::from_slice(bytes)
665            .map_err(|error| format!("incomplete verification report: {error}"))?;
666        Self::from_current_json_value(value)?;
667        Ok(report)
668    }
669
670    /// Parse a report written by the current producer and require every field
671    /// that producer promises to emit. Retained-report readers use
672    /// [`Self::from_json_value`] instead so fields added after an old run remain
673    /// honest absence rather than making the whole report unreadable.
674    #[allow(dead_code)] // path-included readers use different parse forms
675    pub fn from_current_json_value(value: serde_json::Value) -> Result<Self, String> {
676        Self::from_producer_json_value(value, true)
677    }
678
679    /// Parse a retained cell receipt from before exact output evidence was
680    /// added. Preserve that absence while retaining the other producer-field
681    /// and duplicate-key requirements. This is not current producer admission
682    /// and must not be used for either operand of backend parity.
683    #[allow(dead_code)] // only retained cell receipt readers use this form
684    pub fn from_retained_cell_json_slice(bytes: &[u8]) -> Result<Self, String> {
685        let report = Self::from_json_slice(bytes)?;
686        let value = serde_json::from_slice(bytes)
687            .map_err(|error| format!("incomplete verification report: {error}"))?;
688        Self::from_producer_json_value(value, false)?;
689        Ok(report)
690    }
691
692    fn from_producer_json_value(
693        value: serde_json::Value,
694        require_outputs: bool,
695    ) -> Result<Self, String> {
696        let object = value
697            .as_object()
698            .ok_or_else(|| "incomplete verification report: expected an object".to_string())?;
699        for field in [
700            "verified",
701            "bitwise_parity",
702            "verdict",
703            "infrastructure_error",
704            "comparison",
705            "no_result_reason",
706            "compared_log_messages",
707            "guest_exit_code",
708            "guest_signal",
709            "first_divergent_scheduler_turn",
710            "first_divergent_virtual_nanoseconds",
711            "first_divergent_record",
712            "first_divergent_syscall",
713            "first_divergent_left_message",
714            "first_divergent_right_message",
715        ] {
716            if !object.contains_key(field) {
717                return Err(format!(
718                    "incomplete verification report: missing current producer field `{field}`"
719                ));
720            }
721        }
722        let verdict = object.get("verdict").and_then(serde_json::Value::as_str);
723        if require_outputs
724            && matches!(verdict, Some("matched" | "diverged"))
725            && object
726                .get("compared_outputs")
727                .is_none_or(serde_json::Value::is_null)
728        {
729            return Err(
730                "incomplete verification report: missing current producer field `compared_outputs`"
731                    .into(),
732            );
733        }
734        if let Some(comparison) = object
735            .get("comparison")
736            .and_then(serde_json::Value::as_object)
737        {
738            for field in [
739                "strictness",
740                "display_name",
741                "compare_logs",
742                "compare_io_buffers",
743                "log_scope",
744                "record_envelope",
745                "virtualize_time",
746                "strip_lines",
747                "canonicalize_addresses",
748                "full_trace",
749                "exact_remainder",
750                "stripped_prefixes",
751                "canonicalizations",
752                "ignore_lines",
753                "skip_commit",
754                "skip_detlog",
755            ] {
756                if !comparison.contains_key(field) {
757                    return Err(format!(
758                        "incomplete verification report: missing current comparison field `{field}`"
759                    ));
760                }
761            }
762        }
763        let report = Self::from_json_value(value)?;
764        // Historical receipts may predate this field. When a matched report
765        // explicitly carries outputs, they must support its claim even if the
766        // receipt was read through the retained-input compatibility path.
767        if report.verdict == Verdict::Matched && report.compared_outputs.is_some() {
768            report.require_exact_output_match()?;
769        }
770        if report.verdict == Verdict::NoResult && report.compared_outputs.is_some() {
771            return Err(
772                "inconsistent verification report: no_result carries compared_outputs".into(),
773            );
774        }
775        match (&report.verdict, &report.no_result_reason) {
776            (Verdict::NoResult, Some(NoResultReason::ComparisonRefused { detail }))
777                if detail.trim().is_empty() =>
778            {
779                Err("incomplete verification report: comparison_refused omitted its detail".into())
780            }
781            (Verdict::NoResult, Some(_)) => Ok(report),
782            (Verdict::NoResult, None) => Ok(report),
783            (_, Some(_)) => Err(format!(
784                "inconsistent verification report: verdict={} carries no_result_reason",
785                report.verdict
786            )),
787            (_, None) => Ok(report),
788        }
789    }
790
791    /// Prove that the invocation actually compared the canonical INFO evidence.
792    /// This is separate from whether that comparison matched: a canonical
793    /// divergence is a product result, while a stripped/output-only/empty-log
794    /// comparison is incomplete evidence and therefore an infrastructure result.
795    pub fn require_canonical_comparison(&self) -> Result<(), String> {
796        let counts = self.compared_log_messages.as_ref();
797        // No comparison at all is a distinct outcome from a comparison that was
798        // too weak, and it is still not admissible. Naming it separately is the
799        // whole point: "no verdict was recorded" is actionable against the
800        // producer, while the old "unreadable report" pointed at the reader.
801        let Some(comparison) = self.comparison.as_ref() else {
802            return Err(format!(
803                "verification recorded no comparison at all (verdict={}), so there is no canonical INFO evidence to admit",
804                self.verdict
805            ));
806        };
807        if comparison.strictness == LogCompareStrictness::Canonical
808            && comparison.compare_logs
809            && comparison.record_envelope.is_canonical()
810            && counts.is_some_and(|counts| counts.left > 0 && counts.right > 0)
811        {
812            Ok(())
813        } else {
814            Err(format!(
815                "verification did not compare canonical non-vacuous INFO evidence: strictness={} compare_logs={} record_envelope={} messages={}/{}",
816                comparison.strictness,
817                comparison.compare_logs,
818                comparison.record_envelope.as_str(),
819                counts.map_or(0, |counts| counts.left),
820                counts.map_or(0, |counts| counts.right),
821            ))
822        }
823    }
824
825    /// Require complete, identical guest outputs for a current strict operand.
826    /// Historical log-only receipts retain their separate admission path.
827    pub fn require_exact_output_match(&self) -> Result<(), String> {
828        let outputs = self
829            .compared_outputs
830            .as_ref()
831            .ok_or("same-backend verification omitted exact output evidence")?;
832        outputs.require_exact_match()?;
833        let output = &outputs.left;
834        for (field, digest) in [
835            ("stdout_sha256", output.stdout_sha256.as_str()),
836            ("stderr_sha256", output.stderr_sha256.as_str()),
837        ] {
838            if digest.len() != 64
839                || !digest
840                    .bytes()
841                    .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
842            {
843                return Err(format!("{field} is not a SHA-256 digest"));
844            }
845        }
846        if output.exit_code.is_some() == output.signal.is_some() {
847            return Err(
848                "output must record exactly one guest disposition (exit code or signal)".into(),
849            );
850        }
851        if self.guest_exit_code != output.exit_code || self.guest_signal != output.signal {
852            return Err("output disposition contradicts its verification result".into());
853        }
854        Ok(())
855    }
856
857    /// Admit a green only when both the canonical comparison and the match
858    /// claim agree. `verified=true` alone is intentionally insufficient: KVM's
859    /// output-only fallback can report it with zero compared INFO messages.
860    pub fn require_canonical_match(&self) -> Result<(), String> {
861        self.require_canonical_comparison()?;
862        if self.compared_outputs.is_some() {
863            self.require_exact_output_match()?;
864        }
865        if self.verified && self.verdict == Verdict::Matched && self.bitwise_parity {
866            Ok(())
867        } else {
868            Err(format!(
869                "canonical verification did not match: verified={} verdict={} bitwise_parity={}",
870                self.verified, self.verdict, self.bitwise_parity
871            ))
872        }
873    }
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879
880    #[test]
881    fn the_exact_branch_counter_verdict_is_optional_and_strictly_typed() {
882        // Absent: the key is not written, so a report from another backend has
883        // exactly the bytes it had before the field existed.
884        let report = VerificationReport::no_result();
885        let bytes = serde_json::to_vec(&report).unwrap();
886        let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
887        assert!(value.get("exact_branch_counter").is_none(), "{value}");
888        assert_eq!(
889            VerificationReport::from_current_json_slice(&bytes)
890                .unwrap()
891                .exact_branch_counter,
892            None
893        );
894        // Present: it round-trips through the current-producer reader.
895        let mut report = VerificationReport::no_result();
896        report.exact_branch_counter = Some(BranchCounterVerdict {
897            present: false,
898            evidence: "fixture CPU: Reverie performance-counter validation failed \
899                       (AmdSpecLockMapShouldBeDisabled)"
900                .into(),
901        });
902        let bytes = serde_json::to_vec(&report).unwrap();
903        assert_eq!(
904            VerificationReport::from_current_json_slice(&bytes)
905                .unwrap()
906                .exact_branch_counter,
907            report.exact_branch_counter
908        );
909        // An unknown key inside the verdict is refused, not dropped.
910        let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
911        value["exact_branch_counter"]["measured_counts"] = serde_json::json!(91);
912        assert!(VerificationReport::from_json_slice(value.to_string().as_bytes()).is_err());
913    }
914
915    fn failed_container_report(
916        run: VerificationRun,
917        disposition: ContainerDisposition,
918    ) -> VerificationReport {
919        let mut report = VerificationReport::no_result();
920        report.no_result_reason = Some(NoResultReason::ContainerFailed(ContainerFailure {
921            run,
922            disposition,
923        }));
924        report
925    }
926
927    #[test]
928    fn run_failure_round_trips_and_names_no_guest_disposition() {
929        for run in [VerificationRun::Run1, VerificationRun::Run2] {
930            for failure in [
931                RunFailure::BackendFailure,
932                RunFailure::DeterminismLoss,
933                RunFailure::PolicyRefusal,
934                RunFailure::Deadlock,
935            ] {
936                let mut report = VerificationReport::no_result();
937                report.no_result_reason = Some(NoResultReason::RunFailed {
938                    run,
939                    failure,
940                    detail: "drrun started and the DBT run then failed".into(),
941                });
942                let bytes = serde_json::to_vec(&report).unwrap();
943                let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
944                assert_eq!(value["no_result_reason"]["kind"], "run_failed");
945                assert_eq!(value["no_result_reason"]["failure"], failure.as_str());
946                assert_eq!(
947                    VerificationReport::from_current_json_slice(&bytes).unwrap(),
948                    report
949                );
950                assert!(report.require_canonical_comparison().is_err());
951
952                let mut guest = report.clone();
953                guest.guest_exit_code = Some(0);
954                let bytes = serde_json::to_vec(&guest).unwrap();
955                assert!(VerificationReport::from_current_json_slice(&bytes).is_err());
956
957                let mut blank = report.clone();
958                blank.no_result_reason = Some(NoResultReason::RunFailed {
959                    run,
960                    failure,
961                    detail: " ".into(),
962                });
963                let bytes = serde_json::to_vec(&blank).unwrap();
964                assert!(VerificationReport::from_current_json_slice(&bytes).is_err());
965            }
966        }
967    }
968
969    #[test]
970    fn container_failure_requires_exact_actor_and_absent_comparison_fields() {
971        for run in [VerificationRun::Run1, VerificationRun::Run2] {
972            for disposition in [
973                ContainerDisposition::Exited { code: 0 },
974                ContainerDisposition::Exited { code: 101 },
975                ContainerDisposition::Signaled {
976                    signal: 14,
977                    core_dumped: false,
978                },
979                ContainerDisposition::Signaled {
980                    signal: 11,
981                    core_dumped: true,
982                },
983            ] {
984                let report = failed_container_report(run, disposition);
985                let bytes = serde_json::to_vec(&report).unwrap();
986                assert_eq!(
987                    VerificationReport::from_current_json_slice(&bytes).unwrap(),
988                    report
989                );
990                assert!(report.require_canonical_comparison().is_err());
991                assert!(report.require_canonical_match().is_err());
992                let NoResultReason::ContainerFailed(failure) =
993                    report.no_result_reason.as_ref().unwrap()
994                else {
995                    unreachable!()
996                };
997                failure
998                    .require_reporter_exit(Some(125), None, false)
999                    .unwrap();
1000                for (status, signal, timeout) in [
1001                    (Some(0), None, false),
1002                    (Some(122), None, false),
1003                    (None, Some(14), false),
1004                    (Some(125), None, true),
1005                ] {
1006                    assert!(
1007                        failure
1008                            .require_reporter_exit(status, signal, timeout)
1009                            .is_err()
1010                    );
1011                }
1012                let value = serde_json::to_value(&report).unwrap();
1013                for (field, invented) in [
1014                    ("verified", serde_json::json!(true)),
1015                    ("bitwise_parity", serde_json::json!(true)),
1016                    ("verdict", serde_json::json!("matched")),
1017                    ("guest_exit_code", serde_json::json!(0)),
1018                    ("guest_signal", serde_json::json!(14)),
1019                    (
1020                        "runtime",
1021                        serde_json::json!({"run1":{"scheduler_turns":1,"virtual_nanoseconds":2}}),
1022                    ),
1023                    (
1024                        "compared_log_messages",
1025                        serde_json::json!({"left":1,"right":1}),
1026                    ),
1027                    ("first_divergent_record", serde_json::json!(1)),
1028                    ("first_divergent_syscall", serde_json::json!(1)),
1029                    ("first_divergent_scheduler_turn", serde_json::json!(1)),
1030                    ("first_divergent_virtual_nanoseconds", serde_json::json!(1)),
1031                    (
1032                        "first_divergent_left_message",
1033                        serde_json::json!("invented"),
1034                    ),
1035                    (
1036                        "first_divergent_right_message",
1037                        serde_json::json!("invented"),
1038                    ),
1039                    (
1040                        "dbt_counted_branches",
1041                        serde_json::json!({"left":1,"right":1}),
1042                    ),
1043                    (
1044                        "infrastructure_error",
1045                        serde_json::json!({"kind":"skid_overshoot","count":1}),
1046                    ),
1047                ] {
1048                    let mut bad = value.clone();
1049                    bad[field] = invented;
1050                    let bytes = serde_json::to_vec(&bad).unwrap();
1051                    assert!(
1052                        VerificationReport::from_current_json_slice(&bytes).is_err(),
1053                        "{field}"
1054                    );
1055                    assert!(
1056                        VerificationReport::from_retained_cell_json_slice(&bytes).is_err(),
1057                        "{field}"
1058                    );
1059                }
1060            }
1061        }
1062    }
1063
1064    #[test]
1065    fn container_failure_rejects_malformed_duplicate_and_reserved_dispositions() {
1066        let report = failed_container_report(
1067            VerificationRun::Run1,
1068            ContainerDisposition::Exited { code: 0 },
1069        );
1070        let value = serde_json::to_value(report).unwrap();
1071        for code in [-1, 121, 122, 124, 129, 142, 192, 256] {
1072            let mut bad = value.clone();
1073            bad["no_result_reason"]["disposition"]["code"] = code.into();
1074            assert!(
1075                VerificationReport::from_current_json_value(bad).is_err(),
1076                "{code}"
1077            );
1078        }
1079        for replacement in [
1080            serde_json::json!({"kind":"container_failed","disposition":{"kind":"exited","code":1}}),
1081            serde_json::json!({"kind":"container_failed","run":"run3","disposition":{"kind":"exited","code":1}}),
1082            serde_json::json!({"kind":"container_failed","run":"run1","disposition":{"kind":"exited","code":1,"signal":14}}),
1083            serde_json::json!({"kind":"container_failed","run":"run1","disposition":{"kind":"signaled","signal":14}}),
1084            serde_json::json!({"kind":"container_failed","run":"run1","disposition":{"kind":"signaled","signal":0,"core_dumped":false}}),
1085            serde_json::json!({"kind":"container_failed","run":"run1","disposition":{"kind":"signaled","signal":65,"core_dumped":false}}),
1086            serde_json::json!({"kind":"container_failed","run":"run1","extra":true,"disposition":{"kind":"exited","code":1}}),
1087        ] {
1088            let mut bad = value.clone();
1089            bad["no_result_reason"] = replacement;
1090            assert!(VerificationReport::from_current_json_value(bad).is_err());
1091        }
1092        let raw = serde_json::to_string(&value).unwrap();
1093        for (needle, replacement) in [
1094            (r#""run":"run1""#, r#""run":"run1","run":"run2""#),
1095            (r#""code":0"#, r#""code":0,"code":0"#),
1096        ] {
1097            assert!(raw.contains(needle));
1098            let bad = raw.replacen(needle, replacement, 1);
1099            assert!(
1100                VerificationReport::from_current_json_slice(bad.as_bytes())
1101                    .unwrap_err()
1102                    .contains("duplicate field")
1103            );
1104        }
1105    }
1106
1107    /// The divergence position must survive the parse. It was already being
1108    /// emitted by hermit and already present in the retained report string, but
1109    /// this struct dropped it, so no cell could ever report where its
1110    /// divergence began.
1111    #[test]
1112    fn divergence_position_survives_the_parse() {
1113        let json = br#"{"verified":false,"bitwise_parity":false,"verdict":"diverged",
1114            "comparison":{"strictness":"canonical","compare_logs":true,"record_envelope":"all_records_v1"},"compared_log_messages":{"left":180,"right":180},
1115            "first_divergent_scheduler_turn":4,
1116            "first_divergent_virtual_nanoseconds":1767225600002825515,
1117            "first_divergent_record":108,
1118            "first_divergent_syscall":37,
1119            "first_divergent_left_message":"INFO detcore: left event",
1120            "first_divergent_right_message":"INFO detcore: right event"}"#;
1121        let report = VerificationReport::from_json_slice(json).expect("diverged report parses");
1122        assert_eq!(report.first_divergent_scheduler_turn, Some(4));
1123        assert_eq!(
1124            report.first_divergent_virtual_nanoseconds,
1125            Some(1_767_225_600_002_825_515)
1126        );
1127        assert_eq!(report.first_divergent_record, Some(108));
1128        // THE FOURTH COORDINATE, asserted because it was the one that could
1129        // stop arriving unnoticed. Demonstrated 2026-08-25: with this line
1130        // absent, renaming the field so no real report could populate it left
1131        // all 11 tests in this module GREEN. A struct field nothing asserts is
1132        // indistinguishable from one nothing populates.
1133        assert_eq!(report.first_divergent_syscall, Some(37));
1134        assert_eq!(
1135            report.first_divergent_left_message.as_deref(),
1136            Some("INFO detcore: left event")
1137        );
1138        assert_eq!(
1139            report.first_divergent_right_message.as_deref(),
1140            Some("INFO detcore: right event")
1141        );
1142    }
1143
1144    /// The record index is the only coordinate that LOCATES the divergence;
1145    /// the other two are the preceding COMMIT and merely bound it. So a report
1146    /// can carry a record with both bounds collapsed to the origin, and that
1147    /// is a MORE informative report rather than a malformed one.
1148    #[test]
1149    fn a_located_record_does_not_require_its_bounding_siblings() {
1150        let json = br#"{"verified":false,"bitwise_parity":false,"verdict":"diverged",
1151            "comparison":{"strictness":"canonical","compare_logs":true,"record_envelope":"all_records_v1"},
1152            "compared_log_messages":{"left":180,"right":180},
1153            "first_divergent_record":3}"#;
1154        let report = VerificationReport::from_json_slice(json).expect("report parses");
1155        assert_eq!(report.first_divergent_record, Some(3));
1156        assert_eq!(report.first_divergent_scheduler_turn, None);
1157        assert_eq!(report.first_divergent_virtual_nanoseconds, None);
1158        assert_eq!(report.first_divergent_left_message, None);
1159        assert_eq!(report.first_divergent_right_message, None);
1160        assert_eq!(report.first_divergent_syscall, None);
1161    }
1162
1163    /// An EARLIER divergence must report a SMALLER value in both units. This is
1164    /// the ordering the whole metric rests on: a fix that moves the divergence
1165    /// later has to be visible as a larger number, and a regression as a
1166    /// smaller one. Both figures below were measured with `hermit log-diff
1167    /// --json` against one real 131-line INFO log, diverged at two different
1168    /// depths -- after COMMIT turn 4 and after COMMIT turn 1.
1169    #[test]
1170    fn an_earlier_divergence_reports_a_smaller_position() {
1171        let parse = |turn: u64, nanos: u64| {
1172            let json = format!(
1173                r#"{{"verified":false,"bitwise_parity":false,"verdict":"diverged",
1174                    "comparison":{{"strictness":"canonical","compare_logs":true,"record_envelope":"all_records_v1"}},"compared_log_messages":{{"left":180,"right":180}},
1175                    "first_divergent_scheduler_turn":{turn},
1176                    "first_divergent_virtual_nanoseconds":{nanos}}}"#
1177            );
1178            VerificationReport::from_json_slice(json.as_bytes()).expect("report parses")
1179        };
1180        let late = parse(4, 1_767_225_600_002_825_515);
1181        let early = parse(1, 1_767_225_600_000_500_000);
1182        assert!(
1183            early.first_divergent_scheduler_turn < late.first_divergent_scheduler_turn,
1184            "an earlier divergence must order before a later one in scheduler turns"
1185        );
1186        assert!(
1187            early.first_divergent_virtual_nanoseconds < late.first_divergent_virtual_nanoseconds,
1188            "and in virtual nanoseconds"
1189        );
1190    }
1191
1192    /// A report written before the field existed must still parse. Requiring it
1193    /// would reclassify every retained pre-field report as an unreadable-report
1194    /// infrastructure fault.
1195    #[test]
1196    fn a_report_without_the_divergence_position_still_parses() {
1197        let json = br#"{"verified":true,"bitwise_parity":true,"verdict":"matched",
1198            "comparison":{"strictness":"canonical","compare_logs":true,"record_envelope":"all_records_v1"},"compared_log_messages":{"left":180,"right":180}}"#;
1199        let report = VerificationReport::from_json_slice(json);
1200        let report = report.expect("a pre-field report is not malformed");
1201        assert_eq!(report.first_divergent_scheduler_turn, None);
1202        assert_eq!(report.first_divergent_virtual_nanoseconds, None);
1203    }
1204
1205    #[test]
1206    fn runtime_totals_survive_the_typed_report_parse() {
1207        let json = br#"{"verified":true,"bitwise_parity":true,"verdict":"matched",
1208            "comparison":{"strictness":"canonical","compare_logs":true,"record_envelope":"all_records_v1"},"compared_log_messages":{"left":180,"right":180},
1209            "runtime":{"run1":{"scheduler_turns":12,"virtual_nanoseconds":34,"syscalls":5},
1210                       "run2":{"scheduler_turns":13,"virtual_nanoseconds":35,"syscalls":6}}}"#;
1211        let report = VerificationReport::from_json_slice(json).expect("runtime report parses");
1212        let runtime = report.runtime.expect("runtime retained");
1213        assert_eq!(runtime.run1.expect("run1").syscalls, Some(5));
1214        assert_eq!(runtime.run2.expect("run2").scheduler_turns, 13);
1215    }
1216
1217    fn report(
1218        strictness: LogCompareStrictness,
1219        compare_logs: bool,
1220        left: u64,
1221        right: u64,
1222    ) -> VerificationReport {
1223        VerificationReport {
1224            verified: true,
1225            bitwise_parity: true,
1226            verdict: Verdict::Matched,
1227            no_result_reason: None,
1228            infrastructure_error: None,
1229            comparison: Some(ComparisonReport {
1230                strictness,
1231                display_name: None,
1232                compare_logs,
1233                compare_io_buffers: None,
1234                log_scope: None,
1235                record_envelope: RecordEnvelopeReport::AllRecordsV1,
1236                virtualize_time: None,
1237                strip_lines: None,
1238                canonicalize_addresses: None,
1239                full_trace: None,
1240                exact_remainder: None,
1241                stripped_prefixes: None,
1242                canonicalizations: None,
1243                ignore_lines: None,
1244                skip_commit: None,
1245                skip_detlog: None,
1246            }),
1247            compared_log_messages: Some(ComparedLogMessages { left, right }),
1248            compared_outputs: None,
1249            dbt_counted_branches: None,
1250            runtime: None,
1251            guest_exit_code: None,
1252            guest_signal: None,
1253            first_divergent_scheduler_turn: None,
1254            first_divergent_virtual_nanoseconds: None,
1255            first_divergent_record: None,
1256            first_divergent_syscall: None,
1257            first_divergent_left_message: None,
1258            first_divergent_right_message: None,
1259            exact_branch_counter: None,
1260        }
1261    }
1262
1263    #[test]
1264    fn brackets_every_canonical_match_requirement() {
1265        assert!(
1266            report(LogCompareStrictness::Canonical, true, 1, 1)
1267                .require_canonical_match()
1268                .is_ok()
1269        );
1270        let mut weak = report(LogCompareStrictness::Canonical, true, 1, 1);
1271        weak.verified = false;
1272        assert!(weak.require_canonical_match().is_err());
1273        let mut weak = report(LogCompareStrictness::Canonical, true, 1, 1);
1274        weak.verdict = Verdict::Diverged;
1275        assert!(weak.require_canonical_match().is_err());
1276        let mut weak = report(LogCompareStrictness::Canonical, true, 1, 1);
1277        weak.bitwise_parity = false;
1278        assert!(weak.require_canonical_match().is_err());
1279        assert!(
1280            report(LogCompareStrictness::Stripped, true, 1, 1)
1281                .require_canonical_match()
1282                .is_err()
1283        );
1284        assert!(
1285            report(LogCompareStrictness::Canonical, false, 1, 1)
1286                .require_canonical_match()
1287                .is_err()
1288        );
1289        assert!(
1290            report(LogCompareStrictness::Canonical, true, 0, 1)
1291                .require_canonical_match()
1292                .is_err()
1293        );
1294        let mut weak = report(LogCompareStrictness::Canonical, true, 1, 1);
1295        weak.comparison.as_mut().unwrap().record_envelope = RecordEnvelopeReport::CallerDefined;
1296        assert!(weak.require_canonical_match().is_err());
1297    }
1298
1299    /// The exact bytes hermit writes for a run that reached no verdict, copied
1300    /// from a real capture rather than reconstructed. Before this was `Option`,
1301    /// parsing these failed with "invalid type: null, expected struct
1302    /// ComparisonReport at line 1 column 80" and the cell was scored ERROR
1303    /// "verification report is unreadable" -- an infrastructure fault where the
1304    /// truth was that the producer had recorded nothing.
1305    const REAL_NO_RESULT_REPORT: &str = concat!(
1306        r#"{"verified":false,"bitwise_parity":false,"verdict":"no_result","#,
1307        r#""comparison":null,"compared_log_messages":null,"guest_exit_code":null,"#,
1308        r#""guest_signal":null,"first_divergent_scheduler_turn":null,"#,
1309        r#""first_divergent_virtual_nanoseconds":null,"first_divergent_record":null,"#,
1310        r#""first_divergent_syscall":null}"#,
1311    );
1312
1313    #[test]
1314    fn a_real_no_result_report_parses_instead_of_reading_as_unreadable() {
1315        let parsed = VerificationReport::from_json_slice(REAL_NO_RESULT_REPORT.as_bytes())
1316            .expect("a documented no-result report must parse");
1317        assert_eq!(parsed.verdict, Verdict::NoResult);
1318        assert!(parsed.comparison.is_none());
1319    }
1320
1321    #[test]
1322    fn a_no_result_report_is_still_inadmissible_and_names_the_real_reason() {
1323        // Parsing it is NOT admitting it. The point of the change is that the
1324        // refusal now points at the producer that recorded nothing, instead of
1325        // claiming the report could not be read.
1326        let parsed = VerificationReport::from_json_slice(REAL_NO_RESULT_REPORT.as_bytes())
1327            .expect("a documented no-result report must parse");
1328        let refusal = parsed
1329            .require_canonical_comparison()
1330            .expect_err("a report with no comparison must never be admitted");
1331        assert!(
1332            refusal.contains("recorded no comparison at all"),
1333            "refusal must name the missing comparison, got: {refusal}"
1334        );
1335        assert!(
1336            refusal.contains("no_result"),
1337            "refusal must carry the verdict, got: {refusal}"
1338        );
1339        assert!(parsed.require_canonical_match().is_err());
1340    }
1341
1342    #[test]
1343    fn a_missing_comparison_field_is_refused_rather_than_defaulting_to_none() {
1344        // serde would otherwise map an absent Option field to None, letting a
1345        // report that never mentioned a comparison read as a legitimate
1346        // no-result. Optional must not mean absent.
1347        let absent = r#"{"verified":false,"bitwise_parity":false,"verdict":"no_result","compared_log_messages":null}"#;
1348        assert!(VerificationReport::from_json_slice(absent.as_bytes()).is_err());
1349    }
1350
1351    #[test]
1352    fn a_malformed_comparison_object_is_still_refused_at_parse() {
1353        // Optional must not mean lenient: a comparison that is present but the
1354        // wrong shape is still an unreadable report, not a silent None.
1355        let malformed = REAL_NO_RESULT_REPORT.replace(r#""comparison":null"#, r#""comparison":7"#);
1356        assert!(VerificationReport::from_json_slice(malformed.as_bytes()).is_err());
1357        let missing_field = REAL_NO_RESULT_REPORT.replace(
1358            r#""comparison":null"#,
1359            r#""comparison":{"compare_logs":true,"record_envelope":"all_records_v1"}"#,
1360        );
1361        assert!(VerificationReport::from_json_slice(missing_field.as_bytes()).is_err());
1362    }
1363
1364    #[test]
1365    fn comparison_record_envelope_is_required_and_unknown_values_are_refused() {
1366        let missing = serde_json::json!({
1367            "verified": true,
1368            "bitwise_parity": true,
1369            "verdict": "matched",
1370            "infrastructure_error": null,
1371            "comparison": {"strictness": "canonical", "compare_logs": true},
1372            "compared_log_messages": {"left": 1, "right": 1}
1373        });
1374        let error = VerificationReport::from_json_value(missing)
1375            .expect_err("a comparison that never states its record envelope is not admissible")
1376            .to_string();
1377        // The runner surfaces this verbatim as "verification report is
1378        // unreadable: {error}". Refusing is only useful if the operator can
1379        // tell WHICH producer contract was unmet -- an old binary that predates
1380        // the field looks identical to a corrupt file unless the message names
1381        // it. Assert the naming rather than trusting serde to keep doing it.
1382        assert!(
1383            error.contains("record_envelope"),
1384            "the refusal must name the missing field so an operator can tell a producer that \
1385             predates the envelope from a corrupt report; got: {error}"
1386        );
1387
1388        let unknown = serde_json::json!({
1389            "verified": true,
1390            "bitwise_parity": true,
1391            "verdict": "matched",
1392            "infrastructure_error": null,
1393            "comparison": {
1394                "strictness": "canonical",
1395                "compare_logs": true,
1396                "record_envelope": "unknown_v9"
1397            },
1398            "compared_log_messages": {"left": 1, "right": 1}
1399        });
1400        assert!(VerificationReport::from_json_value(unknown).is_err());
1401    }
1402
1403    #[test]
1404    fn readable_object_with_unreadable_nested_predicate_is_refused() {
1405        let malformed = serde_json::json!({
1406            "verified": true,
1407            "bitwise_parity": true,
1408            "verdict": "matched",
1409            "comparison": null,
1410            "compared_log_messages": {"left": 1, "right": 1}
1411        });
1412        assert!(VerificationReport::from_json_value(malformed).is_err());
1413    }
1414
1415    #[test]
1416    fn typed_no_result_reason_round_trips_and_unknown_values_are_refused() {
1417        let current = serde_json::to_value(VerificationReport::no_result()).unwrap();
1418        let parsed = VerificationReport::from_current_json_value(current.clone())
1419            .expect("the typed not-run reason must round-trip");
1420        assert_eq!(parsed.no_result_reason, Some(NoResultReason::NotRun));
1421
1422        let mut rejected = VerificationReport::no_result();
1423        rejected.no_result_reason = Some(NoResultReason::FirstRunRejected {
1424            exit_code: Some(9),
1425            signal: None,
1426            stdout_bytes: 0,
1427            stderr_bytes: 0,
1428        });
1429        rejected.guest_exit_code = Some(9);
1430        let mut rejected_value = serde_json::to_value(&rejected).unwrap();
1431        for include_null in [false, true] {
1432            if include_null {
1433                rejected_value["compared_outputs"] = serde_json::Value::Null;
1434            } else {
1435                rejected_value
1436                    .as_object_mut()
1437                    .unwrap()
1438                    .remove("compared_outputs");
1439            }
1440            let parsed = VerificationReport::from_current_json_slice(
1441                &serde_json::to_vec(&rejected_value).unwrap(),
1442            )
1443            .expect("a first-run rejection has no comparison output evidence");
1444            assert_eq!(parsed, rejected);
1445        }
1446        let output = ComparedOutput {
1447            exit_code: Some(9),
1448            signal: None,
1449            stdout_sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1450                .into(),
1451            stdout_bytes: 0,
1452            stderr_sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1453                .into(),
1454            stderr_bytes: 0,
1455        };
1456        rejected.compared_outputs = Some(ComparedOutputs {
1457            left: output.clone(),
1458            right: output,
1459        });
1460        let error =
1461            VerificationReport::from_current_json_slice(&serde_json::to_vec(&rejected).unwrap())
1462                .expect_err("a first-run rejection cannot carry comparison output evidence");
1463        assert!(
1464            error.contains("no_result") && error.contains("compared_outputs"),
1465            "{error}"
1466        );
1467
1468        let mut missing = current.clone();
1469        missing.as_object_mut().unwrap().remove("no_result_reason");
1470        let error = VerificationReport::from_current_json_value(missing)
1471            .expect_err("a current no-result must state the nullable reason field");
1472        assert!(error.contains("no_result_reason"), "{error}");
1473
1474        let mut absent = current.clone();
1475        absent["no_result_reason"] = serde_json::Value::Null;
1476        let parsed = VerificationReport::from_current_json_value(absent)
1477            .expect("explicit null records that the producer has no exact cause");
1478        assert_eq!(parsed.no_result_reason, None);
1479
1480        let mut comparator_refusal = current.clone();
1481        comparator_refusal["no_result_reason"] = serde_json::json!({
1482            "kind": "comparison_refused",
1483            "detail": "the first log was truncated"
1484        });
1485        let parsed = VerificationReport::from_current_json_value(comparator_refusal)
1486            .expect("a comparator refusal carries the producer's exact cause");
1487        assert_eq!(
1488            parsed.no_result_reason,
1489            Some(NoResultReason::ComparisonRefused {
1490                detail: "the first log was truncated".into()
1491            })
1492        );
1493
1494        let mut unknown = current;
1495        unknown["no_result_reason"] = serde_json::json!({"kind": "never_ran"});
1496        let error = VerificationReport::from_current_json_value(unknown)
1497            .expect_err("an unknown no-result reason must be refused");
1498        assert!(
1499            error.contains("unknown variant"),
1500            "refusal must identify the unknown typed reason: {error}"
1501        );
1502    }
1503
1504    #[test]
1505    fn current_comparison_requires_every_producer_field_by_name() {
1506        let mut current = serde_json::json!({
1507            "verified": true,
1508            "bitwise_parity": true,
1509            "verdict": "matched",
1510            "no_result_reason": null,
1511            "infrastructure_error": null,
1512            "comparison": {
1513                "strictness": "canonical",
1514                "display_name": "BitwiseInfoV1",
1515                "compare_logs": true,
1516                "compare_io_buffers": true,
1517                "log_scope": "info",
1518                "record_envelope": "all_records_v1",
1519                "virtualize_time": true,
1520                "strip_lines": false,
1521                "canonicalize_addresses": true,
1522                "full_trace": true,
1523                "exact_remainder": true,
1524                "stripped_prefixes": ["real-wall-clock-prefix/v1"],
1525                "canonicalizations": ["host-address-to-first-appearance-ordinal/v1"],
1526                "ignore_lines": false,
1527                "skip_commit": false,
1528                "skip_detlog": false
1529            },
1530            "compared_log_messages": {"left": 1, "right": 1},
1531            "compared_outputs": {
1532                "left": {
1533                    "exit_code": 0,
1534                    "signal": null,
1535                    "stdout_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1536                    "stdout_bytes": 4,
1537                    "stderr_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1538                    "stderr_bytes": 0
1539                },
1540                "right": {
1541                    "exit_code": 0,
1542                    "signal": null,
1543                    "stdout_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1544                    "stdout_bytes": 4,
1545                    "stderr_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1546                    "stderr_bytes": 0
1547                }
1548            },
1549            "guest_exit_code": 0,
1550            "guest_signal": null,
1551            "first_divergent_scheduler_turn": null,
1552            "first_divergent_virtual_nanoseconds": null,
1553            "first_divergent_record": null,
1554            "first_divergent_syscall": null,
1555            "first_divergent_left_message": null,
1556            "first_divergent_right_message": null
1557        });
1558        let parsed = VerificationReport::from_current_json_value(current.clone())
1559            .expect("the complete current producer shape must parse");
1560        let comparison = parsed.comparison.expect("matched report has comparison");
1561        assert_eq!(comparison.compare_io_buffers, Some(true));
1562        assert_eq!(comparison.log_scope, Some(ComparedLogScope::Info));
1563        assert_eq!(parsed.guest_exit_code, Some(0));
1564        let mut missing_outputs = current.clone();
1565        missing_outputs
1566            .as_object_mut()
1567            .unwrap()
1568            .remove("compared_outputs");
1569        let error = VerificationReport::from_current_json_value(missing_outputs)
1570            .expect_err("a completed current report must carry exact output evidence");
1571        assert!(error.contains("compared_outputs"), "{error}");
1572        let mut null_outputs = current.clone();
1573        null_outputs["compared_outputs"] = serde_json::Value::Null;
1574        let error = VerificationReport::from_current_json_value(null_outputs)
1575            .expect_err("null is not output evidence for a completed current report");
1576        assert!(error.contains("compared_outputs"), "{error}");
1577        current["comparison"]
1578            .as_object_mut()
1579            .unwrap()
1580            .remove("compare_io_buffers");
1581        let error = VerificationReport::from_current_json_value(current)
1582            .expect_err("current comparison missing a producer field must be refused");
1583        assert!(
1584            error.contains("compare_io_buffers"),
1585            "refusal must name the missing comparison field: {error}"
1586        );
1587    }
1588
1589    #[test]
1590    fn current_raw_reports_reject_duplicates_and_preserve_required_fields() {
1591        let mut current = serde_json::json!({
1592            "verified": true,
1593            "bitwise_parity": true,
1594            "verdict": "matched",
1595            "no_result_reason": null,
1596            "infrastructure_error": null,
1597            "comparison": {
1598                "strictness": "canonical",
1599                "display_name": "BitwiseInfoV1",
1600                "compare_logs": true,
1601                "compare_io_buffers": true,
1602                "log_scope": "info",
1603                "record_envelope": "all_records_v1",
1604                "virtualize_time": true,
1605                "strip_lines": false,
1606                "canonicalize_addresses": true,
1607                "full_trace": true,
1608                "exact_remainder": true,
1609                "stripped_prefixes": ["real-wall-clock-prefix/v1"],
1610                "canonicalizations": ["host-address-to-first-appearance-ordinal/v1"],
1611                "ignore_lines": false,
1612                "skip_commit": false,
1613                "skip_detlog": false
1614            },
1615            "compared_log_messages": {"left": 1, "right": 1},
1616            "guest_exit_code": 0,
1617            "guest_signal": null,
1618            "first_divergent_scheduler_turn": null,
1619            "first_divergent_virtual_nanoseconds": null,
1620            "first_divergent_record": null,
1621            "first_divergent_syscall": null,
1622            "first_divergent_left_message": null,
1623            "first_divergent_right_message": null
1624        });
1625        let output = serde_json::json!({
1626            "exit_code": 0, "signal": null,
1627            "stdout_sha256": "a".repeat(64), "stdout_bytes": 0,
1628            "stderr_sha256": "b".repeat(64), "stderr_bytes": 0
1629        });
1630        current["compared_outputs"] = serde_json::json!({"left": output, "right": output});
1631        for mutation in [
1632            "unequal",
1633            "bad-digest",
1634            "two-dispositions",
1635            "wrong-disposition",
1636        ] {
1637            let mut bad = current.clone();
1638            match mutation {
1639                "unequal" => {
1640                    bad["compared_outputs"]["right"]["stdout_sha256"] = "c".repeat(64).into()
1641                }
1642                "bad-digest" => {
1643                    for side in ["left", "right"] {
1644                        bad["compared_outputs"][side]["stdout_sha256"] = "invalid".into();
1645                    }
1646                }
1647                "two-dispositions" => {
1648                    for side in ["left", "right"] {
1649                        bad["compared_outputs"][side]["signal"] = 9.into();
1650                    }
1651                }
1652                "wrong-disposition" => bad["guest_exit_code"] = 7.into(),
1653                _ => unreachable!(),
1654            }
1655            let bytes = serde_json::to_vec(&bad).unwrap();
1656            assert!(
1657                VerificationReport::from_current_json_slice(&bytes).is_err(),
1658                "{mutation}"
1659            );
1660            assert!(
1661                VerificationReport::from_retained_cell_json_slice(&bytes).is_err(),
1662                "{mutation}"
1663            );
1664            assert!(
1665                VerificationReport::from_json_slice(&bytes)
1666                    .unwrap()
1667                    .require_canonical_match()
1668                    .is_err(),
1669                "{mutation}"
1670            );
1671        }
1672        let mut historical = current.clone();
1673        historical
1674            .as_object_mut()
1675            .unwrap()
1676            .remove("compared_outputs");
1677        let bytes = serde_json::to_vec(&historical).unwrap();
1678        VerificationReport::from_retained_cell_json_slice(&bytes)
1679            .unwrap()
1680            .require_canonical_match()
1681            .unwrap();
1682        assert!(VerificationReport::from_current_json_slice(&bytes).is_err());
1683        let mut diverged = current.clone();
1684        diverged["verdict"] = "diverged".into();
1685        diverged["verified"] = false.into();
1686        diverged["bitwise_parity"] = false.into();
1687        diverged["compared_outputs"]["right"]["stdout_sha256"] = "c".repeat(64).into();
1688        VerificationReport::from_current_json_value(diverged).unwrap();
1689        let mut relaxed = current.clone();
1690        relaxed["comparison"]["strictness"] = "stripped".into();
1691        relaxed["bitwise_parity"] = false.into();
1692        VerificationReport::from_current_json_value(relaxed).unwrap();
1693        let raw = serde_json::to_string(&current).unwrap();
1694        VerificationReport::from_current_json_slice(raw.as_bytes())
1695            .unwrap()
1696            .require_canonical_match()
1697            .unwrap();
1698        for (needle, replacement) in [
1699            (r#""verified":true"#, r#""verified":false,"verified":true"#),
1700            (r#""verified":true"#, r#""verified":true,"verified":true"#),
1701            (
1702                r#""no_result_reason":null"#,
1703                r#""no_result_reason":{"kind":"not_run"},"no_result_reason":null"#,
1704            ),
1705            (
1706                r#""no_result_reason":null"#,
1707                r#""no_result_reason":null,"no_result_reason":null"#,
1708            ),
1709            (r#""left":1"#, r#""left":0,"left":1"#),
1710            (r#""left":1"#, r#""left":1,"left":1"#),
1711        ] {
1712            assert_eq!(raw.matches(needle).count(), 1);
1713            let duplicated = raw.replacen(needle, replacement, 1);
1714            let collapsed: serde_json::Value = serde_json::from_str(&duplicated).unwrap();
1715            assert_eq!(
1716                collapsed, current,
1717                "the last-value map loses this contradiction"
1718            );
1719            let error =
1720                VerificationReport::from_current_json_slice(duplicated.as_bytes()).unwrap_err();
1721            assert!(error.contains("duplicate field"), "{replacement}: {error}");
1722        }
1723        for field in ["no_result_reason", "guest_signal"] {
1724            let mut missing = current.clone();
1725            missing.as_object_mut().unwrap().remove(field);
1726            let error =
1727                VerificationReport::from_current_json_slice(&serde_json::to_vec(&missing).unwrap())
1728                    .unwrap_err();
1729            assert!(error.contains(field), "{error}");
1730        }
1731        let mut missing = current;
1732        missing["comparison"]
1733            .as_object_mut()
1734            .unwrap()
1735            .remove("compare_io_buffers");
1736        assert!(
1737            VerificationReport::from_current_json_slice(&serde_json::to_vec(&missing).unwrap())
1738                .unwrap_err()
1739                .contains("compare_io_buffers")
1740        );
1741        let mut refused = serde_json::to_value(VerificationReport::no_result()).unwrap();
1742        refused["no_result_reason"] = serde_json::json!({"kind":"comparison_refused","detail":"the second log was truncated"});
1743        VerificationReport::from_current_json_slice(&serde_json::to_vec(&refused).unwrap())
1744            .unwrap();
1745        refused["no_result_reason"]["detail"] = serde_json::json!("");
1746        assert!(
1747            VerificationReport::from_current_json_slice(&serde_json::to_vec(&refused).unwrap())
1748                .unwrap_err()
1749                .contains("detail")
1750        );
1751    }
1752
1753    #[test]
1754    fn unknown_verdict_is_refused_by_the_shared_type() {
1755        let unknown = serde_json::json!({
1756            "verified": false,
1757            "bitwise_parity": false,
1758            "verdict": "partly_matched",
1759            "comparison": null,
1760            "compared_log_messages": null
1761        });
1762        let error = VerificationReport::from_json_value(unknown)
1763            .expect_err("an unknown verdict must not become a product failure");
1764        assert!(
1765            error.contains("unknown variant"),
1766            "unexpected error: {error}"
1767        );
1768    }
1769
1770    #[test]
1771    fn infrastructure_error_is_distinct_from_no_result_and_requires_a_positive_count() {
1772        let mut report =
1773            serde_json::to_value(report(LogCompareStrictness::Canonical, true, 1, 1)).unwrap();
1774        report["verified"] = serde_json::json!(false);
1775        report["bitwise_parity"] = serde_json::json!(false);
1776        report["verdict"] = serde_json::json!("infrastructure_error");
1777        report["infrastructure_error"] = serde_json::json!({"kind": "skid_overshoot", "count": 2});
1778
1779        let parsed = VerificationReport::from_json_value(report.clone()).unwrap();
1780        assert_eq!(parsed.verdict, Verdict::InfrastructureError);
1781        assert_eq!(
1782            parsed.infrastructure_error,
1783            Some(InfrastructureError::SkidOvershoot { count: 2 })
1784        );
1785        assert!(parsed.comparison.is_some());
1786
1787        report["infrastructure_error"]["count"] = serde_json::json!(0);
1788        let error = VerificationReport::from_json_value(report)
1789            .expect_err("zero cannot describe an observed skid overshoot");
1790        assert!(error.contains("count must be positive"), "{error}");
1791    }
1792}