Skip to main content

agent_sdk_core/records/
validated_output.rs

1//! Durable and observable SDK records. Use these DTOs for events, journals, effects,
2//! context, output, and feature evidence. Constructing records is data-only;
3//! persistence, publication, and external actions happen through ports or application
4//! coordinators. This file contains the validated output portion of that contract.
5//!
6use 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
22/// Constant value for the records::validated_output contract. Use it to
23/// keep SDK records and tests aligned on the same stable value.
24pub const VALIDATED_OUTPUT_RECORD_SCHEMA_VERSION: u16 = 1;
25/// Constant value for the records::validated_output contract. Use it to
26/// keep SDK records and tests aligned on the same stable value.
27pub const VALIDATION_REPORT_RECORD_SCHEMA_VERSION: u16 = 1;
28/// Constant value for the records::validated_output contract. Use it to
29/// keep SDK records and tests aligned on the same stable value.
30pub const TYPED_RESULT_PUBLICATION_RECORD_SCHEMA_VERSION: u16 = 1;
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33/// Carries the validated output record payload for journal, event, or fixture surfaces.
34/// Creating or cloning it only preserves serialized SDK state; append, publish, replay, or export effects are documented on the runtime and port methods that store it.
35pub struct ValidatedOutput {
36    /// Wire schema version for this record shape.
37    /// Use it for compatibility checks before deserializing or replaying stored data.
38    pub record_schema_version: u16,
39    /// Stable output id used for typed lineage, lookup, or dedupe.
40    pub output_id: ValidatedOutputId,
41    /// Stable schema id used for typed lineage, lookup, or dedupe.
42    pub schema_id: OutputSchemaId,
43    /// Wire schema version used for compatibility checks.
44    pub schema_version: SchemaVersion,
45    /// Deterministic schema fingerprint used for stale checks, package
46    /// evidence, or replay comparisons.
47    pub schema_fingerprint: ContentHash,
48    /// Typed canonical value ref reference. Resolving or executing it is a
49    /// separate policy-gated step.
50    pub canonical_value_ref: ContentRef,
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    /// Typed validation report refs references. Resolving them is separate
53    /// from constructing this record.
54    pub validation_report_refs: Vec<ValidationReportRef>,
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    /// Validation policy applied before output is accepted as typed data.
57    /// It controls validator selection, bounds, failure visibility, and local validation
58    /// behavior.
59    pub validation_attempts: Vec<ValidationAttemptId>,
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    /// Attempt identifier or attempt history for bounded retry/repair.
62    /// Use it to preserve ordering and avoid retry loops that cannot be audited.
63    pub repair_attempts: Vec<RepairAttemptId>,
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    /// Attempt identifier or attempt history for bounded retry/repair.
66    /// Use it to preserve ordering and avoid retry loops that cannot be audited.
67    pub source_attempt_ids: Vec<AttemptId>,
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    /// Content references associated with this record; resolving them is a
70    /// separate policy-gated step.
71    pub content_refs: Vec<ContentRef>,
72    /// Lineage information connecting this value to its source records.
73    /// Use it to audit derived data without replaying side effects.
74    pub lineage: OutputLineage,
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    /// Policy references that govern admission, projection, execution, or
77    /// delivery.
78    pub policy_refs: Vec<PolicyRef>,
79    /// Privacy class used for projection, telemetry, and raw-content access
80    /// decisions.
81    pub privacy: PrivacyClass,
82    /// Redacted human-readable summary safe for events, telemetry, and logs.
83    pub redacted_summary: String,
84}
85
86impl ValidatedOutput {
87    /// Constructs this value from validation report. Use it when
88    /// adapting canonical SDK records without introducing a second
89    /// behavior path.
90    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    /// Validates the records::validated_output invariants and returns a
143    /// typed error on failure. Validation is pure and does not perform
144    /// I/O, dispatch, journal appends, or adapter calls.
145    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    /// Returns the validation report keys derived from this value.
190    /// This is data-only and does not perform I/O, call host ports, append journals, publish
191    /// events, or start processes.
192    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)]
201/// Carries the validated output params record payload for journal, event, or fixture surfaces.
202/// Creating or cloning it only preserves serialized SDK state; append, publish, replay, or export effects are documented on the runtime and port methods that store it.
203pub struct ValidatedOutputParams {
204    /// Stable output id used for typed lineage, lookup, or dedupe.
205    pub output_id: ValidatedOutputId,
206    /// Stable schema id used for typed lineage, lookup, or dedupe.
207    pub schema_id: OutputSchemaId,
208    /// Wire schema version used for compatibility checks.
209    pub schema_version: SchemaVersion,
210    /// Deterministic schema fingerprint used for stale checks, package
211    /// evidence, or replay comparisons.
212    pub schema_fingerprint: ContentHash,
213    /// Typed canonical value ref reference. Resolving or executing it is a
214    /// separate policy-gated step.
215    pub canonical_value_ref: ContentRef,
216    /// Attempt identifier or attempt history for bounded retry/repair.
217    /// Use it to preserve ordering and avoid retry loops that cannot be audited.
218    pub repair_attempts: Vec<RepairAttemptId>,
219    /// Attempt identifier or attempt history for bounded retry/repair.
220    /// Use it to preserve ordering and avoid retry loops that cannot be audited.
221    pub source_attempt_ids: Vec<AttemptId>,
222    /// Content references associated with this record; resolving them is a
223    /// separate policy-gated step.
224    pub content_refs: Vec<ContentRef>,
225    /// Lineage information connecting this value to its source records.
226    /// Use it to audit derived data without replaying side effects.
227    pub lineage: OutputLineage,
228    /// Policy references that govern admission, projection, execution, or
229    /// delivery.
230    pub policy_refs: Vec<PolicyRef>,
231    /// Privacy class used for projection, telemetry, and raw-content access
232    /// decisions.
233    pub privacy: PrivacyClass,
234    /// Redacted human-readable summary safe for events, telemetry, and logs.
235    pub redacted_summary: String,
236}
237
238#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
239/// Carries the output lineage record payload for journal, event, or fixture surfaces.
240/// Creating or cloning it only preserves serialized SDK state; append, publish, replay, or export effects are documented on the runtime and port methods that store it.
241pub struct OutputLineage {
242    /// Typed lineage ref reference. Resolving or executing it is a separate
243    /// policy-gated step.
244    pub lineage_ref: LineageRef,
245    /// Producer reference for this value.
246    /// Use it to identify the SDK component or adapter that created the output.
247    pub produced_by: EntityRef,
248    #[serde(default, skip_serializing_if = "Vec::is_empty")]
249    /// Source refs this value was derived from.
250    /// Use them to trace provenance without embedding raw source content.
251    pub derived_from: Vec<EntityRef>,
252}
253
254#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
255/// Carries the validation report ref record payload for journal, event, or fixture surfaces.
256/// Creating or cloning it only preserves serialized SDK state; append, publish, replay, or export effects are documented on the runtime and port methods that store it.
257pub struct ValidationReportRef {
258    /// Stable validation attempt id used for typed lineage, lookup, or
259    /// dedupe.
260    pub validation_attempt_id: ValidationAttemptId,
261    /// Typed report ref reference. Resolving or executing it is a separate
262    /// policy-gated step.
263    pub report_ref: ContentRef,
264    /// Finite status for this record or lifecycle stage.
265    pub status: ValidationStatus,
266    /// Redacted human-readable summary safe for events, telemetry, and logs.
267    pub redacted_summary: String,
268}
269
270#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
271/// Carries the validation report record record payload for journal, event, or fixture surfaces.
272/// Creating or cloning it only preserves serialized SDK state; append, publish, replay, or export effects are documented on the runtime and port methods that store it.
273pub struct ValidationReportRecord {
274    /// Wire schema version for this record shape.
275    /// Use it for compatibility checks before deserializing or replaying stored data.
276    pub record_schema_version: u16,
277    /// Stable validation attempt id used for typed lineage, lookup, or
278    /// dedupe.
279    pub validation_attempt_id: ValidationAttemptId,
280    /// Stable schema id used for typed lineage, lookup, or dedupe.
281    pub schema_id: OutputSchemaId,
282    /// Wire schema version used for compatibility checks.
283    pub schema_version: SchemaVersion,
284    /// Stable source attempt id used for typed lineage, lookup, or dedupe.
285    pub source_attempt_id: AttemptId,
286    /// Content reference for the candidate value being validated.
287    pub candidate_content_ref: ContentRef,
288    /// Typed validation report ref reference. Resolving or executing it is a
289    /// separate policy-gated step.
290    pub validation_report_ref: ContentRef,
291    /// Finite status for this record or lifecycle stage.
292    pub status: ValidationStatus,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    /// Redacted summary for display, logs, events, or telemetry.
295    /// It should describe the value without exposing raw private content.
296    pub redacted_error_summary: Option<String>,
297    #[serde(default, skip_serializing_if = "Vec::is_empty")]
298    /// Policy references that govern admission, projection, execution, or
299    /// delivery.
300    pub policy_refs: Vec<PolicyRef>,
301    /// Privacy class used for projection, telemetry, and raw-content access
302    /// decisions.
303    pub privacy: PrivacyClass,
304    /// Redacted human-readable summary safe for events, telemetry, and logs.
305    pub redacted_summary: String,
306}
307
308impl ValidationReportRecord {
309    /// Builds the passed record or result value.
310    /// This is data-only and does not perform I/O, call host ports, append journals, publish
311    /// events, or start processes.
312    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    /// Returns an updated value with failed configured.
338    /// This is data-only and does not perform I/O, call host ports, append journals, publish
339    /// events, or start processes.
340    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    /// Converts this value into ref data.
367    /// This is data-only and does not perform I/O, call host ports, append journals, publish
368    /// events, or start processes.
369    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")]
381/// Enumerates the finite validation status cases.
382/// Serialized names are part of the SDK contract; update fixtures when variants change.
383pub enum ValidationStatus {
384    /// Use this variant when the contract needs to represent passed; selecting it has no side effect by itself.
385    Passed,
386    /// Use this variant when the contract needs to represent failed; selecting it has no side effect by itself.
387    Failed,
388}
389
390impl ValidationStatus {
391    /// Reports whether this value is success. The check is pure and
392    /// does not mutate SDK or host state.
393    pub fn is_success(self) -> bool {
394        matches!(self, Self::Passed)
395    }
396}
397
398#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
399/// Carries the typed result publication record record payload for journal, event, or fixture surfaces.
400/// Creating or cloning it only preserves serialized SDK state; append, publish, replay, or export effects are documented on the runtime and port methods that store it.
401pub struct TypedResultPublicationRecord {
402    /// Wire schema version for this record shape.
403    /// Use it for compatibility checks before deserializing or replaying stored data.
404    pub record_schema_version: u16,
405    /// Stable validated output id used for typed lineage, lookup, or dedupe.
406    pub validated_output_id: ValidatedOutputId,
407    /// Stable schema id used for typed lineage, lookup, or dedupe.
408    pub schema_id: OutputSchemaId,
409    /// Wire schema version used for compatibility checks.
410    pub schema_version: SchemaVersion,
411    /// Typed canonical value ref reference. Resolving or executing it is a
412    /// separate policy-gated step.
413    pub canonical_value_ref: ContentRef,
414    #[serde(default, skip_serializing_if = "Vec::is_empty")]
415    /// Typed validation report refs references. Resolving them is separate
416    /// from constructing this record.
417    pub validation_report_refs: Vec<ValidationReportRef>,
418    #[serde(default, skip_serializing_if = "Vec::is_empty")]
419    /// Attempt identifier or attempt history for bounded retry/repair.
420    /// Use it to preserve ordering and avoid retry loops that cannot be audited.
421    pub source_attempt_ids: Vec<AttemptId>,
422    #[serde(default, skip_serializing_if = "Vec::is_empty")]
423    /// Policy references that govern admission, projection, execution, or
424    /// delivery.
425    pub policy_refs: Vec<PolicyRef>,
426    /// Finite status for this record or lifecycle stage.
427    pub status: TypedResultPublicationStatus,
428    /// Privacy class used for projection, telemetry, and raw-content access
429    /// decisions.
430    pub privacy: PrivacyClass,
431    /// Redacted human-readable summary safe for events, telemetry, and logs.
432    pub redacted_summary: String,
433}
434
435impl TypedResultPublicationRecord {
436    /// Builds the published record or result value.
437    /// This is data-only and does not perform I/O, call host ports, append journals, publish
438    /// events, or start processes.
439    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    /// Builds the policy denied value.
457    /// This is data construction and performs no I/O, journal append, event publication, or
458    /// process work.
459    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    /// Validates the records::validated_output invariants and returns a
480    /// typed error on failure. Validation is pure and does not perform
481    /// I/O, dispatch, journal appends, or adapter calls.
482    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    /// Returns the validation report keys derived from this value.
517    /// This is data-only and does not perform I/O, call host ports, append journals, publish
518    /// events, or start processes.
519    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")]
529/// Enumerates the finite typed result publication status cases.
530/// Serialized names are part of the SDK contract; update fixtures when variants change.
531pub enum TypedResultPublicationStatus {
532    /// Use this variant when the contract needs to represent published; selecting it has no side effect by itself.
533    Published,
534    /// Use this variant when the contract needs to represent policy denied; selecting it has no side effect by itself.
535    PolicyDenied,
536}
537
538#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
539/// Carries the decoded typed output record payload for journal, event, or fixture surfaces.
540/// Creating or cloning it only preserves serialized SDK state; append, publish, replay, or export effects are documented on the runtime and port methods that store it.
541pub struct DecodedTypedOutput<T> {
542    /// Content reference where payload bytes or structured tool output are
543    /// stored.
544    pub content_ref: ContentRef,
545    /// Output used by this record or request.
546    pub output: T,
547}
548
549impl<T> DecodedTypedOutput<T> {
550    /// Creates a new records::validated_output value with explicit
551    /// caller-provided inputs. This constructor is data-only and
552    /// performs no I/O or external side effects.
553    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)]
562/// Carries the structured output result record payload for journal, event, or fixture surfaces.
563/// Creating or cloning it only preserves serialized SDK state; append, publish, replay, or export effects are documented on the runtime and port methods that store it.
564pub struct StructuredOutputResult<T> {
565    /// Stable schema id used for typed lineage, lookup, or dedupe.
566    pub schema_id: OutputSchemaId,
567    /// Wire schema version used for compatibility checks.
568    pub schema_version: SchemaVersion,
569    /// Stable validated output id used for typed lineage, lookup, or dedupe.
570    pub validated_output_id: ValidatedOutputId,
571    #[serde(default, skip_serializing_if = "Vec::is_empty")]
572    /// Validation policy applied before output is accepted as typed data.
573    /// It controls validator selection, bounds, failure visibility, and local validation
574    /// behavior.
575    pub validation_attempts: Vec<ValidationAttemptId>,
576    #[serde(default, skip_serializing_if = "Vec::is_empty")]
577    /// Attempt identifier or attempt history for bounded retry/repair.
578    /// Use it to preserve ordering and avoid retry loops that cannot be audited.
579    pub repair_attempts: Vec<RepairAttemptId>,
580    #[serde(default, skip_serializing_if = "Vec::is_empty")]
581    /// Attempt identifier or attempt history for bounded retry/repair.
582    /// Use it to preserve ordering and avoid retry loops that cannot be audited.
583    pub source_attempt_ids: Vec<AttemptId>,
584    /// Output used by this record or request.
585    pub output: T,
586    /// Typed output ref reference. Resolving or executing it is a separate
587    /// policy-gated step.
588    pub output_ref: ContentRef,
589    /// Lineage information connecting this value to its source records.
590    /// Use it to audit derived data without replaying side effects.
591    pub lineage: OutputLineage,
592    #[serde(default, skip_serializing_if = "Vec::is_empty")]
593    /// Policy references that govern admission, projection, execution, or
594    /// delivery.
595    pub policy_refs: Vec<PolicyRef>,
596    /// Privacy class used for projection, telemetry, and raw-content access
597    /// decisions.
598    pub privacy: PrivacyClass,
599    /// Redacted human-readable summary safe for events, telemetry, and logs.
600    pub redacted_summary: String,
601}
602
603impl<T> StructuredOutputResult<T> {
604    /// Constructs this value from publication. Use it when adapting
605    /// canonical SDK records without introducing a second behavior
606    /// path.
607    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/// Enumerates the finite validated output publication step cases.
644/// Serialized names are part of the SDK contract; update fixtures when variants change.
645#[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    /// Use this variant when the contract needs to represent validation report; selecting it has no side effect by itself.
651    ValidationReport(ValidationReportRecord),
652    /// Use this variant when the contract needs to represent validated output; selecting it has no side effect by itself.
653    ValidatedOutput(ValidatedOutput),
654    /// Use this variant when the contract needs to represent typed result publication; selecting it has no side effect by itself.
655    TypedResultPublication(TypedResultPublicationRecord),
656}
657
658/// Validates the records::validated_output invariants and returns a
659/// typed error on failure. Validation is pure and does not perform I/O,
660/// dispatch, journal appends, or adapter calls.
661pub 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)]
738/// Enumerates the finite typed output error cases.
739/// Serialized names are part of the SDK contract; update fixtures when variants change.
740pub enum TypedOutputError {
741    #[error("validated output record schema version {record_schema_version} is unsupported")]
742    /// Use this variant when the contract needs to represent schema version unsupported; selecting it has no side effect by itself.
743    SchemaVersionUnsupported {
744        /// Wire schema version for this record shape.
745        /// Use it for compatibility checks before deserializing or replaying stored data.
746        record_schema_version: u16,
747    },
748    #[error("validated output schema fingerprint must be a sha256 digest")]
749    /// Use this variant when the contract needs to represent invalid schema fingerprint; selecting it has no side effect by itself.
750    InvalidSchemaFingerprint,
751    #[error("validated output is missing validation report evidence")]
752    /// Use this variant when the contract needs to represent missing validation report; selecting it has no side effect by itself.
753    MissingValidationReport {
754        /// Stable output id used for typed lineage, lookup, or dedupe.
755        output_id: ValidatedOutputId,
756    },
757    #[error("validated output is missing a source model attempt")]
758    /// Use this variant when the contract needs to represent missing source attempt; selecting it has no side effect by itself.
759    MissingSourceAttempt {
760        /// Stable output id used for typed lineage, lookup, or dedupe.
761        output_id: ValidatedOutputId,
762    },
763    #[error("validated output is missing its canonical content ref")]
764    /// Use this variant when the contract needs to represent missing canonical content ref; selecting it has no side effect by itself.
765    MissingCanonicalContentRef {
766        /// Stable output id used for typed lineage, lookup, or dedupe.
767        output_id: ValidatedOutputId,
768    },
769    #[error("validated output is missing a redacted summary")]
770    /// Use this variant when the contract needs to represent missing redacted summary; selecting it has no side effect by itself.
771    MissingRedactedSummary {
772        /// Stable output id used for typed lineage, lookup, or dedupe.
773        output_id: ValidatedOutputId,
774    },
775    #[error("validation report did not pass")]
776    /// Use this variant when the contract needs to represent validation report failed; selecting it has no side effect by itself.
777    ValidationReportFailed {
778        /// Stable validation attempt id used for typed lineage, lookup, or
779        /// dedupe.
780        validation_attempt_id: ValidationAttemptId,
781    },
782    #[error("validated output schema does not match validation report schema")]
783    /// Use this variant when the contract needs to represent schema mismatch; selecting it has no side effect by itself.
784    SchemaMismatch {
785        /// Stable expected schema id used for typed lineage, lookup, or
786        /// dedupe.
787        expected_schema_id: OutputSchemaId,
788        /// Stable actual schema id used for typed lineage, lookup, or dedupe.
789        actual_schema_id: OutputSchemaId,
790    },
791    #[error("typed result publication happened before validated output evidence")]
792    /// Use this variant when the contract needs to represent publication before validation; selecting it has no side effect by itself.
793    PublicationBeforeValidation {
794        /// Stable validated output id used for typed lineage, lookup, or
795        /// dedupe.
796        validated_output_id: ValidatedOutputId,
797    },
798    #[error("validated output publication was denied by output policy")]
799    /// Use this variant when the contract needs to represent publication policy denied; selecting it has no side effect by itself.
800    PublicationPolicyDenied {
801        /// Stable validated output id used for typed lineage, lookup, or
802        /// dedupe.
803        validated_output_id: ValidatedOutputId,
804    },
805    #[error("typed result publication evidence does not match validated output")]
806    /// Use this variant when the contract needs to represent publication evidence mismatch; selecting it has no side effect by itself.
807    PublicationEvidenceMismatch {
808        /// Stable validated output id used for typed lineage, lookup, or
809        /// dedupe.
810        validated_output_id: ValidatedOutputId,
811    },
812    #[error("typed result decoder returned content from a different canonical value ref")]
813    /// Use this variant when the contract needs to represent canonical value ref mismatch; selecting it has no side effect by itself.
814    CanonicalValueRefMismatch {
815        /// Stable validated output id used for typed lineage, lookup, or
816        /// dedupe.
817        validated_output_id: ValidatedOutputId,
818    },
819    #[error("run {run_id:?} does not contain validated structured output")]
820    /// Use this variant when the contract needs to represent missing validated output; selecting it has no side effect by itself.
821    MissingValidatedOutput {
822        /// Run identifier used for lineage, filtering, replay, and dedupe.
823        run_id: RunId,
824    },
825}
826
827impl TypedOutputError {
828    /// Computes or returns retry classification for the
829    /// records::validated_output contract without external I/O or side
830    /// effects.
831    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}