Skip to main content

termivar_core/
run_report.rs

1//! Transport-neutral run completion and audit records.
2//!
3//! These records describe whether work completed and which verifier outcomes or
4//! unresolved observations were retained. They do not turn an observation into
5//! a vulnerability finding and they do not grant execution authority.
6
7use std::{
8    collections::{BTreeSet, HashSet},
9    fmt,
10    marker::PhantomData,
11};
12
13use chrono::{DateTime, Utc};
14use serde::{
15    de::{DeserializeSeed, SeqAccess, Visitor},
16    Deserialize, Deserializer, Serialize,
17};
18use sha2::{Digest, Sha256};
19use thiserror::Error;
20
21use crate::{EntityId, EvidenceId, Outcome, OutcomeStatus, Probability, VerificationStage};
22
23/// Stable schema name for a typed Termivar run report.
24pub const RUN_REPORT_SCHEMA: &str = "venom-run/v1";
25/// Maximum bytes retained in a single human-readable report field.
26pub const MAX_RUN_REPORT_TEXT_BYTES: usize = 1_024;
27/// Maximum steps retained in one report.
28pub const MAX_RUN_REPORT_STEPS: usize = 4_096;
29/// Maximum outcome projections retained in one report.
30pub const MAX_RUN_REPORT_OUTCOMES: usize = 4_096;
31/// Maximum evidence references retained by one projected outcome.
32pub const MAX_RUN_REPORT_EVIDENCE_IDS: usize = 256;
33
34/// Validation errors for typed run reports.
35#[derive(Debug, Clone, PartialEq, Eq, Error)]
36#[non_exhaustive]
37pub enum RunReportError {
38    /// A required field was blank.
39    #[error("{field} must not be blank")]
40    Blank { field: &'static str },
41    /// A bounded string exceeded its limit.
42    #[error("{field} exceeds {limit} bytes")]
43    TextTooLong { field: &'static str, limit: usize },
44    /// A collection exceeded its retention ceiling.
45    #[error("{field} contains {actual} records; limit is {limit}")]
46    TooMany {
47        field: &'static str,
48        actual: usize,
49        limit: usize,
50    },
51    /// Completion predates the recorded start.
52    #[error("completed_at must not precede started_at")]
53    InvalidTimeRange,
54    /// A resource accounting record mixed metered and unmetered semantics.
55    #[error("invalid resource accounting: {reason}")]
56    InvalidAccounting { reason: &'static str },
57    /// A step ordinal was duplicated or out of order.
58    #[error("step ordinals must be strictly increasing")]
59    InvalidStepOrder,
60    /// Two executable steps declared the same host-visible identity.
61    #[error("registered step identities must be unique")]
62    DuplicateStepIdentity,
63    /// A mutable host view no longer matched the immutable authority captured
64    /// for this run before execution.
65    #[error("run authority no longer matches its captured execution scope")]
66    RunAuthorityMismatch,
67    /// An outcome fingerprint was malformed or duplicated.
68    #[error("invalid or duplicate outcome fingerprint")]
69    InvalidFingerprint,
70    /// An outcome projection contradicted the existing outcome contract.
71    #[error("invalid {status:?} outcome projection: {reason}")]
72    InvalidOutcome {
73        /// Existing verifier disposition being projected.
74        status: OutcomeStatus,
75        /// Stable explanation of the violated invariant.
76        reason: &'static str,
77    },
78    /// The run schema was not the supported version.
79    #[error("unsupported run report schema: {0}")]
80    UnsupportedSchema(String),
81    /// The overall status contradicted the typed stop classification.
82    #[error("run status {status:?} is incompatible with stop code {code:?}")]
83    IncompatibleStatusStop {
84        /// Overall completion state.
85        status: RunStatus,
86        /// Stop classification supplied with that state.
87        code: RunStopCode,
88    },
89    /// Step-level states contradicted the aggregate run state.
90    #[error("run status {status:?} with stop code {code:?} contradicts its steps: {reason}")]
91    InconsistentSteps {
92        /// Overall completion state.
93        status: RunStatus,
94        /// Stop classification supplied with that state.
95        code: RunStopCode,
96        /// Stable explanation of the mismatch.
97        reason: &'static str,
98    },
99}
100
101fn bounded(value: impl Into<String>, field: &'static str) -> Result<String, RunReportError> {
102    let value = value.into();
103    if value.trim().is_empty() {
104        return Err(RunReportError::Blank { field });
105    }
106    if value.len() > MAX_RUN_REPORT_TEXT_BYTES {
107        return Err(RunReportError::TextTooLong {
108            field,
109            limit: MAX_RUN_REPORT_TEXT_BYTES,
110        });
111    }
112    Ok(value)
113}
114
115fn deserialize_bounded_string<'de, D>(deserializer: D) -> Result<String, D::Error>
116where
117    D: Deserializer<'de>,
118{
119    BoundedStringSeed::new("run report text").deserialize(deserializer)
120}
121
122fn deserialize_optional_bounded_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
123where
124    D: Deserializer<'de>,
125{
126    deserializer.deserialize_option(BoundedOptionalStringVisitor::new("run report text"))
127}
128
129fn deserialize_bounded_entity_id<'de, D>(deserializer: D) -> Result<EntityId, D::Error>
130where
131    D: Deserializer<'de>,
132{
133    let value = BoundedStringSeed::new("run report entity id").deserialize(deserializer)?;
134    EntityId::new(value).map_err(serde::de::Error::custom)
135}
136
137struct BoundedStringSeed {
138    field: &'static str,
139}
140
141impl BoundedStringSeed {
142    const fn new(field: &'static str) -> Self {
143        Self { field }
144    }
145}
146
147impl<'de> DeserializeSeed<'de> for BoundedStringSeed {
148    type Value = String;
149
150    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
151    where
152        D: Deserializer<'de>,
153    {
154        deserializer.deserialize_str(BoundedStringVisitor::new(self.field))
155    }
156}
157
158struct BoundedStringVisitor {
159    field: &'static str,
160}
161
162impl BoundedStringVisitor {
163    const fn new(field: &'static str) -> Self {
164        Self { field }
165    }
166
167    fn check<E>(&self, value: &str) -> Result<(), E>
168    where
169        E: serde::de::Error,
170    {
171        if value.len() > MAX_RUN_REPORT_TEXT_BYTES {
172            return Err(E::custom(format_args!(
173                "{} exceeds the byte limit of {MAX_RUN_REPORT_TEXT_BYTES}",
174                self.field
175            )));
176        }
177        Ok(())
178    }
179}
180
181impl<'de> Visitor<'de> for BoundedStringVisitor {
182    type Value = String;
183
184    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        write!(
186            formatter,
187            "{} no longer than {MAX_RUN_REPORT_TEXT_BYTES} bytes",
188            self.field
189        )
190    }
191
192    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
193    where
194        E: serde::de::Error,
195    {
196        self.check(value)?;
197        Ok(value.to_owned())
198    }
199
200    fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
201    where
202        E: serde::de::Error,
203    {
204        self.visit_str(value)
205    }
206
207    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
208    where
209        E: serde::de::Error,
210    {
211        self.check(&value)?;
212        Ok(value)
213    }
214}
215
216struct BoundedOptionalStringVisitor {
217    field: &'static str,
218}
219
220impl BoundedOptionalStringVisitor {
221    const fn new(field: &'static str) -> Self {
222        Self { field }
223    }
224}
225
226impl<'de> Visitor<'de> for BoundedOptionalStringVisitor {
227    type Value = Option<String>;
228
229    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
230        write!(
231            formatter,
232            "null or {} no longer than {MAX_RUN_REPORT_TEXT_BYTES} bytes",
233            self.field
234        )
235    }
236
237    fn visit_none<E>(self) -> Result<Self::Value, E>
238    where
239        E: serde::de::Error,
240    {
241        Ok(None)
242    }
243
244    fn visit_unit<E>(self) -> Result<Self::Value, E>
245    where
246        E: serde::de::Error,
247    {
248        Ok(None)
249    }
250
251    fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
252    where
253        D: Deserializer<'de>,
254    {
255        BoundedStringSeed::new(self.field)
256            .deserialize(deserializer)
257            .map(Some)
258    }
259}
260
261/// Overall completion state for one run.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264#[non_exhaustive]
265pub enum RunStatus {
266    /// Every scheduled step reached its intended completion boundary.
267    Complete,
268    /// Some useful state was retained, but one or more steps did not complete.
269    Partial,
270    /// Host cancellation stopped the run.
271    Cancelled,
272    /// The run failed before it could produce a useful partial result.
273    Failed,
274}
275
276/// Stable classification for why a run stopped.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279#[non_exhaustive]
280pub enum RunStopCode {
281    /// The configured work completed.
282    Completed,
283    /// No action remained eligible.
284    NoEligibleAction,
285    /// A resource envelope stopped further work.
286    BudgetExhausted,
287    /// A report retention ceiling was reached after useful work completed.
288    ReportLimitExceeded,
289    /// The host cancelled the run.
290    Cancelled,
291    /// A phase returned an error.
292    StepFailed,
293    /// A phase exceeded its deadline.
294    StepTimedOut,
295    /// A task panicked or otherwise failed to join.
296    TaskJoinFailed,
297    /// Runtime orchestration failed.
298    RuntimeFailed,
299}
300
301/// Structured, bounded reason that ended a run.
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(try_from = "RunStopReasonWire")]
304pub struct RunStopReason {
305    code: RunStopCode,
306    detail: String,
307}
308
309#[derive(Deserialize)]
310#[serde(deny_unknown_fields)]
311struct RunStopReasonWire {
312    code: RunStopCode,
313    #[serde(deserialize_with = "deserialize_bounded_string")]
314    detail: String,
315}
316
317impl RunStopReason {
318    /// Creates a bounded stop reason.
319    pub fn new(code: RunStopCode, detail: impl Into<String>) -> Result<Self, RunReportError> {
320        Ok(Self {
321            code,
322            detail: bounded(detail, "stop reason")?,
323        })
324    }
325
326    /// Returns the stable stop classification.
327    pub const fn code(&self) -> RunStopCode {
328        self.code
329    }
330
331    /// Returns the bounded explanatory detail.
332    pub fn detail(&self) -> &str {
333        &self.detail
334    }
335}
336
337impl TryFrom<RunStopReasonWire> for RunStopReason {
338    type Error = RunReportError;
339
340    fn try_from(value: RunStopReasonWire) -> Result<Self, Self::Error> {
341        Self::new(value.code, value.detail)
342    }
343}
344
345/// Completion state for one phase or semantic action.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(rename_all = "snake_case")]
348#[non_exhaustive]
349pub enum RunStepStatus {
350    /// The step completed its objective.
351    Succeeded,
352    /// The step returned an error.
353    Failed,
354    /// The step exceeded its deadline.
355    TimedOut,
356    /// Host cancellation interrupted the step.
357    Cancelled,
358    /// Policy prevented the step from running.
359    Skipped,
360    /// Resource policy denied the step.
361    BudgetExhausted,
362}
363
364/// Typed severity attached to a projected outcome or unresolved observation.
365#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
366#[serde(rename_all = "snake_case")]
367#[non_exhaustive]
368pub enum SecuritySeverity {
369    /// Informational observation with no security impact claim.
370    Info,
371    /// Low severity.
372    Low,
373    /// Medium severity.
374    Medium,
375    /// High severity.
376    High,
377    /// Critical severity.
378    Critical,
379}
380
381/// Whether a resource dimension was enforced by a shared meter.
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "snake_case")]
384#[non_exhaustive]
385pub enum ResourceAccountingMode {
386    /// The runtime measured and enforced this dimension.
387    Metered,
388    /// The runtime observed consumption but did not enforce a limit.
389    Observed,
390    /// The runtime cannot truthfully report this dimension.
391    Unmetered,
392}
393
394/// Accounting for one resource dimension.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396#[serde(try_from = "ResourceAccountingWire")]
397pub struct ResourceAccounting {
398    mode: ResourceAccountingMode,
399    limit: Option<u64>,
400    consumed: Option<u64>,
401    remaining: Option<u64>,
402}
403
404#[derive(Deserialize)]
405#[serde(deny_unknown_fields)]
406struct ResourceAccountingWire {
407    mode: ResourceAccountingMode,
408    limit: Option<u64>,
409    consumed: Option<u64>,
410    remaining: Option<u64>,
411}
412
413impl ResourceAccounting {
414    /// Records a metered resource. `consumed` may exceed `limit` by the single
415    /// observation that revealed a boundary crossing.
416    pub const fn metered(limit: u64, consumed: u64) -> Self {
417        Self {
418            mode: ResourceAccountingMode::Metered,
419            limit: Some(limit),
420            consumed: Some(consumed),
421            remaining: Some(limit.saturating_sub(consumed)),
422        }
423    }
424
425    /// Records observed consumption for a dimension without claiming that the
426    /// runtime enforced a limit.
427    pub const fn observed(consumed: u64) -> Self {
428        Self {
429            mode: ResourceAccountingMode::Observed,
430            limit: None,
431            consumed: Some(consumed),
432            remaining: None,
433        }
434    }
435
436    /// Records that the runtime did not meter this dimension.
437    pub const fn unmetered() -> Self {
438        Self {
439            mode: ResourceAccountingMode::Unmetered,
440            limit: None,
441            consumed: None,
442            remaining: None,
443        }
444    }
445
446    /// Returns the accounting mode.
447    pub const fn mode(&self) -> ResourceAccountingMode {
448        self.mode
449    }
450
451    /// Returns the enforced limit when metered.
452    pub const fn limit(&self) -> Option<u64> {
453        self.limit
454    }
455
456    /// Returns measured consumption when metered.
457    pub const fn consumed(&self) -> Option<u64> {
458        self.consumed
459    }
460
461    /// Returns remaining capacity when metered.
462    pub const fn remaining(&self) -> Option<u64> {
463        self.remaining
464    }
465}
466
467impl TryFrom<ResourceAccountingWire> for ResourceAccounting {
468    type Error = RunReportError;
469
470    fn try_from(value: ResourceAccountingWire) -> Result<Self, Self::Error> {
471        match (value.mode, value.limit, value.consumed, value.remaining) {
472            (ResourceAccountingMode::Unmetered, None, None, None) => Ok(Self::unmetered()),
473            (ResourceAccountingMode::Observed, None, Some(consumed), None) => {
474                Ok(Self::observed(consumed))
475            },
476            (ResourceAccountingMode::Metered, Some(limit), Some(consumed), Some(remaining))
477                if remaining == limit.saturating_sub(consumed) =>
478            {
479                Ok(Self::metered(limit, consumed))
480            },
481            _ => Err(RunReportError::InvalidAccounting {
482                reason: "mode and values are inconsistent",
483            }),
484        }
485    }
486}
487
488/// Run-level resource accounting.
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
490#[serde(deny_unknown_fields)]
491pub struct RunAccounting {
492    requests: ResourceAccounting,
493    response_body_bytes: ResourceAccounting,
494    request_body_bytes: ResourceAccounting,
495    wall_time_ms: ResourceAccounting,
496}
497
498impl RunAccounting {
499    /// Creates accounting for all required resource dimensions.
500    pub const fn new(
501        requests: ResourceAccounting,
502        response_body_bytes: ResourceAccounting,
503        request_body_bytes: ResourceAccounting,
504        wall_time_ms: ResourceAccounting,
505    ) -> Self {
506        Self {
507            requests,
508            response_body_bytes,
509            request_body_bytes,
510            wall_time_ms,
511        }
512    }
513
514    /// Returns fully explicit unmetered accounting.
515    pub const fn unmetered() -> Self {
516        Self::new(
517            ResourceAccounting::unmetered(),
518            ResourceAccounting::unmetered(),
519            ResourceAccounting::unmetered(),
520            ResourceAccounting::unmetered(),
521        )
522    }
523
524    /// Returns request accounting.
525    pub const fn requests(&self) -> &ResourceAccounting {
526        &self.requests
527    }
528
529    /// Returns response-body accounting.
530    pub const fn response_body_bytes(&self) -> &ResourceAccounting {
531        &self.response_body_bytes
532    }
533
534    /// Returns request-body accounting.
535    pub const fn request_body_bytes(&self) -> &ResourceAccounting {
536        &self.request_body_bytes
537    }
538
539    /// Returns wall-time accounting.
540    pub const fn wall_time_ms(&self) -> &ResourceAccounting {
541        &self.wall_time_ms
542    }
543}
544
545/// One attempted phase or semantic action.
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547#[serde(try_from = "RunStepReportWire")]
548pub struct RunStepReport {
549    ordinal: u32,
550    action_id: String,
551    status: RunStepStatus,
552    duration_ms: u64,
553    detail: Option<String>,
554}
555
556#[derive(Deserialize)]
557#[serde(deny_unknown_fields)]
558struct RunStepReportWire {
559    ordinal: u32,
560    #[serde(deserialize_with = "deserialize_bounded_string")]
561    action_id: String,
562    status: RunStepStatus,
563    duration_ms: u64,
564    #[serde(default, deserialize_with = "deserialize_optional_bounded_string")]
565    detail: Option<String>,
566}
567
568impl RunStepReport {
569    /// Creates a bounded step report.
570    pub fn new(
571        ordinal: u32,
572        action_id: impl Into<String>,
573        status: RunStepStatus,
574        duration_ms: u64,
575        detail: Option<String>,
576    ) -> Result<Self, RunReportError> {
577        Ok(Self {
578            ordinal,
579            action_id: bounded(action_id, "step action id")?,
580            status,
581            duration_ms,
582            detail: detail
583                .map(|value| bounded(value, "step detail"))
584                .transpose()?,
585        })
586    }
587
588    /// Returns the stable step ordinal.
589    pub const fn ordinal(&self) -> u32 {
590        self.ordinal
591    }
592
593    /// Returns the phase or action identity.
594    pub fn action_id(&self) -> &str {
595        &self.action_id
596    }
597
598    /// Returns the completion state.
599    pub const fn status(&self) -> RunStepStatus {
600        self.status
601    }
602
603    /// Returns the observed duration.
604    pub const fn duration_ms(&self) -> u64 {
605        self.duration_ms
606    }
607
608    /// Returns bounded diagnostic detail.
609    pub fn detail(&self) -> Option<&str> {
610        self.detail.as_deref()
611    }
612}
613
614impl TryFrom<RunStepReportWire> for RunStepReport {
615    type Error = RunReportError;
616
617    fn try_from(value: RunStepReportWire) -> Result<Self, Self::Error> {
618        Self::new(
619            value.ordinal,
620            value.action_id,
621            value.status,
622            value.duration_ms,
623            value.detail,
624        )
625    }
626}
627
628/// A verifier outcome or unresolved observation projected into a run report.
629///
630/// Non-unknown records can only be created from an already validated
631/// [`Outcome`]. The flattened fields make reports convenient to consume while
632/// the embedded outcome preserves the verifier rule, case, hypothesis, stage,
633/// and evidence provenance needed to revalidate a serialized record.
634#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
635#[serde(try_from = "RunOutcomeRecordWire")]
636pub struct RunOutcomeRecord {
637    fingerprint: String,
638    subject: EntityId,
639    action_id: String,
640    severity: SecuritySeverity,
641    disposition: OutcomeStatus,
642    confidence: Probability,
643    evidence_ids: BTreeSet<EvidenceId>,
644    rationale: String,
645    redacted_summary: String,
646    verification: Option<Outcome>,
647}
648
649#[derive(Deserialize)]
650#[serde(deny_unknown_fields)]
651struct RunOutcomeRecordWire {
652    #[serde(deserialize_with = "deserialize_bounded_string")]
653    fingerprint: String,
654    #[serde(deserialize_with = "deserialize_bounded_entity_id")]
655    subject: EntityId,
656    #[serde(deserialize_with = "deserialize_bounded_string")]
657    action_id: String,
658    severity: SecuritySeverity,
659    disposition: OutcomeStatus,
660    confidence: Probability,
661    #[serde(deserialize_with = "deserialize_evidence_ids")]
662    evidence_ids: BTreeSet<EvidenceId>,
663    #[serde(deserialize_with = "deserialize_bounded_string")]
664    rationale: String,
665    #[serde(deserialize_with = "deserialize_bounded_string")]
666    redacted_summary: String,
667    verification: Option<VerificationOutcomeWire>,
668}
669
670#[derive(Deserialize)]
671#[serde(deny_unknown_fields)]
672struct VerificationOutcomeWire {
673    #[serde(deserialize_with = "deserialize_bounded_string")]
674    case_id: String,
675    #[serde(deserialize_with = "deserialize_bounded_entity_id")]
676    subject: EntityId,
677    #[serde(deserialize_with = "deserialize_bounded_string")]
678    action_id: String,
679    #[serde(deserialize_with = "deserialize_bounded_string")]
680    hypothesis_id: String,
681    #[serde(default, deserialize_with = "deserialize_optional_bounded_string")]
682    verifier_rule_id: Option<String>,
683    stage: VerificationStage,
684    status: OutcomeStatus,
685    confidence: Probability,
686    #[serde(deserialize_with = "deserialize_bounded_string")]
687    rationale: String,
688    #[serde(deserialize_with = "deserialize_evidence_ids")]
689    evidence_ids: BTreeSet<EvidenceId>,
690}
691
692fn deserialize_evidence_ids<'de, D>(deserializer: D) -> Result<BTreeSet<EvidenceId>, D::Error>
693where
694    D: Deserializer<'de>,
695{
696    deserializer.deserialize_seq(BoundedEvidenceIdsVisitor)
697}
698
699struct BoundedEvidenceIdsVisitor;
700
701struct BoundedEvidenceIdSeed;
702
703struct BoundedEvidenceIdVisitor;
704
705impl<'de> DeserializeSeed<'de> for BoundedEvidenceIdSeed {
706    type Value = EvidenceId;
707
708    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
709    where
710        D: Deserializer<'de>,
711    {
712        deserializer.deserialize_str(BoundedEvidenceIdVisitor)
713    }
714}
715
716impl<'de> Visitor<'de> for BoundedEvidenceIdVisitor {
717    type Value = EvidenceId;
718
719    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
720        write!(
721            formatter,
722            "an evidence identifier no longer than {MAX_RUN_REPORT_TEXT_BYTES} bytes"
723        )
724    }
725
726    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
727    where
728        E: serde::de::Error,
729    {
730        if value.len() > MAX_RUN_REPORT_TEXT_BYTES {
731            return Err(E::custom(format_args!(
732                "outcome evidence id exceeds the byte limit of {MAX_RUN_REPORT_TEXT_BYTES}"
733            )));
734        }
735        EvidenceId::parse(value).map_err(E::custom)
736    }
737
738    fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
739    where
740        E: serde::de::Error,
741    {
742        self.visit_str(value)
743    }
744
745    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
746    where
747        E: serde::de::Error,
748    {
749        self.visit_str(&value)
750    }
751}
752
753impl<'de> Visitor<'de> for BoundedEvidenceIdsVisitor {
754    type Value = BTreeSet<EvidenceId>;
755
756    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
757        write!(
758            formatter,
759            "at most {MAX_RUN_REPORT_EVIDENCE_IDS} encoded evidence identifiers"
760        )
761    }
762
763    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
764    where
765        A: SeqAccess<'de>,
766    {
767        if sequence
768            .size_hint()
769            .is_some_and(|size| size > MAX_RUN_REPORT_EVIDENCE_IDS)
770        {
771            return Err(serde::de::Error::custom(format_args!(
772                "outcome evidence ids exceed the encoded-entry limit of {MAX_RUN_REPORT_EVIDENCE_IDS}"
773            )));
774        }
775
776        let mut evidence_ids = BTreeSet::new();
777        let mut encoded_entries = 0_usize;
778        while let Some(evidence_id) = sequence.next_element_seed(BoundedEvidenceIdSeed)? {
779            encoded_entries += 1;
780            if encoded_entries > MAX_RUN_REPORT_EVIDENCE_IDS {
781                return Err(serde::de::Error::custom(format_args!(
782                    "outcome evidence ids exceed the encoded-entry limit of {MAX_RUN_REPORT_EVIDENCE_IDS}"
783                )));
784            }
785            evidence_ids.insert(evidence_id);
786        }
787        Ok(evidence_ids)
788    }
789}
790
791impl VerificationOutcomeWire {
792    fn into_outcome(self) -> Result<Outcome, RunReportError> {
793        if self.status == OutcomeStatus::Unknown {
794            if self.verifier_rule_id.is_some()
795                || self.confidence != Probability::ZERO
796                || !self.evidence_ids.is_empty()
797            {
798                return Err(RunReportError::InvalidOutcome {
799                    status: self.status,
800                    reason: "unknown verifier outcome has non-canonical provenance",
801                });
802            }
803            return Outcome::unknown(
804                self.case_id,
805                self.subject,
806                self.action_id,
807                self.hypothesis_id,
808                self.stage,
809                self.rationale,
810            )
811            .map_err(|_| RunReportError::InvalidOutcome {
812                status: self.status,
813                reason: "embedded verifier outcome is invalid",
814            });
815        }
816
817        let verifier_rule_id = self
818            .verifier_rule_id
819            .ok_or(RunReportError::InvalidOutcome {
820                status: self.status,
821                reason: "verified outcome is missing verifier rule provenance",
822            })?;
823        Outcome::verified(
824            self.case_id,
825            self.subject,
826            self.action_id,
827            self.hypothesis_id,
828            verifier_rule_id,
829            self.stage,
830            self.status,
831            self.confidence,
832            self.rationale,
833            self.evidence_ids,
834        )
835        .map_err(|_| RunReportError::InvalidOutcome {
836            status: self.status,
837            reason: "embedded verifier outcome is invalid",
838        })
839    }
840}
841
842impl RunOutcomeRecord {
843    /// Creates the canonical projection for an unresolved observation that did
844    /// not pass through a verifier case.
845    pub fn unresolved(
846        subject: EntityId,
847        action_id: impl Into<String>,
848        rationale: impl Into<String>,
849        redacted_summary: impl Into<String>,
850    ) -> Result<Self, RunReportError> {
851        let mut record = Self {
852            fingerprint: String::new(),
853            subject,
854            action_id: bounded(action_id, "outcome action id")?,
855            severity: SecuritySeverity::Info,
856            disposition: OutcomeStatus::Unknown,
857            confidence: Probability::ZERO,
858            evidence_ids: BTreeSet::new(),
859            rationale: bounded(rationale, "outcome rationale")?,
860            redacted_summary: bounded(redacted_summary, "redacted evidence summary")?,
861            verification: None,
862        };
863        validate_record_bounds(&record)?;
864        record.fingerprint = outcome_fingerprint(&record);
865        Ok(record)
866    }
867
868    /// Projects an existing verifier-owned outcome without weakening its case,
869    /// rule, confidence, or immutable evidence requirements.
870    ///
871    /// Projection remains informational because verifier success does not by
872    /// itself establish security impact or finding severity. [`Outcome`] also
873    /// does not carry the orchestration-level `VerificationCase` transition
874    /// permission: a knowledge-only `Success` is therefore not promoted into a
875    /// finding or confirmed claim here. A future impact policy may add a
876    /// narrower constructor without weakening this boundary.
877    pub fn from_outcome(
878        outcome: Outcome,
879        redacted_summary: impl Into<String>,
880    ) -> Result<Self, RunReportError> {
881        let mut record = Self {
882            fingerprint: String::new(),
883            subject: outcome.subject().clone(),
884            action_id: outcome.action_id().to_string(),
885            severity: SecuritySeverity::Info,
886            disposition: outcome.status(),
887            confidence: outcome.confidence(),
888            evidence_ids: outcome.evidence_ids().clone(),
889            rationale: bounded(outcome.rationale(), "outcome rationale")?,
890            redacted_summary: bounded(redacted_summary, "redacted evidence summary")?,
891            verification: Some(outcome),
892        };
893        validate_record_bounds(&record)?;
894        record.fingerprint = outcome_fingerprint(&record);
895        Ok(record)
896    }
897
898    /// Returns the verifier-owned outcome when this is a verification
899    /// projection. Unresolved compatibility observations return `None`.
900    pub const fn verification_outcome(&self) -> Option<&Outcome> {
901        self.verification.as_ref()
902    }
903
904    /// Returns the stable deterministic fingerprint.
905    pub fn fingerprint(&self) -> &str {
906        &self.fingerprint
907    }
908
909    /// Returns the target subject.
910    pub const fn subject(&self) -> &EntityId {
911        &self.subject
912    }
913
914    /// Returns the producer action or module identity.
915    pub fn action_id(&self) -> &str {
916        &self.action_id
917    }
918
919    /// Returns typed severity.
920    pub const fn severity(&self) -> SecuritySeverity {
921        self.severity
922    }
923
924    /// Returns the existing verifier disposition vocabulary.
925    pub const fn disposition(&self) -> OutcomeStatus {
926        self.disposition
927    }
928
929    /// Returns confidence in the projected record.
930    pub const fn confidence(&self) -> Probability {
931        self.confidence
932    }
933
934    /// Returns correlated immutable evidence identifiers.
935    pub const fn evidence_ids(&self) -> &BTreeSet<EvidenceId> {
936        &self.evidence_ids
937    }
938
939    /// Returns the bounded rationale.
940    pub fn rationale(&self) -> &str {
941        &self.rationale
942    }
943
944    /// Returns the bounded, redacted evidence summary.
945    pub fn redacted_summary(&self) -> &str {
946        &self.redacted_summary
947    }
948}
949
950fn validate_record_bounds(record: &RunOutcomeRecord) -> Result<(), RunReportError> {
951    if record.action_id.len() > MAX_RUN_REPORT_TEXT_BYTES {
952        return Err(RunReportError::TextTooLong {
953            field: "outcome action id",
954            limit: MAX_RUN_REPORT_TEXT_BYTES,
955        });
956    }
957    if record.evidence_ids.len() > MAX_RUN_REPORT_EVIDENCE_IDS {
958        return Err(RunReportError::TooMany {
959            field: "outcome evidence ids",
960            actual: record.evidence_ids.len(),
961            limit: MAX_RUN_REPORT_EVIDENCE_IDS,
962        });
963    }
964    if record
965        .evidence_ids
966        .iter()
967        .any(|evidence_id| evidence_id.as_str().len() > MAX_RUN_REPORT_TEXT_BYTES)
968    {
969        return Err(RunReportError::TextTooLong {
970            field: "outcome evidence id",
971            limit: MAX_RUN_REPORT_TEXT_BYTES,
972        });
973    }
974    if record.subject.as_str().len() > MAX_RUN_REPORT_TEXT_BYTES {
975        return Err(RunReportError::TextTooLong {
976            field: "outcome subject",
977            limit: MAX_RUN_REPORT_TEXT_BYTES,
978        });
979    }
980    if let Some(outcome) = &record.verification {
981        for (field, value) in [
982            ("verification case id", outcome.case_id()),
983            ("verification hypothesis id", outcome.hypothesis_id()),
984        ] {
985            if value.len() > MAX_RUN_REPORT_TEXT_BYTES {
986                return Err(RunReportError::TextTooLong {
987                    field,
988                    limit: MAX_RUN_REPORT_TEXT_BYTES,
989                });
990            }
991        }
992        if outcome
993            .verifier_rule_id()
994            .is_some_and(|value| value.len() > MAX_RUN_REPORT_TEXT_BYTES)
995        {
996            return Err(RunReportError::TextTooLong {
997                field: "verifier rule id",
998                limit: MAX_RUN_REPORT_TEXT_BYTES,
999            });
1000        }
1001    }
1002    Ok(())
1003}
1004
1005impl TryFrom<RunOutcomeRecordWire> for RunOutcomeRecord {
1006    type Error = RunReportError;
1007
1008    fn try_from(value: RunOutcomeRecordWire) -> Result<Self, Self::Error> {
1009        let rebuilt = match value.verification {
1010            Some(outcome) => Self::from_outcome(outcome.into_outcome()?, value.redacted_summary)?,
1011            None => Self::unresolved(
1012                value.subject.clone(),
1013                value.action_id.clone(),
1014                value.rationale.clone(),
1015                value.redacted_summary,
1016            )?,
1017        };
1018        if rebuilt.fingerprint != value.fingerprint
1019            || rebuilt.subject != value.subject
1020            || rebuilt.action_id != value.action_id
1021            || rebuilt.severity != value.severity
1022            || rebuilt.disposition != value.disposition
1023            || rebuilt.confidence != value.confidence
1024            || rebuilt.evidence_ids != value.evidence_ids
1025            || rebuilt.rationale != value.rationale
1026        {
1027            return Err(RunReportError::InvalidFingerprint);
1028        }
1029        Ok(rebuilt)
1030    }
1031}
1032
1033fn outcome_fingerprint(record: &RunOutcomeRecord) -> String {
1034    let mut digest = Sha256::new();
1035    digest.update(b"venom.run-outcome.v1\0");
1036    digest_field(
1037        &mut digest,
1038        if record.verification.is_some() {
1039            "verified"
1040        } else {
1041            "unresolved"
1042        },
1043    );
1044    digest_field(&mut digest, record.subject.as_str());
1045    digest_field(&mut digest, &record.action_id);
1046    digest_field(&mut digest, outcome_code(record.disposition));
1047    digest.update(record.confidence.parts_per_million().to_be_bytes());
1048    for evidence_id in &record.evidence_ids {
1049        digest_field(&mut digest, evidence_id.as_str());
1050    }
1051    if let Some(outcome) = &record.verification {
1052        for field in [
1053            outcome.case_id(),
1054            outcome.hypothesis_id(),
1055            outcome.verifier_rule_id().unwrap_or(""),
1056            outcome.stage().as_str(),
1057        ] {
1058            digest_field(&mut digest, field);
1059        }
1060    }
1061    format!("sha256:{:x}", digest.finalize())
1062}
1063
1064fn digest_field(digest: &mut Sha256, value: &str) {
1065    digest.update((value.len() as u64).to_be_bytes());
1066    digest.update(value.as_bytes());
1067}
1068
1069fn outcome_code(value: OutcomeStatus) -> &'static str {
1070    match value {
1071        OutcomeStatus::Success => "success",
1072        OutcomeStatus::Blocked => "blocked",
1073        OutcomeStatus::Unknown => "unknown",
1074        OutcomeStatus::FalsePositive => "false_positive",
1075        OutcomeStatus::NeedsReview => "needs_review",
1076        OutcomeStatus::ConfirmedNegative => "confirmed_negative",
1077    }
1078}
1079
1080/// Complete transport-neutral report for one authorized run.
1081#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1082#[serde(try_from = "RunReportWire")]
1083pub struct RunReport {
1084    schema: String,
1085    status: RunStatus,
1086    stop_reason: RunStopReason,
1087    target: String,
1088    authorized_origin: String,
1089    started_at: DateTime<Utc>,
1090    completed_at: DateTime<Utc>,
1091    accounting: RunAccounting,
1092    steps: Vec<RunStepReport>,
1093    outcomes: Vec<RunOutcomeRecord>,
1094}
1095
1096#[derive(Deserialize)]
1097#[serde(deny_unknown_fields)]
1098struct RunReportWire {
1099    #[serde(deserialize_with = "deserialize_bounded_string")]
1100    schema: String,
1101    status: RunStatus,
1102    stop_reason: RunStopReason,
1103    #[serde(deserialize_with = "deserialize_bounded_string")]
1104    target: String,
1105    #[serde(deserialize_with = "deserialize_bounded_string")]
1106    authorized_origin: String,
1107    started_at: DateTime<Utc>,
1108    completed_at: DateTime<Utc>,
1109    accounting: RunAccounting,
1110    #[serde(deserialize_with = "deserialize_run_steps")]
1111    steps: Vec<RunStepReport>,
1112    #[serde(deserialize_with = "deserialize_run_outcomes")]
1113    outcomes: Vec<RunOutcomeRecord>,
1114}
1115
1116fn deserialize_run_steps<'de, D>(deserializer: D) -> Result<Vec<RunStepReport>, D::Error>
1117where
1118    D: Deserializer<'de>,
1119{
1120    deserializer.deserialize_seq(BoundedVecVisitor::<RunStepReport>::new(
1121        "run steps",
1122        MAX_RUN_REPORT_STEPS,
1123    ))
1124}
1125
1126fn deserialize_run_outcomes<'de, D>(deserializer: D) -> Result<Vec<RunOutcomeRecord>, D::Error>
1127where
1128    D: Deserializer<'de>,
1129{
1130    deserializer.deserialize_seq(BoundedVecVisitor::<RunOutcomeRecord>::new(
1131        "run outcomes",
1132        MAX_RUN_REPORT_OUTCOMES,
1133    ))
1134}
1135
1136struct BoundedVecVisitor<T> {
1137    field: &'static str,
1138    limit: usize,
1139    marker: PhantomData<T>,
1140}
1141
1142impl<T> BoundedVecVisitor<T> {
1143    const fn new(field: &'static str, limit: usize) -> Self {
1144        Self {
1145            field,
1146            limit,
1147            marker: PhantomData,
1148        }
1149    }
1150}
1151
1152impl<'de, T> Visitor<'de> for BoundedVecVisitor<T>
1153where
1154    T: Deserialize<'de>,
1155{
1156    type Value = Vec<T>;
1157
1158    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1159        write!(formatter, "at most {} {}", self.limit, self.field)
1160    }
1161
1162    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1163    where
1164        A: SeqAccess<'de>,
1165    {
1166        if sequence.size_hint().is_some_and(|size| size > self.limit) {
1167            return Err(serde::de::Error::custom(format_args!(
1168                "{} exceeds the limit of {}",
1169                self.field, self.limit
1170            )));
1171        }
1172        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(self.limit));
1173        while let Some(value) = sequence.next_element()? {
1174            if values.len() == self.limit {
1175                return Err(serde::de::Error::custom(format_args!(
1176                    "{} exceeds the limit of {}",
1177                    self.field, self.limit
1178                )));
1179            }
1180            values.push(value);
1181        }
1182        Ok(values)
1183    }
1184}
1185
1186/// Construction input for a validated [`RunReport`].
1187#[derive(Debug, Clone)]
1188pub struct RunReportInput {
1189    status: RunStatus,
1190    stop_reason: RunStopReason,
1191    target: String,
1192    authorized_origin: String,
1193    started_at: DateTime<Utc>,
1194    completed_at: DateTime<Utc>,
1195    accounting: RunAccounting,
1196    steps: Vec<RunStepReport>,
1197    outcomes: Vec<RunOutcomeRecord>,
1198}
1199
1200impl RunReportInput {
1201    /// Creates the required run envelope with explicit unmetered accounting.
1202    pub fn new(
1203        status: RunStatus,
1204        stop_reason: RunStopReason,
1205        target: impl Into<String>,
1206        authorized_origin: impl Into<String>,
1207        started_at: DateTime<Utc>,
1208        completed_at: DateTime<Utc>,
1209    ) -> Result<Self, RunReportError> {
1210        Ok(Self {
1211            status,
1212            stop_reason,
1213            target: bounded(target, "run target")?,
1214            authorized_origin: bounded(authorized_origin, "authorized origin")?,
1215            started_at,
1216            completed_at,
1217            accounting: RunAccounting::unmetered(),
1218            steps: Vec::new(),
1219            outcomes: Vec::new(),
1220        })
1221    }
1222
1223    /// Replaces resource accounting with the host's explicit measurements.
1224    pub fn with_accounting(mut self, accounting: RunAccounting) -> Self {
1225        self.accounting = accounting;
1226        self
1227    }
1228
1229    /// Attaches attempted steps in execution order.
1230    pub fn with_steps(mut self, steps: Vec<RunStepReport>) -> Self {
1231        self.steps = steps;
1232        self
1233    }
1234
1235    /// Attaches projected outcomes and unresolved observations.
1236    pub fn with_outcomes(mut self, outcomes: Vec<RunOutcomeRecord>) -> Self {
1237        self.outcomes = outcomes;
1238        self
1239    }
1240}
1241
1242impl RunReport {
1243    /// Creates and validates a complete run report.
1244    pub fn new(input: RunReportInput) -> Result<Self, RunReportError> {
1245        if input.completed_at < input.started_at {
1246            return Err(RunReportError::InvalidTimeRange);
1247        }
1248        if !status_matches_stop(input.status, input.stop_reason.code()) {
1249            return Err(RunReportError::IncompatibleStatusStop {
1250                status: input.status,
1251                code: input.stop_reason.code(),
1252            });
1253        }
1254        if input.steps.len() > MAX_RUN_REPORT_STEPS {
1255            return Err(RunReportError::TooMany {
1256                field: "run steps",
1257                actual: input.steps.len(),
1258                limit: MAX_RUN_REPORT_STEPS,
1259            });
1260        }
1261        if input.outcomes.len() > MAX_RUN_REPORT_OUTCOMES {
1262            return Err(RunReportError::TooMany {
1263                field: "run outcomes",
1264                actual: input.outcomes.len(),
1265                limit: MAX_RUN_REPORT_OUTCOMES,
1266            });
1267        }
1268        if input
1269            .steps
1270            .windows(2)
1271            .any(|pair| pair[0].ordinal() >= pair[1].ordinal())
1272        {
1273            return Err(RunReportError::InvalidStepOrder);
1274        }
1275        validate_aggregate_consistency(
1276            input.status,
1277            input.stop_reason.code(),
1278            &input.steps,
1279            &input.outcomes,
1280        )?;
1281        let mut fingerprints = HashSet::with_capacity(input.outcomes.len());
1282        if input
1283            .outcomes
1284            .iter()
1285            .any(|record| !fingerprints.insert(record.fingerprint()))
1286        {
1287            return Err(RunReportError::InvalidFingerprint);
1288        }
1289        Ok(Self {
1290            schema: RUN_REPORT_SCHEMA.to_string(),
1291            status: input.status,
1292            stop_reason: input.stop_reason,
1293            target: input.target,
1294            authorized_origin: input.authorized_origin,
1295            started_at: input.started_at,
1296            completed_at: input.completed_at,
1297            accounting: input.accounting,
1298            steps: input.steps,
1299            outcomes: input.outcomes,
1300        })
1301    }
1302
1303    /// Returns the stable schema name.
1304    pub fn schema(&self) -> &str {
1305        &self.schema
1306    }
1307
1308    /// Returns overall completion status.
1309    pub const fn status(&self) -> RunStatus {
1310        self.status
1311    }
1312
1313    /// Returns the structured stop reason.
1314    pub const fn stop_reason(&self) -> &RunStopReason {
1315        &self.stop_reason
1316    }
1317
1318    /// Returns the sanitized target representation.
1319    pub fn target(&self) -> &str {
1320        &self.target
1321    }
1322
1323    /// Returns the authorized origin.
1324    pub fn authorized_origin(&self) -> &str {
1325        &self.authorized_origin
1326    }
1327
1328    /// Returns the UTC start instant.
1329    pub const fn started_at(&self) -> DateTime<Utc> {
1330        self.started_at
1331    }
1332
1333    /// Returns the UTC completion instant.
1334    pub const fn completed_at(&self) -> DateTime<Utc> {
1335        self.completed_at
1336    }
1337
1338    /// Returns resource accounting.
1339    pub const fn accounting(&self) -> &RunAccounting {
1340        &self.accounting
1341    }
1342
1343    /// Returns attempted steps in execution order.
1344    pub fn steps(&self) -> &[RunStepReport] {
1345        &self.steps
1346    }
1347
1348    /// Returns outcome/observation projections.
1349    pub fn outcomes(&self) -> &[RunOutcomeRecord] {
1350        &self.outcomes
1351    }
1352}
1353
1354impl TryFrom<RunReportWire> for RunReport {
1355    type Error = RunReportError;
1356
1357    fn try_from(value: RunReportWire) -> Result<Self, Self::Error> {
1358        if value.schema != RUN_REPORT_SCHEMA {
1359            return Err(RunReportError::UnsupportedSchema(value.schema));
1360        }
1361        let input = RunReportInput::new(
1362            value.status,
1363            value.stop_reason,
1364            value.target,
1365            value.authorized_origin,
1366            value.started_at,
1367            value.completed_at,
1368        )?
1369        .with_accounting(value.accounting)
1370        .with_steps(value.steps)
1371        .with_outcomes(value.outcomes);
1372        Self::new(input)
1373    }
1374}
1375
1376fn status_matches_stop(status: RunStatus, code: RunStopCode) -> bool {
1377    match status {
1378        RunStatus::Complete => {
1379            matches!(code, RunStopCode::Completed | RunStopCode::NoEligibleAction)
1380        },
1381        RunStatus::Partial => matches!(
1382            code,
1383            RunStopCode::NoEligibleAction
1384                | RunStopCode::BudgetExhausted
1385                | RunStopCode::ReportLimitExceeded
1386                | RunStopCode::StepFailed
1387                | RunStopCode::StepTimedOut
1388                | RunStopCode::TaskJoinFailed
1389                | RunStopCode::RuntimeFailed
1390        ),
1391        RunStatus::Cancelled => code == RunStopCode::Cancelled,
1392        RunStatus::Failed => matches!(
1393            code,
1394            RunStopCode::BudgetExhausted
1395                | RunStopCode::StepFailed
1396                | RunStopCode::StepTimedOut
1397                | RunStopCode::TaskJoinFailed
1398                | RunStopCode::RuntimeFailed
1399        ),
1400    }
1401}
1402
1403fn validate_aggregate_consistency(
1404    status: RunStatus,
1405    code: RunStopCode,
1406    steps: &[RunStepReport],
1407    outcomes: &[RunOutcomeRecord],
1408) -> Result<(), RunReportError> {
1409    let has = |wanted| steps.iter().any(|step| step.status() == wanted);
1410    let has_success = has(RunStepStatus::Succeeded);
1411    let has_failure = has(RunStepStatus::Failed);
1412    let has_timeout = has(RunStepStatus::TimedOut);
1413    let has_cancelled = has(RunStepStatus::Cancelled);
1414    let has_skipped = has(RunStepStatus::Skipped);
1415    let has_budget_exhausted = has(RunStepStatus::BudgetExhausted);
1416    let useful_state = has_success || !outcomes.is_empty();
1417
1418    let inconsistent = |reason| {
1419        Err(RunReportError::InconsistentSteps {
1420            status,
1421            code,
1422            reason,
1423        })
1424    };
1425
1426    match status {
1427        RunStatus::Complete
1428            if steps
1429                .iter()
1430                .any(|step| step.status() != RunStepStatus::Succeeded) =>
1431        {
1432            return inconsistent("complete runs may contain only succeeded steps");
1433        },
1434        RunStatus::Complete if code == RunStopCode::Completed && steps.is_empty() => {
1435            return inconsistent("completed runs require at least one succeeded step");
1436        },
1437        RunStatus::Partial if !useful_state => {
1438            return inconsistent("partial runs must retain a successful step or outcome");
1439        },
1440        RunStatus::Partial if has_cancelled => {
1441            return inconsistent("a cancelled step requires cancelled aggregate status");
1442        },
1443        RunStatus::Failed if useful_state => {
1444            return inconsistent("failed runs cannot retain successful work as if it were absent");
1445        },
1446        RunStatus::Cancelled if !steps.is_empty() && !has_cancelled && !has_skipped => {
1447            return inconsistent("cancelled runs with steps require a cancelled or skipped step");
1448        },
1449        _ => {},
1450    }
1451
1452    match code {
1453        RunStopCode::StepFailed if !has_failure => {
1454            inconsistent("step_failed requires a failed step")
1455        },
1456        RunStopCode::StepTimedOut if !has_timeout => {
1457            inconsistent("step_timed_out requires a timed-out step")
1458        },
1459        RunStopCode::TaskJoinFailed if !has_failure => {
1460            inconsistent("task_join_failed requires a failed step receipt")
1461        },
1462        RunStopCode::BudgetExhausted
1463            if status == RunStatus::Partial && !has_budget_exhausted && !has_success =>
1464        {
1465            inconsistent("partial budget exhaustion requires completed or denied step evidence")
1466        },
1467        RunStopCode::ReportLimitExceeded if outcomes.is_empty() => {
1468            inconsistent("report retention exhaustion requires retained outcomes")
1469        },
1470        _ => Ok(()),
1471    }
1472}
1473
1474#[cfg(test)]
1475mod tests {
1476    use super::*;
1477
1478    const GOLDEN_REPORT_JSON: &str = r#"{
1479  "schema": "venom-run/v1",
1480  "status": "complete",
1481  "stop_reason": {
1482    "code": "completed",
1483    "detail": "all configured steps completed"
1484  },
1485  "target": "https://example.test/",
1486  "authorized_origin": "https://example.test",
1487  "started_at": "2026-08-13T10:00:00Z",
1488  "completed_at": "2026-08-13T10:00:01Z",
1489  "accounting": {
1490    "requests": {
1491      "mode": "unmetered",
1492      "limit": null,
1493      "consumed": null,
1494      "remaining": null
1495    },
1496    "response_body_bytes": {
1497      "mode": "unmetered",
1498      "limit": null,
1499      "consumed": null,
1500      "remaining": null
1501    },
1502    "request_body_bytes": {
1503      "mode": "unmetered",
1504      "limit": null,
1505      "consumed": null,
1506      "remaining": null
1507    },
1508    "wall_time_ms": {
1509      "mode": "unmetered",
1510      "limit": null,
1511      "consumed": null,
1512      "remaining": null
1513    }
1514  },
1515  "steps": [
1516    {
1517      "ordinal": 1,
1518      "action_id": "legacy.recon",
1519      "status": "succeeded",
1520      "duration_ms": 1,
1521      "detail": null
1522    }
1523  ],
1524  "outcomes": [
1525    {
1526      "fingerprint": "sha256:bf121d89e97f83f2103ab3f97f9439e6b8485df96177ec8911b30ae38c0f9250",
1527      "subject": "endpoint:https://example.test",
1528      "action_id": "legacy.recon",
1529      "severity": "info",
1530      "disposition": "unknown",
1531      "confidence": 0,
1532      "evidence_ids": [],
1533      "rationale": "unverified legacy observation",
1534      "redacted_summary": "details redacted",
1535      "verification": null
1536    }
1537  ]
1538}"#;
1539
1540    fn time(value: &str) -> DateTime<Utc> {
1541        value.parse().unwrap()
1542    }
1543
1544    #[test]
1545    fn report_round_trip_and_golden_are_stable() {
1546        let outcome = RunOutcomeRecord::unresolved(
1547            EntityId::new("endpoint:https://example.test").unwrap(),
1548            "legacy.recon",
1549            "unverified legacy observation",
1550            "details redacted",
1551        )
1552        .unwrap();
1553        let input = RunReportInput::new(
1554            RunStatus::Complete,
1555            RunStopReason::new(RunStopCode::Completed, "all configured steps completed").unwrap(),
1556            "https://example.test/",
1557            "https://example.test",
1558            time("2026-08-13T10:00:00Z"),
1559            time("2026-08-13T10:00:01Z"),
1560        )
1561        .unwrap()
1562        .with_accounting(RunAccounting::unmetered())
1563        .with_steps(vec![RunStepReport::new(
1564            1,
1565            "legacy.recon",
1566            RunStepStatus::Succeeded,
1567            1,
1568            None,
1569        )
1570        .unwrap()])
1571        .with_outcomes(vec![outcome]);
1572        let report = RunReport::new(input).unwrap();
1573        let json = serde_json::to_string_pretty(&report).unwrap();
1574        let decoded: RunReport = serde_json::from_str(&json).unwrap();
1575        assert_eq!(decoded, report);
1576        assert_eq!(decoded.schema(), RUN_REPORT_SCHEMA);
1577        assert_eq!(json, GOLDEN_REPORT_JSON);
1578    }
1579
1580    #[test]
1581    fn malformed_wire_fails_closed() {
1582        let invalid_accounting = r#"{
1583          "mode":"unmetered","limit":1,"consumed":0,"remaining":1
1584        }"#;
1585        assert!(serde_json::from_str::<ResourceAccounting>(invalid_accounting).is_err());
1586
1587        let reason = RunStopReason::new(RunStopCode::NoEligibleAction, "done").unwrap();
1588        let input = RunReportInput::new(
1589            RunStatus::Complete,
1590            reason,
1591            "https://example.test/",
1592            "https://example.test",
1593            time("2026-08-13T10:00:00Z"),
1594            time("2026-08-13T10:00:01Z"),
1595        )
1596        .unwrap();
1597        let mut report = serde_json::to_value(RunReport::new(input).unwrap()).unwrap();
1598        report["schema"] = serde_json::json!("venom-run/v999");
1599        assert!(serde_json::from_value::<RunReport>(report).is_err());
1600    }
1601
1602    #[test]
1603    fn accounting_distinguishes_enforced_observed_and_unknown_dimensions() {
1604        let metered = ResourceAccounting::metered(10, 4);
1605        assert_eq!(metered.mode(), ResourceAccountingMode::Metered);
1606        assert_eq!(metered.limit(), Some(10));
1607        assert_eq!(metered.consumed(), Some(4));
1608        assert_eq!(metered.remaining(), Some(6));
1609
1610        let observed = ResourceAccounting::observed(7);
1611        assert_eq!(observed.mode(), ResourceAccountingMode::Observed);
1612        assert_eq!(observed.limit(), None);
1613        assert_eq!(observed.consumed(), Some(7));
1614        assert_eq!(observed.remaining(), None);
1615        assert_eq!(
1616            serde_json::from_value::<ResourceAccounting>(serde_json::to_value(&observed).unwrap())
1617                .unwrap(),
1618            observed
1619        );
1620
1621        let unmetered = ResourceAccounting::unmetered();
1622        assert_eq!(unmetered.mode(), ResourceAccountingMode::Unmetered);
1623        assert_eq!(unmetered.consumed(), None);
1624    }
1625
1626    #[test]
1627    fn report_retention_limit_is_distinct_from_runtime_budget_exhaustion() {
1628        let outcome = RunOutcomeRecord::unresolved(
1629            EntityId::new("endpoint:https://example.test").unwrap(),
1630            "legacy.recon",
1631            "unresolved",
1632            "redacted",
1633        )
1634        .unwrap();
1635        let input = RunReportInput::new(
1636            RunStatus::Partial,
1637            RunStopReason::new(RunStopCode::ReportLimitExceeded, "retention limit").unwrap(),
1638            "https://example.test/",
1639            "https://example.test",
1640            time("2026-08-13T10:00:00Z"),
1641            time("2026-08-13T10:00:01Z"),
1642        )
1643        .unwrap()
1644        .with_steps(vec![RunStepReport::new(
1645            1,
1646            "legacy.recon",
1647            RunStepStatus::Succeeded,
1648            1,
1649            None,
1650        )
1651        .unwrap()])
1652        .with_outcomes(vec![outcome]);
1653        let report = RunReport::new(input).unwrap();
1654        assert_eq!(
1655            report.stop_reason().code(),
1656            RunStopCode::ReportLimitExceeded
1657        );
1658        assert_eq!(report.status(), RunStatus::Partial);
1659    }
1660
1661    #[test]
1662    fn report_rejects_unknown_fields_and_forged_fingerprints() {
1663        let outcome = RunOutcomeRecord::unresolved(
1664            EntityId::new("endpoint:https://example.test").unwrap(),
1665            "legacy.recon",
1666            "unresolved",
1667            "redacted",
1668        )
1669        .unwrap();
1670        let mut forged = serde_json::to_value(&outcome).unwrap();
1671        forged["fingerprint"] = serde_json::json!("sha256:forged");
1672        assert!(serde_json::from_value::<RunOutcomeRecord>(forged).is_err());
1673
1674        let input = RunReportInput::new(
1675            RunStatus::Complete,
1676            RunStopReason::new(RunStopCode::NoEligibleAction, "done").unwrap(),
1677            "https://example.test/",
1678            "https://example.test",
1679            time("2026-08-13T10:00:00Z"),
1680            time("2026-08-13T10:00:01Z"),
1681        )
1682        .unwrap();
1683        let mut wire = serde_json::to_value(RunReport::new(input).unwrap()).unwrap();
1684        wire["unexpected"] = serde_json::json!(true);
1685        assert!(serde_json::from_value::<RunReport>(wire).is_err());
1686
1687        let mut nested =
1688            serde_json::to_value(RunStopReason::new(RunStopCode::Completed, "done").unwrap())
1689                .unwrap();
1690        nested["unexpected"] = serde_json::json!(true);
1691        assert!(serde_json::from_value::<RunStopReason>(nested).is_err());
1692
1693        let duplicate = RunReportInput::new(
1694            RunStatus::Complete,
1695            RunStopReason::new(RunStopCode::NoEligibleAction, "done").unwrap(),
1696            "https://example.test/",
1697            "https://example.test",
1698            time("2026-08-13T10:00:00Z"),
1699            time("2026-08-13T10:00:01Z"),
1700        )
1701        .unwrap()
1702        .with_outcomes(vec![outcome.clone(), outcome]);
1703        assert_eq!(
1704            RunReport::new(duplicate),
1705            Err(RunReportError::InvalidFingerprint)
1706        );
1707    }
1708
1709    #[test]
1710    fn report_rejects_incompatible_status_time_and_step_order() {
1711        let cancelled = RunReportInput::new(
1712            RunStatus::Complete,
1713            RunStopReason::new(RunStopCode::Cancelled, "host cancelled").unwrap(),
1714            "https://example.test/",
1715            "https://example.test",
1716            time("2026-08-13T10:00:00Z"),
1717            time("2026-08-13T10:00:01Z"),
1718        )
1719        .unwrap();
1720        assert!(matches!(
1721            RunReport::new(cancelled),
1722            Err(RunReportError::IncompatibleStatusStop { .. })
1723        ));
1724
1725        let reversed = RunReportInput::new(
1726            RunStatus::Complete,
1727            RunStopReason::new(RunStopCode::Completed, "done").unwrap(),
1728            "https://example.test/",
1729            "https://example.test",
1730            time("2026-08-13T10:00:01Z"),
1731            time("2026-08-13T10:00:00Z"),
1732        )
1733        .unwrap();
1734        assert_eq!(
1735            RunReport::new(reversed),
1736            Err(RunReportError::InvalidTimeRange)
1737        );
1738
1739        let duplicate_steps = RunReportInput::new(
1740            RunStatus::Complete,
1741            RunStopReason::new(RunStopCode::Completed, "done").unwrap(),
1742            "https://example.test/",
1743            "https://example.test",
1744            time("2026-08-13T10:00:00Z"),
1745            time("2026-08-13T10:00:01Z"),
1746        )
1747        .unwrap()
1748        .with_steps(vec![
1749            RunStepReport::new(1, "one", RunStepStatus::Succeeded, 1, None).unwrap(),
1750            RunStepReport::new(1, "two", RunStepStatus::Succeeded, 1, None).unwrap(),
1751        ]);
1752        assert_eq!(
1753            RunReport::new(duplicate_steps),
1754            Err(RunReportError::InvalidStepOrder)
1755        );
1756
1757        let complete_with_skipped = RunReportInput::new(
1758            RunStatus::Complete,
1759            RunStopReason::new(RunStopCode::Completed, "done").unwrap(),
1760            "https://example.test/",
1761            "https://example.test",
1762            time("2026-08-13T10:00:00Z"),
1763            time("2026-08-13T10:00:01Z"),
1764        )
1765        .unwrap()
1766        .with_steps(vec![RunStepReport::new(
1767            1,
1768            "skipped",
1769            RunStepStatus::Skipped,
1770            0,
1771            None,
1772        )
1773        .unwrap()]);
1774        assert!(matches!(
1775            RunReport::new(complete_with_skipped),
1776            Err(RunReportError::InconsistentSteps { .. })
1777        ));
1778
1779        let complete = RunReportInput::new(
1780            RunStatus::Complete,
1781            RunStopReason::new(RunStopCode::Completed, "done").unwrap(),
1782            "https://example.test/",
1783            "https://example.test",
1784            time("2026-08-13T10:00:00Z"),
1785            time("2026-08-13T10:00:01Z"),
1786        )
1787        .unwrap()
1788        .with_steps(vec![RunStepReport::new(
1789            1,
1790            "complete",
1791            RunStepStatus::Succeeded,
1792            1,
1793            None,
1794        )
1795        .unwrap()]);
1796        let mut complete_wire = serde_json::to_value(RunReport::new(complete).unwrap()).unwrap();
1797        complete_wire["steps"][0]["status"] = serde_json::json!("skipped");
1798        assert!(serde_json::from_value::<RunReport>(complete_wire).is_err());
1799
1800        let cancelled_with_success = RunReportInput::new(
1801            RunStatus::Cancelled,
1802            RunStopReason::new(RunStopCode::Cancelled, "host cancelled").unwrap(),
1803            "https://example.test/",
1804            "https://example.test",
1805            time("2026-08-13T10:00:00Z"),
1806            time("2026-08-13T10:00:01Z"),
1807        )
1808        .unwrap()
1809        .with_steps(vec![RunStepReport::new(
1810            1,
1811            "complete",
1812            RunStepStatus::Succeeded,
1813            1,
1814            None,
1815        )
1816        .unwrap()]);
1817        assert!(matches!(
1818            RunReport::new(cancelled_with_success),
1819            Err(RunReportError::InconsistentSteps { .. })
1820        ));
1821
1822        let cancelled = RunReportInput::new(
1823            RunStatus::Cancelled,
1824            RunStopReason::new(RunStopCode::Cancelled, "host cancelled").unwrap(),
1825            "https://example.test/",
1826            "https://example.test",
1827            time("2026-08-13T10:00:00Z"),
1828            time("2026-08-13T10:00:01Z"),
1829        )
1830        .unwrap()
1831        .with_steps(vec![RunStepReport::new(
1832            1,
1833            "skipped",
1834            RunStepStatus::Skipped,
1835            0,
1836            None,
1837        )
1838        .unwrap()]);
1839        let mut cancelled_wire = serde_json::to_value(RunReport::new(cancelled).unwrap()).unwrap();
1840        cancelled_wire["steps"][0]["status"] = serde_json::json!("succeeded");
1841        assert!(serde_json::from_value::<RunReport>(cancelled_wire).is_err());
1842    }
1843
1844    #[test]
1845    fn unknown_projection_cannot_be_escalated_on_the_wire() {
1846        let record = RunOutcomeRecord::unresolved(
1847            EntityId::new("endpoint:https://example.test").unwrap(),
1848            "legacy.detector",
1849            "legacy heuristic is unresolved",
1850            "redacted",
1851        )
1852        .unwrap();
1853        let mut severity = serde_json::to_value(&record).unwrap();
1854        severity["severity"] = serde_json::json!("critical");
1855        assert!(serde_json::from_value::<RunOutcomeRecord>(severity).is_err());
1856
1857        let mut confidence = serde_json::to_value(&record).unwrap();
1858        confidence["confidence"] = serde_json::json!(900_000);
1859        assert!(serde_json::from_value::<RunOutcomeRecord>(confidence).is_err());
1860
1861        let mut evidence = serde_json::to_value(record).unwrap();
1862        evidence["evidence_ids"] = serde_json::json!(["evidence:legacy"]);
1863        assert!(serde_json::from_value::<RunOutcomeRecord>(evidence).is_err());
1864    }
1865
1866    #[test]
1867    fn verified_projection_preserves_provenance_and_ignores_presentation_wording_in_identity() {
1868        let verified = Outcome::verified(
1869            "case:nginx",
1870            EntityId::new("endpoint:https://example.test").unwrap(),
1871            "web.action.nginx.verify",
1872            "hypothesis:nginx",
1873            "verify.nginx.version",
1874            VerificationStage::Passive,
1875            OutcomeStatus::Success,
1876            Probability::from_percent(95).unwrap(),
1877            "version token matched",
1878            BTreeSet::from([EvidenceId::parse("evidence:server-header").unwrap()]),
1879        )
1880        .unwrap();
1881        let first =
1882            RunOutcomeRecord::from_outcome(verified.clone(), "server header redacted").unwrap();
1883
1884        let mut wording = serde_json::to_value(&first).unwrap();
1885        wording["rationale"] = serde_json::json!("different explanation");
1886        wording["redacted_summary"] = serde_json::json!("different safe presentation");
1887        wording["verification"]["rationale"] = serde_json::json!("different explanation");
1888        let second: RunOutcomeRecord = serde_json::from_value(wording).unwrap();
1889        assert_eq!(first.fingerprint(), second.fingerprint());
1890        assert_eq!(second.severity(), SecuritySeverity::Info);
1891        let preserved = second.verification_outcome().unwrap();
1892        assert_eq!(preserved.case_id(), "case:nginx");
1893        assert_eq!(preserved.hypothesis_id(), "hypothesis:nginx");
1894        assert_eq!(preserved.verifier_rule_id(), Some("verify.nginx.version"));
1895        assert_eq!(preserved.stage(), VerificationStage::Passive);
1896
1897        let mut forged = serde_json::to_value(first).unwrap();
1898        forged["verification"]["verifier_rule_id"] = serde_json::json!("verify.forged");
1899        assert!(serde_json::from_value::<RunOutcomeRecord>(forged).is_err());
1900
1901        let projected = RunOutcomeRecord::from_outcome(verified, "redacted").unwrap();
1902        let mut severity = serde_json::to_value(projected).unwrap();
1903        severity["severity"] = serde_json::json!("high");
1904        assert!(serde_json::from_value::<RunOutcomeRecord>(severity).is_err());
1905    }
1906
1907    #[test]
1908    fn wire_rejects_oversized_scalar_text_before_conversion() {
1909        let oversized = "x".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1);
1910
1911        let stop_reason = serde_json::json!({
1912            "code": "completed",
1913            "detail": oversized,
1914        });
1915        assert!(serde_json::from_value::<RunStopReasonWire>(stop_reason).is_err());
1916
1917        let step = RunStepReport::new(
1918            1,
1919            "legacy.recon",
1920            RunStepStatus::Succeeded,
1921            1,
1922            Some("complete".to_string()),
1923        )
1924        .unwrap();
1925        for field in ["action_id", "detail"] {
1926            let mut wire = serde_json::to_value(&step).unwrap();
1927            wire[field] = serde_json::json!(oversized);
1928            assert!(
1929                serde_json::from_value::<RunStepReportWire>(wire).is_err(),
1930                "oversized step {field} was accepted"
1931            );
1932        }
1933        let mut step_without_detail = serde_json::to_value(&step).unwrap();
1934        step_without_detail
1935            .as_object_mut()
1936            .unwrap()
1937            .remove("detail");
1938        assert!(serde_json::from_value::<RunStepReportWire>(step_without_detail).is_ok());
1939
1940        let unresolved = RunOutcomeRecord::unresolved(
1941            EntityId::new("endpoint:https://example.test").unwrap(),
1942            "legacy.detector",
1943            "unresolved",
1944            "redacted",
1945        )
1946        .unwrap();
1947        for field in [
1948            "fingerprint",
1949            "subject",
1950            "action_id",
1951            "rationale",
1952            "redacted_summary",
1953        ] {
1954            let mut wire = serde_json::to_value(&unresolved).unwrap();
1955            wire[field] = serde_json::json!(oversized);
1956            assert!(
1957                serde_json::from_value::<RunOutcomeRecordWire>(wire).is_err(),
1958                "oversized outcome {field} was accepted"
1959            );
1960        }
1961
1962        let verified = Outcome::verified(
1963            "case:bounded-scalars",
1964            EntityId::new("endpoint:https://example.test").unwrap(),
1965            "verified.action",
1966            "hypothesis:bounded-scalars",
1967            "verify.bounded-scalars",
1968            VerificationStage::Passive,
1969            OutcomeStatus::Success,
1970            Probability::from_percent(90).unwrap(),
1971            "verified",
1972            BTreeSet::from([EvidenceId::parse("evidence:one").unwrap()]),
1973        )
1974        .unwrap();
1975        let projection = RunOutcomeRecord::from_outcome(verified, "redacted").unwrap();
1976        let projection_wire = serde_json::to_value(&projection).unwrap();
1977        for field in [
1978            "case_id",
1979            "subject",
1980            "action_id",
1981            "hypothesis_id",
1982            "verifier_rule_id",
1983            "rationale",
1984        ] {
1985            let mut wire = projection_wire["verification"].clone();
1986            wire[field] = serde_json::json!(oversized);
1987            assert!(
1988                serde_json::from_value::<VerificationOutcomeWire>(wire).is_err(),
1989                "oversized verification {field} was accepted"
1990            );
1991        }
1992        let mut verification_without_rule = projection_wire["verification"].clone();
1993        verification_without_rule
1994            .as_object_mut()
1995            .unwrap()
1996            .remove("verifier_rule_id");
1997        assert!(
1998            serde_json::from_value::<VerificationOutcomeWire>(verification_without_rule).is_ok()
1999        );
2000
2001        let input = RunReportInput::new(
2002            RunStatus::Complete,
2003            RunStopReason::new(RunStopCode::NoEligibleAction, "no eligible action").unwrap(),
2004            "https://example.test/",
2005            "https://example.test",
2006            time("2026-08-13T10:00:00Z"),
2007            time("2026-08-13T10:00:01Z"),
2008        )
2009        .unwrap();
2010        let report = RunReport::new(input).unwrap();
2011        for field in ["schema", "target", "authorized_origin"] {
2012            let mut wire = serde_json::to_value(&report).unwrap();
2013            wire[field] = serde_json::json!(oversized);
2014            assert!(
2015                serde_json::from_value::<RunReportWire>(wire.clone()).is_err(),
2016                "oversized report {field} was accepted by the private wire"
2017            );
2018            assert!(
2019                serde_json::from_value::<RunReport>(wire).is_err(),
2020                "oversized report {field} was accepted by the public contract"
2021            );
2022        }
2023
2024        let escaped = "\\u0078".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1);
2025        let escaped_reason = format!(r#"{{"code":"completed","detail":"{escaped}"}}"#);
2026        assert!(serde_json::from_str::<RunStopReasonWire>(&escaped_reason).is_err());
2027    }
2028
2029    #[test]
2030    fn bounded_fields_and_collections_fail_closed() {
2031        let oversized = "x".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1);
2032        assert!(RunOutcomeRecord::unresolved(
2033            EntityId::new("endpoint:https://example.test").unwrap(),
2034            "legacy.detector",
2035            oversized,
2036            "redacted",
2037        )
2038        .is_err());
2039
2040        let evidence_ids = (0..=MAX_RUN_REPORT_EVIDENCE_IDS)
2041            .map(|index| EvidenceId::parse(format!("evidence:{index}")))
2042            .collect::<Result<BTreeSet<_>, _>>()
2043            .unwrap();
2044        let outcome = Outcome::verified(
2045            "case:bounded",
2046            EntityId::new("endpoint:https://example.test").unwrap(),
2047            "verified.action",
2048            "hypothesis:bounded",
2049            "verify.bounded",
2050            VerificationStage::Passive,
2051            OutcomeStatus::Success,
2052            Probability::from_percent(90).unwrap(),
2053            "verified",
2054            evidence_ids,
2055        )
2056        .unwrap();
2057        assert!(matches!(
2058            RunOutcomeRecord::from_outcome(outcome, "redacted"),
2059            Err(RunReportError::TooMany { .. })
2060        ));
2061    }
2062
2063    #[test]
2064    fn wire_counts_duplicate_and_unique_evidence_entries_before_insertion() {
2065        fn encoded_ids(unique: bool) -> serde_json::Value {
2066            serde_json::Value::Array(
2067                (0..=MAX_RUN_REPORT_EVIDENCE_IDS)
2068                    .map(|index| {
2069                        serde_json::Value::String(if unique {
2070                            format!("evidence:{index}")
2071                        } else {
2072                            "evidence:duplicate".to_string()
2073                        })
2074                    })
2075                    .collect(),
2076            )
2077        }
2078
2079        let unresolved = RunOutcomeRecord::unresolved(
2080            EntityId::new("endpoint:https://example.test").unwrap(),
2081            "legacy.detector",
2082            "unresolved",
2083            "redacted",
2084        )
2085        .unwrap();
2086        for unique in [true, false] {
2087            let mut outer = serde_json::to_value(&unresolved).unwrap();
2088            outer["evidence_ids"] = encoded_ids(unique);
2089            assert!(serde_json::from_value::<RunOutcomeRecordWire>(outer.clone()).is_err());
2090            assert!(serde_json::from_value::<RunOutcomeRecord>(outer).is_err());
2091        }
2092
2093        let verified = Outcome::verified(
2094            "case:evidence-bound",
2095            EntityId::new("endpoint:https://example.test").unwrap(),
2096            "verified.action",
2097            "hypothesis:evidence-bound",
2098            "verify.evidence-bound",
2099            VerificationStage::Passive,
2100            OutcomeStatus::Success,
2101            Probability::from_percent(90).unwrap(),
2102            "verified",
2103            BTreeSet::from([EvidenceId::parse("evidence:one").unwrap()]),
2104        )
2105        .unwrap();
2106        let projection = RunOutcomeRecord::from_outcome(verified, "redacted").unwrap();
2107        for unique in [true, false] {
2108            let mut embedded = serde_json::to_value(&projection).unwrap();
2109            embedded["verification"]["evidence_ids"] = encoded_ids(unique);
2110            assert!(serde_json::from_value::<VerificationOutcomeWire>(
2111                embedded["verification"].clone()
2112            )
2113            .is_err());
2114            assert!(serde_json::from_value::<RunOutcomeRecord>(embedded).is_err());
2115        }
2116    }
2117
2118    #[test]
2119    fn wire_rejects_oversized_evidence_ids_during_outer_and_embedded_decode() {
2120        let oversized = "x".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1);
2121        let unresolved = RunOutcomeRecord::unresolved(
2122            EntityId::new("endpoint:https://example.test").unwrap(),
2123            "legacy.detector",
2124            "unresolved",
2125            "redacted",
2126        )
2127        .unwrap();
2128        let mut outer = serde_json::to_value(unresolved).unwrap();
2129        outer["evidence_ids"] = serde_json::json!([oversized]);
2130        assert!(serde_json::from_value::<RunOutcomeRecordWire>(outer).is_err());
2131
2132        let verified = Outcome::verified(
2133            "case:evidence-id-bound",
2134            EntityId::new("endpoint:https://example.test").unwrap(),
2135            "verified.action",
2136            "hypothesis:evidence-id-bound",
2137            "verify.evidence-id-bound",
2138            VerificationStage::Passive,
2139            OutcomeStatus::Success,
2140            Probability::from_percent(90).unwrap(),
2141            "verified",
2142            BTreeSet::from([EvidenceId::parse("evidence:one").unwrap()]),
2143        )
2144        .unwrap();
2145        let projection = RunOutcomeRecord::from_outcome(verified, "redacted").unwrap();
2146        let mut embedded = serde_json::to_value(projection).unwrap();
2147        embedded["verification"]["evidence_ids"] =
2148            serde_json::json!(["x".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1)]);
2149        assert!(serde_json::from_value::<VerificationOutcomeWire>(
2150            embedded["verification"].clone()
2151        )
2152        .is_err());
2153    }
2154
2155    #[test]
2156    fn validation_helpers_reject_every_bounded_projection_dimension() {
2157        assert_eq!(
2158            RunStopReason::new(RunStopCode::Completed, " "),
2159            Err(RunReportError::Blank {
2160                field: "stop reason"
2161            })
2162        );
2163        let reason = RunStopReason::new(RunStopCode::Completed, "done").unwrap();
2164        assert_eq!(reason.detail(), "done");
2165
2166        let metered: ResourceAccounting = serde_json::from_value(serde_json::json!({
2167            "mode": "metered",
2168            "limit": 10,
2169            "consumed": 4,
2170            "remaining": 6
2171        }))
2172        .unwrap();
2173        assert_eq!(metered, ResourceAccounting::metered(10, 4));
2174
2175        let step: RunStepReport = serde_json::from_value(serde_json::json!({
2176            "ordinal": 7,
2177            "action_id": "wire.step",
2178            "status": "succeeded",
2179            "duration_ms": 3,
2180            "detail": null
2181        }))
2182        .unwrap();
2183        assert_eq!(step.ordinal(), 7);
2184        assert_eq!(step.action_id(), "wire.step");
2185        assert_eq!(step.duration_ms(), 3);
2186        assert_eq!(step.detail(), None);
2187
2188        let subject = EntityId::new("endpoint:https://example.test").unwrap();
2189        let canonical_unknown = VerificationOutcomeWire {
2190            case_id: "case:unknown".to_string(),
2191            subject: subject.clone(),
2192            action_id: "verify.unknown".to_string(),
2193            hypothesis_id: "hypothesis:unknown".to_string(),
2194            verifier_rule_id: None,
2195            stage: VerificationStage::Passive,
2196            status: OutcomeStatus::Unknown,
2197            confidence: Probability::ZERO,
2198            rationale: "no eligible verifier".to_string(),
2199            evidence_ids: BTreeSet::new(),
2200        }
2201        .into_outcome()
2202        .unwrap();
2203        assert_eq!(canonical_unknown.status(), OutcomeStatus::Unknown);
2204
2205        let noncanonical_unknown = VerificationOutcomeWire {
2206            case_id: "case:unknown".to_string(),
2207            subject: subject.clone(),
2208            action_id: "verify.unknown".to_string(),
2209            hypothesis_id: "hypothesis:unknown".to_string(),
2210            verifier_rule_id: Some("verify.forged".to_string()),
2211            stage: VerificationStage::Passive,
2212            status: OutcomeStatus::Unknown,
2213            confidence: Probability::ZERO,
2214            rationale: "forged".to_string(),
2215            evidence_ids: BTreeSet::new(),
2216        };
2217        assert!(matches!(
2218            noncanonical_unknown.into_outcome(),
2219            Err(RunReportError::InvalidOutcome {
2220                status: OutcomeStatus::Unknown,
2221                ..
2222            })
2223        ));
2224
2225        let malformed_unknown = VerificationOutcomeWire {
2226            case_id: " ".to_string(),
2227            subject: subject.clone(),
2228            action_id: "verify.unknown".to_string(),
2229            hypothesis_id: "hypothesis:unknown".to_string(),
2230            verifier_rule_id: None,
2231            stage: VerificationStage::Passive,
2232            status: OutcomeStatus::Unknown,
2233            confidence: Probability::ZERO,
2234            rationale: "invalid identity".to_string(),
2235            evidence_ids: BTreeSet::new(),
2236        };
2237        assert!(matches!(
2238            malformed_unknown.into_outcome(),
2239            Err(RunReportError::InvalidOutcome {
2240                status: OutcomeStatus::Unknown,
2241                ..
2242            })
2243        ));
2244
2245        let missing_rule = VerificationOutcomeWire {
2246            case_id: "case:blocked".to_string(),
2247            subject: subject.clone(),
2248            action_id: "verify.blocked".to_string(),
2249            hypothesis_id: "hypothesis:blocked".to_string(),
2250            verifier_rule_id: None,
2251            stage: VerificationStage::Passive,
2252            status: OutcomeStatus::Blocked,
2253            confidence: Probability::from_percent(50).unwrap(),
2254            rationale: "blocked".to_string(),
2255            evidence_ids: BTreeSet::from([EvidenceId::parse("evidence:blocked").unwrap()]),
2256        };
2257        assert!(matches!(
2258            missing_rule.into_outcome(),
2259            Err(RunReportError::InvalidOutcome {
2260                status: OutcomeStatus::Blocked,
2261                ..
2262            })
2263        ));
2264
2265        let base_record = || {
2266            RunOutcomeRecord::unresolved(
2267                subject.clone(),
2268                "legacy.detector",
2269                "unresolved",
2270                "redacted",
2271            )
2272            .unwrap()
2273        };
2274
2275        let mut invalid = base_record();
2276        invalid.action_id = "a".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1);
2277        assert!(matches!(
2278            validate_record_bounds(&invalid),
2279            Err(RunReportError::TextTooLong {
2280                field: "outcome action id",
2281                ..
2282            })
2283        ));
2284
2285        let mut invalid = base_record();
2286        invalid.evidence_ids =
2287            BTreeSet::from([EvidenceId::parse("e".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1)).unwrap()]);
2288        assert!(matches!(
2289            validate_record_bounds(&invalid),
2290            Err(RunReportError::TextTooLong {
2291                field: "outcome evidence id",
2292                ..
2293            })
2294        ));
2295
2296        let mut invalid = base_record();
2297        invalid.subject = EntityId::new("s".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1)).unwrap();
2298        assert!(matches!(
2299            validate_record_bounds(&invalid),
2300            Err(RunReportError::TextTooLong {
2301                field: "outcome subject",
2302                ..
2303            })
2304        ));
2305
2306        for (field, case_id, hypothesis_id) in [
2307            (
2308                "verification case id",
2309                "c".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1),
2310                "hypothesis:bounded".to_string(),
2311            ),
2312            (
2313                "verification hypothesis id",
2314                "case:bounded".to_string(),
2315                "h".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1),
2316            ),
2317        ] {
2318            let mut invalid = base_record();
2319            invalid.verification = Some(
2320                Outcome::unknown(
2321                    case_id,
2322                    subject.clone(),
2323                    "verify.unknown",
2324                    hypothesis_id,
2325                    VerificationStage::Passive,
2326                    "bounded",
2327                )
2328                .unwrap(),
2329            );
2330            assert!(matches!(
2331                validate_record_bounds(&invalid),
2332                Err(RunReportError::TextTooLong {
2333                    field: actual,
2334                    ..
2335                }) if actual == field
2336            ));
2337        }
2338
2339        let mut invalid = base_record();
2340        invalid.verification = Some(
2341            Outcome::verified(
2342                "case:bounded",
2343                subject,
2344                "verify.bounded",
2345                "hypothesis:bounded",
2346                "r".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1),
2347                VerificationStage::Passive,
2348                OutcomeStatus::Success,
2349                Probability::from_percent(90).unwrap(),
2350                "bounded",
2351                BTreeSet::from([EvidenceId::parse("evidence:bounded").unwrap()]),
2352            )
2353            .unwrap(),
2354        );
2355        assert!(matches!(
2356            validate_record_bounds(&invalid),
2357            Err(RunReportError::TextTooLong {
2358                field: "verifier rule id",
2359                ..
2360            })
2361        ));
2362
2363        for (status, token) in [
2364            (OutcomeStatus::Success, "success"),
2365            (OutcomeStatus::Blocked, "blocked"),
2366            (OutcomeStatus::Unknown, "unknown"),
2367            (OutcomeStatus::FalsePositive, "false_positive"),
2368            (OutcomeStatus::NeedsReview, "needs_review"),
2369            (OutcomeStatus::ConfirmedNegative, "confirmed_negative"),
2370        ] {
2371            assert_eq!(outcome_code(status), token);
2372        }
2373    }
2374
2375    #[test]
2376    fn report_limits_and_aggregate_failure_proofs_are_exercised() {
2377        let start = time("2026-08-13T10:00:00Z");
2378        let end = time("2026-08-13T10:00:01Z");
2379        let completed = || RunStopReason::new(RunStopCode::Completed, "done").unwrap();
2380        let step =
2381            RunStepReport::new(1, "bounded.step", RunStepStatus::Succeeded, 1, None).unwrap();
2382        let too_many_steps = RunReportInput::new(
2383            RunStatus::Complete,
2384            completed(),
2385            "https://example.test/",
2386            "https://example.test",
2387            start,
2388            end,
2389        )
2390        .unwrap()
2391        .with_steps(vec![step.clone(); MAX_RUN_REPORT_STEPS + 1]);
2392        assert!(matches!(
2393            RunReport::new(too_many_steps),
2394            Err(RunReportError::TooMany {
2395                field: "run steps",
2396                ..
2397            })
2398        ));
2399
2400        let outcome = RunOutcomeRecord::unresolved(
2401            EntityId::new("endpoint:https://example.test").unwrap(),
2402            "legacy.detector",
2403            "unresolved",
2404            "redacted",
2405        )
2406        .unwrap();
2407        let too_many_outcomes = RunReportInput::new(
2408            RunStatus::Complete,
2409            RunStopReason::new(RunStopCode::NoEligibleAction, "done").unwrap(),
2410            "https://example.test/",
2411            "https://example.test",
2412            start,
2413            end,
2414        )
2415        .unwrap()
2416        .with_outcomes(vec![outcome.clone(); MAX_RUN_REPORT_OUTCOMES + 1]);
2417        assert!(matches!(
2418            RunReport::new(too_many_outcomes),
2419            Err(RunReportError::TooMany {
2420                field: "run outcomes",
2421                ..
2422            })
2423        ));
2424
2425        for code in [
2426            RunStopCode::NoEligibleAction,
2427            RunStopCode::BudgetExhausted,
2428            RunStopCode::ReportLimitExceeded,
2429            RunStopCode::StepFailed,
2430            RunStopCode::StepTimedOut,
2431            RunStopCode::TaskJoinFailed,
2432            RunStopCode::RuntimeFailed,
2433        ] {
2434            assert!(status_matches_stop(RunStatus::Partial, code));
2435            assert_eq!(
2436                status_matches_stop(RunStatus::Failed, code),
2437                code != RunStopCode::NoEligibleAction && code != RunStopCode::ReportLimitExceeded
2438            );
2439        }
2440
2441        let failed = RunStepReport::new(1, "failed", RunStepStatus::Failed, 1, None).unwrap();
2442        let timed_out = RunStepReport::new(1, "timed", RunStepStatus::TimedOut, 1, None).unwrap();
2443        let cancelled =
2444            RunStepReport::new(1, "cancelled", RunStepStatus::Cancelled, 1, None).unwrap();
2445        let budget =
2446            RunStepReport::new(1, "budget", RunStepStatus::BudgetExhausted, 1, None).unwrap();
2447
2448        assert!(validate_aggregate_consistency(
2449            RunStatus::Complete,
2450            RunStopCode::Completed,
2451            &[],
2452            &[]
2453        )
2454        .is_err());
2455        assert!(validate_aggregate_consistency(
2456            RunStatus::Partial,
2457            RunStopCode::RuntimeFailed,
2458            &[],
2459            &[]
2460        )
2461        .is_err());
2462        assert!(validate_aggregate_consistency(
2463            RunStatus::Partial,
2464            RunStopCode::RuntimeFailed,
2465            std::slice::from_ref(&cancelled),
2466            std::slice::from_ref(&outcome)
2467        )
2468        .is_err());
2469        assert!(validate_aggregate_consistency(
2470            RunStatus::Failed,
2471            RunStopCode::RuntimeFailed,
2472            std::slice::from_ref(&step),
2473            &[]
2474        )
2475        .is_err());
2476        assert!(validate_aggregate_consistency(
2477            RunStatus::Failed,
2478            RunStopCode::StepFailed,
2479            &[],
2480            &[]
2481        )
2482        .is_err());
2483        assert!(validate_aggregate_consistency(
2484            RunStatus::Failed,
2485            RunStopCode::StepTimedOut,
2486            std::slice::from_ref(&failed),
2487            &[]
2488        )
2489        .is_err());
2490        assert!(validate_aggregate_consistency(
2491            RunStatus::Failed,
2492            RunStopCode::TaskJoinFailed,
2493            std::slice::from_ref(&timed_out),
2494            &[]
2495        )
2496        .is_err());
2497        assert!(validate_aggregate_consistency(
2498            RunStatus::Partial,
2499            RunStopCode::BudgetExhausted,
2500            &[],
2501            std::slice::from_ref(&outcome)
2502        )
2503        .is_err());
2504        assert!(validate_aggregate_consistency(
2505            RunStatus::Partial,
2506            RunStopCode::ReportLimitExceeded,
2507            std::slice::from_ref(&step),
2508            &[]
2509        )
2510        .is_err());
2511
2512        assert!(validate_aggregate_consistency(
2513            RunStatus::Failed,
2514            RunStopCode::StepFailed,
2515            std::slice::from_ref(&failed),
2516            &[]
2517        )
2518        .is_ok());
2519        assert!(validate_aggregate_consistency(
2520            RunStatus::Failed,
2521            RunStopCode::StepTimedOut,
2522            std::slice::from_ref(&timed_out),
2523            &[]
2524        )
2525        .is_ok());
2526        assert!(validate_aggregate_consistency(
2527            RunStatus::Partial,
2528            RunStopCode::BudgetExhausted,
2529            std::slice::from_ref(&budget),
2530            &[]
2531        )
2532        .is_err());
2533    }
2534}