1use 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
23pub const RUN_REPORT_SCHEMA: &str = "venom-run/v1";
25pub const MAX_RUN_REPORT_TEXT_BYTES: usize = 1_024;
27pub const MAX_RUN_REPORT_STEPS: usize = 4_096;
29pub const MAX_RUN_REPORT_OUTCOMES: usize = 4_096;
31pub const MAX_RUN_REPORT_EVIDENCE_IDS: usize = 256;
33
34#[derive(Debug, Clone, PartialEq, Eq, Error)]
36#[non_exhaustive]
37pub enum RunReportError {
38 #[error("{field} must not be blank")]
40 Blank { field: &'static str },
41 #[error("{field} exceeds {limit} bytes")]
43 TextTooLong { field: &'static str, limit: usize },
44 #[error("{field} contains {actual} records; limit is {limit}")]
46 TooMany {
47 field: &'static str,
48 actual: usize,
49 limit: usize,
50 },
51 #[error("completed_at must not precede started_at")]
53 InvalidTimeRange,
54 #[error("invalid resource accounting: {reason}")]
56 InvalidAccounting { reason: &'static str },
57 #[error("step ordinals must be strictly increasing")]
59 InvalidStepOrder,
60 #[error("registered step identities must be unique")]
62 DuplicateStepIdentity,
63 #[error("run authority no longer matches its captured execution scope")]
66 RunAuthorityMismatch,
67 #[error("invalid or duplicate outcome fingerprint")]
69 InvalidFingerprint,
70 #[error("invalid {status:?} outcome projection: {reason}")]
72 InvalidOutcome {
73 status: OutcomeStatus,
75 reason: &'static str,
77 },
78 #[error("unsupported run report schema: {0}")]
80 UnsupportedSchema(String),
81 #[error("run status {status:?} is incompatible with stop code {code:?}")]
83 IncompatibleStatusStop {
84 status: RunStatus,
86 code: RunStopCode,
88 },
89 #[error("run status {status:?} with stop code {code:?} contradicts its steps: {reason}")]
91 InconsistentSteps {
92 status: RunStatus,
94 code: RunStopCode,
96 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264#[non_exhaustive]
265pub enum RunStatus {
266 Complete,
268 Partial,
270 Cancelled,
272 Failed,
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279#[non_exhaustive]
280pub enum RunStopCode {
281 Completed,
283 NoEligibleAction,
285 BudgetExhausted,
287 ReportLimitExceeded,
289 Cancelled,
291 StepFailed,
293 StepTimedOut,
295 TaskJoinFailed,
297 RuntimeFailed,
299}
300
301#[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 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 pub const fn code(&self) -> RunStopCode {
328 self.code
329 }
330
331 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(rename_all = "snake_case")]
348#[non_exhaustive]
349pub enum RunStepStatus {
350 Succeeded,
352 Failed,
354 TimedOut,
356 Cancelled,
358 Skipped,
360 BudgetExhausted,
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
366#[serde(rename_all = "snake_case")]
367#[non_exhaustive]
368pub enum SecuritySeverity {
369 Info,
371 Low,
373 Medium,
375 High,
377 Critical,
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "snake_case")]
384#[non_exhaustive]
385pub enum ResourceAccountingMode {
386 Metered,
388 Observed,
390 Unmetered,
392}
393
394#[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 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 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 pub const fn unmetered() -> Self {
438 Self {
439 mode: ResourceAccountingMode::Unmetered,
440 limit: None,
441 consumed: None,
442 remaining: None,
443 }
444 }
445
446 pub const fn mode(&self) -> ResourceAccountingMode {
448 self.mode
449 }
450
451 pub const fn limit(&self) -> Option<u64> {
453 self.limit
454 }
455
456 pub const fn consumed(&self) -> Option<u64> {
458 self.consumed
459 }
460
461 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#[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 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 pub const fn unmetered() -> Self {
516 Self::new(
517 ResourceAccounting::unmetered(),
518 ResourceAccounting::unmetered(),
519 ResourceAccounting::unmetered(),
520 ResourceAccounting::unmetered(),
521 )
522 }
523
524 pub const fn requests(&self) -> &ResourceAccounting {
526 &self.requests
527 }
528
529 pub const fn response_body_bytes(&self) -> &ResourceAccounting {
531 &self.response_body_bytes
532 }
533
534 pub const fn request_body_bytes(&self) -> &ResourceAccounting {
536 &self.request_body_bytes
537 }
538
539 pub const fn wall_time_ms(&self) -> &ResourceAccounting {
541 &self.wall_time_ms
542 }
543}
544
545#[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 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 pub const fn ordinal(&self) -> u32 {
590 self.ordinal
591 }
592
593 pub fn action_id(&self) -> &str {
595 &self.action_id
596 }
597
598 pub const fn status(&self) -> RunStepStatus {
600 self.status
601 }
602
603 pub const fn duration_ms(&self) -> u64 {
605 self.duration_ms
606 }
607
608 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#[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 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 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 pub const fn verification_outcome(&self) -> Option<&Outcome> {
901 self.verification.as_ref()
902 }
903
904 pub fn fingerprint(&self) -> &str {
906 &self.fingerprint
907 }
908
909 pub const fn subject(&self) -> &EntityId {
911 &self.subject
912 }
913
914 pub fn action_id(&self) -> &str {
916 &self.action_id
917 }
918
919 pub const fn severity(&self) -> SecuritySeverity {
921 self.severity
922 }
923
924 pub const fn disposition(&self) -> OutcomeStatus {
926 self.disposition
927 }
928
929 pub const fn confidence(&self) -> Probability {
931 self.confidence
932 }
933
934 pub const fn evidence_ids(&self) -> &BTreeSet<EvidenceId> {
936 &self.evidence_ids
937 }
938
939 pub fn rationale(&self) -> &str {
941 &self.rationale
942 }
943
944 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#[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#[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 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 pub fn with_accounting(mut self, accounting: RunAccounting) -> Self {
1225 self.accounting = accounting;
1226 self
1227 }
1228
1229 pub fn with_steps(mut self, steps: Vec<RunStepReport>) -> Self {
1231 self.steps = steps;
1232 self
1233 }
1234
1235 pub fn with_outcomes(mut self, outcomes: Vec<RunOutcomeRecord>) -> Self {
1237 self.outcomes = outcomes;
1238 self
1239 }
1240}
1241
1242impl RunReport {
1243 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 pub fn schema(&self) -> &str {
1305 &self.schema
1306 }
1307
1308 pub const fn status(&self) -> RunStatus {
1310 self.status
1311 }
1312
1313 pub const fn stop_reason(&self) -> &RunStopReason {
1315 &self.stop_reason
1316 }
1317
1318 pub fn target(&self) -> &str {
1320 &self.target
1321 }
1322
1323 pub fn authorized_origin(&self) -> &str {
1325 &self.authorized_origin
1326 }
1327
1328 pub const fn started_at(&self) -> DateTime<Utc> {
1330 self.started_at
1331 }
1332
1333 pub const fn completed_at(&self) -> DateTime<Utc> {
1335 self.completed_at
1336 }
1337
1338 pub const fn accounting(&self) -> &RunAccounting {
1340 &self.accounting
1341 }
1342
1343 pub fn steps(&self) -> &[RunStepReport] {
1345 &self.steps
1346 }
1347
1348 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}