1use std::collections::{BTreeMap, BTreeSet};
7
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11use crate::{
12 content::ContentRef,
13 domain::{
14 AgentError, AgentErrorKind, AttemptId, EntityRef, LineageRef, OutputSchemaId, PolicyRef,
15 PrivacyClass, RepairAttemptId, RetryClassification, RunId, ValidatedOutputId,
16 ValidationAttemptId,
17 },
18 output::{ContentHash, SchemaVersion},
19 typed_output_ports::TypedOutputDeserializer,
20};
21
22pub const VALIDATED_OUTPUT_RECORD_SCHEMA_VERSION: u16 = 1;
25pub const VALIDATION_REPORT_RECORD_SCHEMA_VERSION: u16 = 1;
28pub const TYPED_RESULT_PUBLICATION_RECORD_SCHEMA_VERSION: u16 = 1;
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33pub struct ValidatedOutput {
36 pub record_schema_version: u16,
39 pub output_id: ValidatedOutputId,
41 pub schema_id: OutputSchemaId,
43 pub schema_version: SchemaVersion,
45 pub schema_fingerprint: ContentHash,
48 pub canonical_value_ref: ContentRef,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub validation_report_refs: Vec<ValidationReportRef>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub validation_attempts: Vec<ValidationAttemptId>,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub repair_attempts: Vec<RepairAttemptId>,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub source_attempt_ids: Vec<AttemptId>,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub content_refs: Vec<ContentRef>,
72 pub lineage: OutputLineage,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub policy_refs: Vec<PolicyRef>,
79 pub privacy: PrivacyClass,
82 pub redacted_summary: String,
84}
85
86impl ValidatedOutput {
87 pub fn from_validation_report(
91 params: ValidatedOutputParams,
92 report: &ValidationReportRecord,
93 ) -> Result<Self, TypedOutputError> {
94 if !report.status.is_success() {
95 return Err(TypedOutputError::ValidationReportFailed {
96 validation_attempt_id: report.validation_attempt_id.clone(),
97 });
98 }
99 if report.schema_id != params.schema_id || report.schema_version != params.schema_version {
100 return Err(TypedOutputError::SchemaMismatch {
101 expected_schema_id: params.schema_id,
102 actual_schema_id: report.schema_id.clone(),
103 });
104 }
105
106 let mut content_refs = params.content_refs;
107 push_unique_content_ref(&mut content_refs, params.canonical_value_ref.clone());
108 push_unique_content_ref(&mut content_refs, report.candidate_content_ref.clone());
109 push_unique_content_ref(&mut content_refs, report.validation_report_ref.clone());
110
111 let mut policy_refs = params.policy_refs;
112 for policy_ref in &report.policy_refs {
113 push_unique_policy_ref(&mut policy_refs, policy_ref.clone());
114 }
115
116 let mut source_attempt_ids = params.source_attempt_ids;
117 if !source_attempt_ids.contains(&report.source_attempt_id) {
118 source_attempt_ids.push(report.source_attempt_id.clone());
119 }
120
121 let output = Self {
122 record_schema_version: VALIDATED_OUTPUT_RECORD_SCHEMA_VERSION,
123 output_id: params.output_id,
124 schema_id: params.schema_id,
125 schema_version: params.schema_version,
126 schema_fingerprint: params.schema_fingerprint,
127 canonical_value_ref: params.canonical_value_ref,
128 validation_report_refs: vec![report.to_ref()],
129 validation_attempts: vec![report.validation_attempt_id.clone()],
130 repair_attempts: params.repair_attempts,
131 source_attempt_ids,
132 content_refs,
133 lineage: params.lineage,
134 policy_refs,
135 privacy: params.privacy,
136 redacted_summary: params.redacted_summary,
137 };
138 output.validate_shape()?;
139 Ok(output)
140 }
141
142 pub fn validate_shape(&self) -> Result<(), TypedOutputError> {
146 if self.record_schema_version != VALIDATED_OUTPUT_RECORD_SCHEMA_VERSION {
147 return Err(TypedOutputError::SchemaVersionUnsupported {
148 record_schema_version: self.record_schema_version,
149 });
150 }
151 if !is_sha256_fingerprint(self.schema_fingerprint.as_str()) {
152 return Err(TypedOutputError::InvalidSchemaFingerprint);
153 }
154 if self.validation_report_refs.is_empty() {
155 return Err(TypedOutputError::MissingValidationReport {
156 output_id: self.output_id.clone(),
157 });
158 }
159 if self.source_attempt_ids.is_empty() {
160 return Err(TypedOutputError::MissingSourceAttempt {
161 output_id: self.output_id.clone(),
162 });
163 }
164 if self.redacted_summary.is_empty() {
165 return Err(TypedOutputError::MissingRedactedSummary {
166 output_id: self.output_id.clone(),
167 });
168 }
169 if !content_refs_include(&self.content_refs, &self.canonical_value_ref) {
170 return Err(TypedOutputError::MissingCanonicalContentRef {
171 output_id: self.output_id.clone(),
172 });
173 }
174 for report_ref in &self.validation_report_refs {
175 if !report_ref.status.is_success() {
176 return Err(TypedOutputError::ValidationReportFailed {
177 validation_attempt_id: report_ref.validation_attempt_id.clone(),
178 });
179 }
180 if !content_refs_include(&self.content_refs, &report_ref.report_ref) {
181 return Err(TypedOutputError::MissingValidationReport {
182 output_id: self.output_id.clone(),
183 });
184 }
185 }
186 Ok(())
187 }
188
189 pub fn validation_report_keys(&self) -> Vec<String> {
193 self.validation_report_refs
194 .iter()
195 .map(|report_ref| content_ref_key(&report_ref.report_ref))
196 .collect()
197 }
198}
199
200#[derive(Clone, Debug, Eq, PartialEq)]
201pub struct ValidatedOutputParams {
204 pub output_id: ValidatedOutputId,
206 pub schema_id: OutputSchemaId,
208 pub schema_version: SchemaVersion,
210 pub schema_fingerprint: ContentHash,
213 pub canonical_value_ref: ContentRef,
216 pub repair_attempts: Vec<RepairAttemptId>,
219 pub source_attempt_ids: Vec<AttemptId>,
222 pub content_refs: Vec<ContentRef>,
225 pub lineage: OutputLineage,
228 pub policy_refs: Vec<PolicyRef>,
231 pub privacy: PrivacyClass,
234 pub redacted_summary: String,
236}
237
238#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
239pub struct OutputLineage {
242 pub lineage_ref: LineageRef,
245 pub produced_by: EntityRef,
248 #[serde(default, skip_serializing_if = "Vec::is_empty")]
249 pub derived_from: Vec<EntityRef>,
252}
253
254#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
255pub struct ValidationReportRef {
258 pub validation_attempt_id: ValidationAttemptId,
261 pub report_ref: ContentRef,
264 pub status: ValidationStatus,
266 pub redacted_summary: String,
268}
269
270#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
271pub struct ValidationReportRecord {
274 pub record_schema_version: u16,
277 pub validation_attempt_id: ValidationAttemptId,
280 pub schema_id: OutputSchemaId,
282 pub schema_version: SchemaVersion,
284 pub source_attempt_id: AttemptId,
286 pub candidate_content_ref: ContentRef,
288 pub validation_report_ref: ContentRef,
291 pub status: ValidationStatus,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub redacted_error_summary: Option<String>,
297 #[serde(default, skip_serializing_if = "Vec::is_empty")]
298 pub policy_refs: Vec<PolicyRef>,
301 pub privacy: PrivacyClass,
304 pub redacted_summary: String,
306}
307
308impl ValidationReportRecord {
309 pub fn passed(
313 validation_attempt_id: ValidationAttemptId,
314 schema_id: OutputSchemaId,
315 schema_version: SchemaVersion,
316 source_attempt_id: AttemptId,
317 candidate_content_ref: ContentRef,
318 validation_report_ref: ContentRef,
319 redacted_summary: impl Into<String>,
320 ) -> Self {
321 Self {
322 record_schema_version: VALIDATION_REPORT_RECORD_SCHEMA_VERSION,
323 validation_attempt_id,
324 schema_id,
325 schema_version,
326 source_attempt_id,
327 candidate_content_ref,
328 validation_report_ref,
329 status: ValidationStatus::Passed,
330 redacted_error_summary: None,
331 policy_refs: Vec::new(),
332 privacy: PrivacyClass::ContentRefsOnly,
333 redacted_summary: redacted_summary.into(),
334 }
335 }
336
337 pub fn failed(
341 validation_attempt_id: ValidationAttemptId,
342 schema_id: OutputSchemaId,
343 schema_version: SchemaVersion,
344 source_attempt_id: AttemptId,
345 candidate_content_ref: ContentRef,
346 validation_report_ref: ContentRef,
347 redacted_error_summary: impl Into<String>,
348 ) -> Self {
349 let redacted_error_summary = redacted_error_summary.into();
350 Self {
351 record_schema_version: VALIDATION_REPORT_RECORD_SCHEMA_VERSION,
352 validation_attempt_id,
353 schema_id,
354 schema_version,
355 source_attempt_id,
356 candidate_content_ref,
357 validation_report_ref,
358 status: ValidationStatus::Failed,
359 redacted_error_summary: Some(redacted_error_summary.clone()),
360 policy_refs: Vec::new(),
361 privacy: PrivacyClass::ContentRefsOnly,
362 redacted_summary: redacted_error_summary,
363 }
364 }
365
366 pub fn to_ref(&self) -> ValidationReportRef {
370 ValidationReportRef {
371 validation_attempt_id: self.validation_attempt_id.clone(),
372 report_ref: self.validation_report_ref.clone(),
373 status: self.status,
374 redacted_summary: self.redacted_summary.clone(),
375 }
376 }
377}
378
379#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
380#[serde(rename_all = "snake_case")]
381pub enum ValidationStatus {
384 Passed,
386 Failed,
388}
389
390impl ValidationStatus {
391 pub fn is_success(self) -> bool {
394 matches!(self, Self::Passed)
395 }
396}
397
398#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
399pub struct TypedResultPublicationRecord {
402 pub record_schema_version: u16,
405 pub validated_output_id: ValidatedOutputId,
407 pub schema_id: OutputSchemaId,
409 pub schema_version: SchemaVersion,
411 pub canonical_value_ref: ContentRef,
414 #[serde(default, skip_serializing_if = "Vec::is_empty")]
415 pub validation_report_refs: Vec<ValidationReportRef>,
418 #[serde(default, skip_serializing_if = "Vec::is_empty")]
419 pub source_attempt_ids: Vec<AttemptId>,
422 #[serde(default, skip_serializing_if = "Vec::is_empty")]
423 pub policy_refs: Vec<PolicyRef>,
426 pub status: TypedResultPublicationStatus,
428 pub privacy: PrivacyClass,
431 pub redacted_summary: String,
433}
434
435impl TypedResultPublicationRecord {
436 pub fn published(validated_output: &ValidatedOutput) -> Result<Self, TypedOutputError> {
440 validated_output.validate_shape()?;
441 Ok(Self {
442 record_schema_version: TYPED_RESULT_PUBLICATION_RECORD_SCHEMA_VERSION,
443 validated_output_id: validated_output.output_id.clone(),
444 schema_id: validated_output.schema_id.clone(),
445 schema_version: validated_output.schema_version,
446 canonical_value_ref: validated_output.canonical_value_ref.clone(),
447 validation_report_refs: validated_output.validation_report_refs.clone(),
448 source_attempt_ids: validated_output.source_attempt_ids.clone(),
449 policy_refs: validated_output.policy_refs.clone(),
450 status: TypedResultPublicationStatus::Published,
451 privacy: validated_output.privacy,
452 redacted_summary: validated_output.redacted_summary.clone(),
453 })
454 }
455
456 pub fn policy_denied(
460 validated_output: &ValidatedOutput,
461 redacted_summary: impl Into<String>,
462 ) -> Result<Self, TypedOutputError> {
463 validated_output.validate_shape()?;
464 Ok(Self {
465 record_schema_version: TYPED_RESULT_PUBLICATION_RECORD_SCHEMA_VERSION,
466 validated_output_id: validated_output.output_id.clone(),
467 schema_id: validated_output.schema_id.clone(),
468 schema_version: validated_output.schema_version,
469 canonical_value_ref: validated_output.canonical_value_ref.clone(),
470 validation_report_refs: validated_output.validation_report_refs.clone(),
471 source_attempt_ids: validated_output.source_attempt_ids.clone(),
472 policy_refs: validated_output.policy_refs.clone(),
473 status: TypedResultPublicationStatus::PolicyDenied,
474 privacy: validated_output.privacy,
475 redacted_summary: redacted_summary.into(),
476 })
477 }
478
479 pub fn validate_against_output(
483 &self,
484 validated_output: &ValidatedOutput,
485 ) -> Result<(), TypedOutputError> {
486 if self.record_schema_version != TYPED_RESULT_PUBLICATION_RECORD_SCHEMA_VERSION {
487 return Err(TypedOutputError::SchemaVersionUnsupported {
488 record_schema_version: self.record_schema_version,
489 });
490 }
491 if self.status != TypedResultPublicationStatus::Published {
492 return Err(TypedOutputError::PublicationPolicyDenied {
493 validated_output_id: self.validated_output_id.clone(),
494 });
495 }
496 validated_output.validate_shape()?;
497 if self.validation_report_refs.is_empty() {
498 return Err(TypedOutputError::MissingValidationReport {
499 output_id: validated_output.output_id.clone(),
500 });
501 }
502 if self.validated_output_id != validated_output.output_id
503 || self.schema_id != validated_output.schema_id
504 || self.schema_version != validated_output.schema_version
505 || content_ref_key(&self.canonical_value_ref)
506 != content_ref_key(&validated_output.canonical_value_ref)
507 || self.validation_report_refs != validated_output.validation_report_refs
508 {
509 return Err(TypedOutputError::PublicationEvidenceMismatch {
510 validated_output_id: self.validated_output_id.clone(),
511 });
512 }
513 Ok(())
514 }
515
516 pub fn validation_report_keys(&self) -> Vec<String> {
520 self.validation_report_refs
521 .iter()
522 .map(|report_ref| content_ref_key(&report_ref.report_ref))
523 .collect()
524 }
525}
526
527#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
528#[serde(rename_all = "snake_case")]
529pub enum TypedResultPublicationStatus {
532 Published,
534 PolicyDenied,
536}
537
538#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
539pub struct DecodedTypedOutput<T> {
542 pub content_ref: ContentRef,
545 pub output: T,
547}
548
549impl<T> DecodedTypedOutput<T> {
550 pub fn new(content_ref: ContentRef, output: T) -> Self {
554 Self {
555 content_ref,
556 output,
557 }
558 }
559}
560
561#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
562pub struct StructuredOutputResult<T> {
565 pub schema_id: OutputSchemaId,
567 pub schema_version: SchemaVersion,
569 pub validated_output_id: ValidatedOutputId,
571 #[serde(default, skip_serializing_if = "Vec::is_empty")]
572 pub validation_attempts: Vec<ValidationAttemptId>,
576 #[serde(default, skip_serializing_if = "Vec::is_empty")]
577 pub repair_attempts: Vec<RepairAttemptId>,
580 #[serde(default, skip_serializing_if = "Vec::is_empty")]
581 pub source_attempt_ids: Vec<AttemptId>,
584 pub output: T,
586 pub output_ref: ContentRef,
589 pub lineage: OutputLineage,
592 #[serde(default, skip_serializing_if = "Vec::is_empty")]
593 pub policy_refs: Vec<PolicyRef>,
596 pub privacy: PrivacyClass,
599 pub redacted_summary: String,
601}
602
603impl<T> StructuredOutputResult<T> {
604 pub fn from_publication<D>(
608 validated_output: &ValidatedOutput,
609 publication: &TypedResultPublicationRecord,
610 deserializer: &D,
611 ) -> Result<Self, TypedOutputError>
612 where
613 D: TypedOutputDeserializer<T>,
614 {
615 publication.validate_against_output(validated_output)?;
616 let decoded = deserializer.deserialize(&validated_output.canonical_value_ref)?;
617 if content_ref_key(&decoded.content_ref)
618 != content_ref_key(&validated_output.canonical_value_ref)
619 {
620 return Err(TypedOutputError::CanonicalValueRefMismatch {
621 validated_output_id: validated_output.output_id.clone(),
622 });
623 }
624 Ok(Self {
625 schema_id: validated_output.schema_id.clone(),
626 schema_version: validated_output.schema_version,
627 validated_output_id: validated_output.output_id.clone(),
628 validation_attempts: validated_output.validation_attempts.clone(),
629 repair_attempts: validated_output.repair_attempts.clone(),
630 source_attempt_ids: validated_output.source_attempt_ids.clone(),
631 output: decoded.output,
632 output_ref: validated_output.canonical_value_ref.clone(),
633 lineage: validated_output.lineage.clone(),
634 policy_refs: validated_output.policy_refs.clone(),
635 privacy: validated_output.privacy,
636 redacted_summary: validated_output.redacted_summary.clone(),
637 })
638 }
639}
640
641#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
642#[serde(tag = "type", content = "record", rename_all = "snake_case")]
643#[expect(
646 clippy::large_enum_variant,
647 reason = "publication steps are serialized contract records; boxing variants should be handled as a fixture-reviewed API migration"
648)]
649pub enum ValidatedOutputPublicationStep {
650 ValidationReport(ValidationReportRecord),
652 ValidatedOutput(ValidatedOutput),
654 TypedResultPublication(TypedResultPublicationRecord),
656}
657
658pub fn validate_typed_result_publication_order(
662 steps: &[ValidatedOutputPublicationStep],
663) -> Result<(), TypedOutputError> {
664 let mut successful_reports = BTreeMap::<String, ValidationReportRef>::new();
665 let mut validated_outputs = BTreeMap::<String, BTreeMap<String, ValidationReportRef>>::new();
666
667 for step in steps {
668 match step {
669 ValidatedOutputPublicationStep::ValidationReport(report) => {
670 if report.status.is_success() {
671 successful_reports.insert(
672 content_ref_key(&report.validation_report_ref),
673 report.to_ref(),
674 );
675 }
676 }
677 ValidatedOutputPublicationStep::ValidatedOutput(output) => {
678 output.validate_shape()?;
679 let output_report_refs = validation_report_ref_map(&output.validation_report_refs);
680 for (report_key, output_report_ref) in &output_report_refs {
681 let Some(successful_report_ref) = successful_reports.get(report_key) else {
682 return Err(TypedOutputError::PublicationBeforeValidation {
683 validated_output_id: output.output_id.clone(),
684 });
685 };
686 if successful_report_ref != output_report_ref {
687 return Err(TypedOutputError::PublicationEvidenceMismatch {
688 validated_output_id: output.output_id.clone(),
689 });
690 }
691 }
692 validated_outputs.insert(output.output_id.as_str().to_string(), output_report_refs);
693 }
694 ValidatedOutputPublicationStep::TypedResultPublication(publication) => {
695 let Some(output_report_refs) =
696 validated_outputs.get(publication.validated_output_id.as_str())
697 else {
698 return Err(TypedOutputError::PublicationBeforeValidation {
699 validated_output_id: publication.validated_output_id.clone(),
700 });
701 };
702
703 let publication_report_keys = publication
704 .validation_report_keys()
705 .into_iter()
706 .collect::<BTreeSet<_>>();
707 if publication_report_keys.is_empty() {
708 return Err(TypedOutputError::MissingValidationReport {
709 output_id: publication.validated_output_id.clone(),
710 });
711 }
712 let publication_report_refs =
713 validation_report_ref_map(&publication.validation_report_refs);
714 if &publication_report_refs != output_report_refs {
715 return Err(TypedOutputError::PublicationEvidenceMismatch {
716 validated_output_id: publication.validated_output_id.clone(),
717 });
718 }
719
720 for report_ref in &publication.validation_report_refs {
721 let report_key = content_ref_key(&report_ref.report_ref);
722 if !successful_reports.contains_key(&report_key)
723 || !output_report_refs.contains_key(&report_key)
724 {
725 return Err(TypedOutputError::PublicationBeforeValidation {
726 validated_output_id: publication.validated_output_id.clone(),
727 });
728 }
729 }
730 }
731 }
732 }
733
734 Ok(())
735}
736
737#[derive(Clone, Debug, Deserialize, Error, Eq, PartialEq, Serialize)]
738pub enum TypedOutputError {
741 #[error("validated output record schema version {record_schema_version} is unsupported")]
742 SchemaVersionUnsupported {
744 record_schema_version: u16,
747 },
748 #[error("validated output schema fingerprint must be a sha256 digest")]
749 InvalidSchemaFingerprint,
751 #[error("validated output is missing validation report evidence")]
752 MissingValidationReport {
754 output_id: ValidatedOutputId,
756 },
757 #[error("validated output is missing a source model attempt")]
758 MissingSourceAttempt {
760 output_id: ValidatedOutputId,
762 },
763 #[error("validated output is missing its canonical content ref")]
764 MissingCanonicalContentRef {
766 output_id: ValidatedOutputId,
768 },
769 #[error("validated output is missing a redacted summary")]
770 MissingRedactedSummary {
772 output_id: ValidatedOutputId,
774 },
775 #[error("validation report did not pass")]
776 ValidationReportFailed {
778 validation_attempt_id: ValidationAttemptId,
781 },
782 #[error("validated output schema does not match validation report schema")]
783 SchemaMismatch {
785 expected_schema_id: OutputSchemaId,
788 actual_schema_id: OutputSchemaId,
790 },
791 #[error("typed result publication happened before validated output evidence")]
792 PublicationBeforeValidation {
794 validated_output_id: ValidatedOutputId,
797 },
798 #[error("validated output publication was denied by output policy")]
799 PublicationPolicyDenied {
801 validated_output_id: ValidatedOutputId,
804 },
805 #[error("typed result publication evidence does not match validated output")]
806 PublicationEvidenceMismatch {
808 validated_output_id: ValidatedOutputId,
811 },
812 #[error("typed result decoder returned content from a different canonical value ref")]
813 CanonicalValueRefMismatch {
815 validated_output_id: ValidatedOutputId,
818 },
819 #[error("run {run_id:?} does not contain validated structured output")]
820 MissingValidatedOutput {
822 run_id: RunId,
824 },
825}
826
827impl TypedOutputError {
828 pub fn retry_classification(&self) -> RetryClassification {
832 match self {
833 Self::PublicationPolicyDenied { .. } | Self::ValidationReportFailed { .. } => {
834 RetryClassification::NotRetryable
835 }
836 Self::SchemaVersionUnsupported { .. }
837 | Self::InvalidSchemaFingerprint
838 | Self::MissingValidationReport { .. }
839 | Self::MissingSourceAttempt { .. }
840 | Self::MissingCanonicalContentRef { .. }
841 | Self::MissingRedactedSummary { .. }
842 | Self::SchemaMismatch { .. }
843 | Self::PublicationBeforeValidation { .. }
844 | Self::PublicationEvidenceMismatch { .. }
845 | Self::CanonicalValueRefMismatch { .. }
846 | Self::MissingValidatedOutput { .. } => RetryClassification::RepairNeeded,
847 }
848 }
849}
850
851impl From<TypedOutputError> for AgentError {
852 fn from(error: TypedOutputError) -> Self {
853 AgentError::new(
854 AgentErrorKind::StructuredOutputFailure,
855 error.retry_classification(),
856 error.to_string(),
857 )
858 }
859}
860
861fn push_unique_content_ref(content_refs: &mut Vec<ContentRef>, content_ref: ContentRef) {
862 if !content_refs_include(content_refs, &content_ref) {
863 content_refs.push(content_ref);
864 }
865}
866
867fn push_unique_policy_ref(policy_refs: &mut Vec<PolicyRef>, policy_ref: PolicyRef) {
868 if !policy_refs.contains(&policy_ref) {
869 policy_refs.push(policy_ref);
870 }
871}
872
873fn content_refs_include(content_refs: &[ContentRef], expected: &ContentRef) -> bool {
874 let expected_key = content_ref_key(expected);
875 content_refs
876 .iter()
877 .any(|content_ref| content_ref_key(content_ref) == expected_key)
878}
879
880fn validation_report_ref_map(
881 report_refs: &[ValidationReportRef],
882) -> BTreeMap<String, ValidationReportRef> {
883 report_refs
884 .iter()
885 .map(|report_ref| (content_ref_key(&report_ref.report_ref), report_ref.clone()))
886 .collect()
887}
888
889fn content_ref_key(content_ref: &ContentRef) -> String {
890 format!(
891 "{}@{}",
892 content_ref.content_id.as_str(),
893 content_ref.version.as_str()
894 )
895}
896
897fn is_sha256_fingerprint(value: &str) -> bool {
898 let Some(digest) = value.strip_prefix("sha256:") else {
899 return false;
900 };
901 digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
902}