Skip to main content

agent_sdk_core/records/
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 output portion of that contract.
5//!
6use core::fmt;
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
10use serde_json::{Map, Value};
11use sha2::{Digest, Sha256};
12
13use crate::{
14    domain::{AgentError, ContentRef, IdValidationError, OutputSchemaId, PolicyKind, PolicyRef},
15    ids::validate_identifier,
16    policy::ContentCapturePolicy,
17    typed_output_ports::TypedOutputModel,
18};
19
20#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
21/// Carries the output contract record payload for journal, event, or fixture surfaces.
22/// 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.
23pub struct OutputContract {
24    /// Stable schema id used for typed lineage, lookup, or dedupe.
25    pub schema_id: OutputSchemaId,
26    /// Wire schema version used for compatibility checks.
27    pub schema_version: SchemaVersion,
28    /// Schema dialect used to interpret the output schema.
29    /// Validators use it to select the supported JSON-schema subset and compatibility rules.
30    pub dialect: OutputSchemaDialect,
31    /// Schema reference or inline schema used to validate structured output.
32    /// The runtime resolves this before treating model output as typed data.
33    pub schema: OutputSchemaRef,
34    /// Mode that selects how this operation or contract should behave.
35    /// Callers use it to choose the explicit execution path instead of relying on hidden
36    /// defaults.
37    pub mode: OutputMode,
38    /// Validation policy applied before output is accepted as typed data.
39    /// It controls validator selection, bounds, failure visibility, and local validation
40    /// behavior.
41    pub validation: ValidationPolicy,
42    /// Repair policy used after structured output validation fails.
43    /// It controls whether repair is attempted and which policy gates must approve it.
44    pub repair: RepairPolicy,
45    /// Retry budget for validation, repair, or adapter attempts.
46    /// Runtimes use it to stop bounded loops deterministically.
47    pub retry_budget: RetryBudget,
48    /// Content-capture policy that governs raw content, summaries, redaction, and retention.
49    /// Projection, telemetry, and delivery paths must honor it before exposing content.
50    pub content_policy: ContentCapturePolicy,
51    /// Provider-facing projection hint for structured output requests.
52    /// It can guide model prompting but does not replace local validation policy.
53    pub projection_hint: OutputProjectionHint,
54}
55
56impl OutputContract {
57    /// Builds the for type value with the documented defaults.
58    /// This is data-only and does not perform I/O, call host ports, append journals, publish
59    /// events, or start processes.
60    pub fn for_type<T: TypedOutputModel>() -> Self {
61        Self::from_typed_model::<T>(OutputPreset::StrictJsonSchema)
62    }
63
64    /// Returns an updated value with strict json schema configured.
65    /// This is data-only and does not perform I/O, call host ports, append journals, publish
66    /// events, or start processes.
67    pub fn strict_json_schema<T: TypedOutputModel>() -> Self {
68        Self::from_typed_model::<T>(OutputPreset::StrictJsonSchema)
69    }
70
71    /// Builds the fast lenient value.
72    /// This is data construction and performs no I/O, journal append, event publication, or
73    /// process work.
74    pub fn fast_lenient<T: TypedOutputModel>() -> Self {
75        Self::from_typed_model::<T>(OutputPreset::FastLenient)
76    }
77
78    /// Builds the provider assisted value.
79    /// This is data construction and performs no I/O, journal append, event publication, or
80    /// process work.
81    pub fn provider_assisted<T: TypedOutputModel>() -> Self {
82        Self::from_typed_model::<T>(OutputPreset::ProviderAssisted)
83    }
84
85    /// Builds the inline json schema value.
86    /// This is data construction and performs no I/O, journal append, event publication, or
87    /// process work.
88    pub fn inline_json_schema(
89        schema_id: OutputSchemaId,
90        schema_version: SchemaVersion,
91        redacted_schema: Value,
92    ) -> Self {
93        let content_hash = canonical_content_hash(&redacted_schema);
94        Self::new(
95            schema_id,
96            schema_version,
97            OutputSchemaDialect::JsonSchema2020_12Subset,
98            OutputSchemaRef::InlineJson {
99                content_hash,
100                redacted_schema,
101            },
102            OutputPreset::StrictJsonSchema,
103        )
104    }
105
106    /// Creates a new records::output value with explicit
107    /// caller-provided inputs. This constructor is data-only and
108    /// performs no I/O or external side effects.
109    pub fn new(
110        schema_id: OutputSchemaId,
111        schema_version: SchemaVersion,
112        dialect: OutputSchemaDialect,
113        schema: OutputSchemaRef,
114        preset: OutputPreset,
115    ) -> Self {
116        let mut validation = ValidationPolicy::strict_defaults();
117        let mut repair = RepairPolicy::safe_defaults();
118        let mut retry_budget = RetryBudget::attempts(2);
119        let mut projection_hint = OutputProjectionHint::schema_ref_only();
120
121        match preset {
122            OutputPreset::StrictJsonSchema => {}
123            OutputPreset::FastLenient => {
124                validation.allow_additional_properties = true;
125                validation.max_errors_returned = 8;
126                repair.max_repair_attempts = 1;
127                retry_budget = RetryBudget::attempts(1);
128                projection_hint.provider_hint_policy = ProviderHintPolicy::SchemaOptional;
129            }
130            OutputPreset::ProviderAssisted => {
131                projection_hint.provider_hint_policy = ProviderHintPolicy::ProviderNativeHint;
132            }
133        }
134
135        Self {
136            schema_id,
137            schema_version,
138            dialect,
139            schema,
140            mode: OutputMode::FinalOnly,
141            validation,
142            repair,
143            retry_budget,
144            content_policy: ContentCapturePolicy::safe_defaults(PolicyRef::with_kind(
145                PolicyKind::Privacy,
146                "policy.output.content_capture.safe_defaults",
147            )),
148            projection_hint,
149        }
150    }
151
152    /// Computes the stable schema fingerprint for this records::output
153    /// value. The computation is deterministic and side-effect free so
154    /// it can be used in package, journal, or test evidence.
155    pub fn schema_fingerprint(&self) -> ContentHash {
156        self.schema.content_hash()
157    }
158
159    /// Validates the records::output invariants and returns a typed
160    /// error on failure. Validation is pure and does not perform I/O,
161    /// dispatch, journal appends, or adapter calls.
162    pub fn validate_shape(&self) -> Result<(), AgentError> {
163        if self.retry_budget.max_attempts < self.repair.max_repair_attempts {
164            return Err(AgentError::contract_violation(
165                "output retry budget must cover repair attempts",
166            ));
167        }
168        if self.validation.max_candidate_bytes == 0 {
169            return Err(AgentError::missing_required_field(
170                "output.validation.max_candidate_bytes",
171            ));
172        }
173        self.schema.validate_shape()
174    }
175
176    fn from_typed_model<T: TypedOutputModel>(preset: OutputPreset) -> Self {
177        Self::new(
178            OutputSchemaId::new(T::SCHEMA_ID),
179            T::SCHEMA_VERSION,
180            T::DIALECT,
181            T::schema_ref(),
182            preset,
183        )
184    }
185}
186
187#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
188/// Carries the schema version record payload for journal, event, or fixture surfaces.
189/// 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.
190pub struct SchemaVersion {
191    /// Major used by this record or request.
192    pub major: u16,
193    /// Minor used by this record or request.
194    pub minor: u16,
195    /// Patch used by this record or request.
196    pub patch: u16,
197}
198
199impl SchemaVersion {
200    /// Constant value for the records::output contract. Use it to keep
201    /// SDK records and tests aligned on the same stable value.
202    pub const fn new(major: u16, minor: u16, patch: u16) -> Self {
203        Self {
204            major,
205            minor,
206            patch,
207        }
208    }
209}
210
211#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
212#[serde(rename_all = "snake_case")]
213/// Enumerates the finite output schema dialect cases.
214/// Serialized names are part of the SDK contract; update fixtures when variants change.
215pub enum OutputSchemaDialect {
216    /// Use this variant when the contract needs to represent json schema2020 12 subset; selecting it has no side effect by itself.
217    JsonSchema2020_12Subset,
218    /// Use this variant when the contract needs to represent rust serde type name; selecting it has no side effect by itself.
219    RustSerdeTypeName,
220    /// Use this variant when the contract needs to represent host registered; selecting it has no side effect by itself.
221    HostRegistered,
222}
223
224#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
225#[serde(tag = "type", rename_all = "snake_case")]
226/// Enumerates the finite output schema ref cases.
227/// Serialized names are part of the SDK contract; update fixtures when variants change.
228pub enum OutputSchemaRef {
229    /// Use this variant when the contract needs to represent inline json; selecting it has no side effect by itself.
230    InlineJson {
231        /// Stable hash for the bytes or canonical payload used for stale
232        /// checks and fingerprints.
233        content_hash: ContentHash,
234        /// Schema body safe to expose after redaction.
235        /// It can be logged or shown without revealing private schema content beyond policy.
236        redacted_schema: Value,
237    },
238    /// Use this variant when the contract needs to represent content store; selecting it has no side effect by itself.
239    ContentStore {
240        /// Content reference where payload bytes or structured tool output
241        /// are stored.
242        content_ref: ContentRef,
243        /// Stable hash for the bytes or canonical payload used for stale
244        /// checks and fingerprints.
245        content_hash: ContentHash,
246    },
247    /// Use this variant when the contract needs to represent rust serde; selecting it has no side effect by itself.
248    RustSerde {
249        /// Type name used by this record or request.
250        type_name: TypeName,
251        /// Crate name used by this record or request.
252        crate_name: CrateName,
253        /// Version string for this capability, package, or protocol surface.
254        /// Use it for compatibility checks during package or adapter resolution.
255        crate_version: String,
256        /// Typed generated schema ref reference. Resolving or executing it is
257        /// a separate policy-gated step.
258        generated_schema_ref: ContentRef,
259        /// Stable hash for the bytes or canonical payload used for stale
260        /// checks and fingerprints.
261        content_hash: ContentHash,
262    },
263    /// Use this variant when the contract needs to represent host registered; selecting it has no side effect by itself.
264    HostRegistered {
265        /// Typed validator ref reference. Resolving or executing it is a
266        /// separate policy-gated step.
267        validator_ref: OutputValidatorRef,
268        /// Version string for this capability, package, or protocol surface.
269        /// Use it for compatibility checks during package or adapter resolution.
270        contract_version: SchemaVersion,
271        /// Stable hash for the bytes or canonical payload used for stale
272        /// checks and fingerprints.
273        content_hash: ContentHash,
274    },
275}
276
277impl Eq for OutputSchemaRef {}
278
279impl OutputSchemaRef {
280    /// Computes the stable content hash for this records::output value.
281    /// The computation is deterministic and side-effect free so it can
282    /// be used in package, journal, or test evidence.
283    pub fn content_hash(&self) -> ContentHash {
284        match self {
285            Self::InlineJson { content_hash, .. }
286            | Self::ContentStore { content_hash, .. }
287            | Self::RustSerde { content_hash, .. }
288            | Self::HostRegistered { content_hash, .. } => content_hash.clone(),
289        }
290    }
291
292    /// Validates the records::output invariants and returns a typed
293    /// error on failure. Validation is pure and does not perform I/O,
294    /// dispatch, journal appends, or adapter calls.
295    pub fn validate_shape(&self) -> Result<(), AgentError> {
296        match self {
297            Self::InlineJson {
298                content_hash,
299                redacted_schema,
300            } => {
301                content_hash.validate_shape()?;
302                if redacted_schema.is_null() {
303                    return Err(AgentError::missing_required_field(
304                        "output.schema.redacted_schema",
305                    ));
306                }
307            }
308            Self::ContentStore { content_hash, .. }
309            | Self::RustSerde { content_hash, .. }
310            | Self::HostRegistered { content_hash, .. } => content_hash.validate_shape()?,
311        }
312        Ok(())
313    }
314}
315
316#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
317#[serde(rename_all = "snake_case")]
318/// Enumerates the finite output mode cases.
319/// Serialized names are part of the SDK contract; update fixtures when variants change.
320pub enum OutputMode {
321    /// Use this variant when the contract needs to represent final only; selecting it has no side effect by itself.
322    FinalOnly,
323    /// Use this variant when the contract needs to represent incremental preview; selecting it has no side effect by itself.
324    IncrementalPreview,
325}
326
327#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
328/// Carries the validation policy record payload for journal, event, or fixture surfaces.
329/// 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.
330pub struct ValidationPolicy {
331    /// Typed validator ref reference. Resolving or executing it is a separate
332    /// policy-gated step.
333    pub validator_ref: OutputValidatorRef,
334    /// max candidate bytes used for bounds checks, summaries, or truncation
335    /// evidence.
336    pub max_candidate_bytes: u64,
337    /// Maximum number of validation errors to expose in a report.
338    /// Use it to keep failure output bounded and safe for logs or events.
339    pub max_errors_returned: u16,
340    /// Whether JSON-schema validation permits properties not declared by the schema.
341    /// Strict SDK output contracts should usually keep this false for deterministic
342    /// typed-output validation.
343    pub allow_additional_properties: bool,
344    #[serde(default, skip_serializing_if = "Vec::is_empty")]
345    /// Additional semantic validator refs to run after schema validation.
346    /// Each validator still resolves through policy-gated validator infrastructure.
347    pub semantic_validators: Vec<SemanticValidatorRef>,
348    /// Timeout budget in milliseconds for the requested operation.
349    pub timeout_ms: u64,
350    /// Visibility policy for validation failure details.
351    /// It controls how much error detail can appear in reports, events, and repair prompts.
352    pub failure_visibility: ValidationFailureVisibility,
353}
354
355impl ValidationPolicy {
356    /// Returns an updated value with strict defaults configured.
357    /// This is data-only and does not perform I/O, call host ports, append journals, publish
358    /// events, or start processes.
359    pub fn strict_defaults() -> Self {
360        Self {
361            validator_ref: OutputValidatorRef::new("validator.output.json_schema.local.v1"),
362            max_candidate_bytes: 32 * 1024,
363            max_errors_returned: 32,
364            allow_additional_properties: false,
365            semantic_validators: Vec::new(),
366            timeout_ms: 10_000,
367            failure_visibility: ValidationFailureVisibility::RedactedSummary,
368        }
369    }
370
371    /// Returns validator ref policy for callers that need to inspect the contract state.
372    /// This is data-only and does not perform I/O, call host ports, append journals, publish
373    /// events, or start processes.
374    pub fn validator_ref_policy(&self) -> PolicyRef {
375        PolicyRef::with_kind(PolicyKind::RuntimePackage, self.validator_ref.as_str())
376    }
377}
378
379#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
380#[serde(rename_all = "snake_case")]
381/// Enumerates the finite validation failure visibility cases.
382/// Serialized names are part of the SDK contract; update fixtures when variants change.
383pub enum ValidationFailureVisibility {
384    /// Use this variant when the contract needs to represent redacted summary; selecting it has no side effect by itself.
385    RedactedSummary,
386    /// Only stable validation error codes should be exposed. Selecting
387    /// this variant is data-only and does not publish or persist errors.
388    ErrorCodesOnly,
389}
390
391#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
392/// Carries the repair policy record payload for journal, event, or fixture surfaces.
393/// 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.
394pub struct RepairPolicy {
395    /// Typed repair adapter ref reference. Resolving or executing it is a
396    /// separate policy-gated step.
397    pub repair_adapter_ref: RepairAdapterRef,
398    /// Attempt identifier or attempt history for bounded retry/repair.
399    /// Use it to preserve ordering and avoid retry loops that cannot be audited.
400    pub max_repair_attempts: u8,
401    /// Whether include schema in prompt is enabled.
402    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
403    pub include_schema_in_prompt: bool,
404    /// Whether include redacted errors is enabled.
405    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
406    pub include_redacted_errors: bool,
407    /// Whether include candidate content is enabled.
408    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
409    pub include_candidate_content: CandidateContentRepairPolicy,
410    /// Backoff used by this record or request.
411    pub backoff: RetryBackoff,
412    /// On exhausted used by this record or request.
413    pub on_exhausted: RepairExhaustedBehavior,
414}
415
416impl RepairPolicy {
417    /// Returns an updated value with safe defaults configured.
418    /// This is data-only and does not perform I/O, call host ports, append journals, publish
419    /// events, or start processes.
420    pub fn safe_defaults() -> Self {
421        Self {
422            repair_adapter_ref: RepairAdapterRef::new("repair.output.provider_redacted.v1"),
423            max_repair_attempts: 2,
424            include_schema_in_prompt: true,
425            include_redacted_errors: true,
426            include_candidate_content: CandidateContentRepairPolicy::ContentRefOnly,
427            backoff: RetryBackoff::None,
428            on_exhausted: RepairExhaustedBehavior::FailRun,
429        }
430    }
431
432    /// Returns repair adapter ref policy for callers that need to inspect the contract state.
433    /// This is data-only and does not perform I/O, call host ports, append journals, publish
434    /// events, or start processes.
435    pub fn repair_adapter_ref_policy(&self) -> PolicyRef {
436        PolicyRef::with_kind(PolicyKind::RuntimePackage, self.repair_adapter_ref.as_str())
437    }
438}
439
440#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
441/// Carries the retry budget record payload for journal, event, or fixture surfaces.
442/// 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.
443pub struct RetryBudget {
444    /// Attempt identifier or attempt history for bounded retry/repair.
445    /// Use it to preserve ordering and avoid retry loops that cannot be audited.
446    pub max_attempts: u8,
447}
448
449impl RetryBudget {
450    /// Builds the attempts value.
451    /// This is data construction and performs no I/O, journal append, event publication, or
452    /// process work.
453    pub fn attempts(max_attempts: u8) -> Self {
454        Self { max_attempts }
455    }
456}
457
458#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
459#[serde(rename_all = "snake_case")]
460/// Enumerates the finite candidate content repair policy cases.
461/// Serialized names are part of the SDK contract; update fixtures when variants change.
462pub enum CandidateContentRepairPolicy {
463    /// Use this variant when the contract needs to represent content ref only; selecting it has no side effect by itself.
464    ContentRefOnly,
465    /// Use this variant when the contract needs to represent redacted candidate; selecting it has no side effect by itself.
466    RedactedCandidate,
467    /// Use this variant when the contract needs to represent omit candidate; selecting it has no side effect by itself.
468    OmitCandidate,
469}
470
471#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
472#[serde(rename_all = "snake_case")]
473/// Enumerates the finite retry backoff cases.
474/// Serialized names are part of the SDK contract; update fixtures when variants change.
475pub enum RetryBackoff {
476    /// Use this variant when the contract needs to represent none; selecting it has no side effect by itself.
477    None,
478    /// Use this variant when the contract needs to represent linear; selecting it has no side effect by itself.
479    Linear,
480}
481
482#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
483#[serde(rename_all = "snake_case")]
484/// Enumerates the finite repair exhausted behavior cases.
485/// Serialized names are part of the SDK contract; update fixtures when variants change.
486pub enum RepairExhaustedBehavior {
487    /// Use this variant when the contract needs to represent fail run; selecting it has no side effect by itself.
488    FailRun,
489    /// Use this variant when the contract needs to represent return validation error; selecting it has no side effect by itself.
490    ReturnValidationError,
491}
492
493#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
494/// Carries the output projection hint record payload for journal, event, or fixture surfaces.
495/// 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.
496pub struct OutputProjectionHint {
497    /// Policy for provider-side structured-output hints.
498    /// Hints may guide prompting but cannot replace SDK-owned validation.
499    pub provider_hint_policy: ProviderHintPolicy,
500    /// Typed include schema ref reference. Resolving or executing it is a
501    /// separate policy-gated step.
502    pub include_schema_ref: bool,
503}
504
505impl OutputProjectionHint {
506    /// Returns an updated value with schema ref only configured.
507    /// This is data-only and does not perform I/O, call host ports, append journals, publish
508    /// events, or start processes.
509    pub fn schema_ref_only() -> Self {
510        Self {
511            provider_hint_policy: ProviderHintPolicy::SchemaRequired,
512            include_schema_ref: true,
513        }
514    }
515}
516
517#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
518#[serde(rename_all = "snake_case")]
519/// Enumerates the finite provider hint policy cases.
520/// Serialized names are part of the SDK contract; update fixtures when variants change.
521pub enum ProviderHintPolicy {
522    /// Use this variant when the contract needs to represent schema required; selecting it has no side effect by itself.
523    SchemaRequired,
524    /// Use this variant when the contract needs to represent schema optional; selecting it has no side effect by itself.
525    SchemaOptional,
526    /// Use this variant when the contract needs to represent provider native hint; selecting it has no side effect by itself.
527    ProviderNativeHint,
528}
529
530#[derive(Clone, Copy, Debug, Eq, PartialEq)]
531/// Enumerates the finite output preset cases.
532/// Serialized names are part of the SDK contract; update fixtures when variants change.
533pub enum OutputPreset {
534    /// Use this variant when the contract needs to represent strict json schema; selecting it has no side effect by itself.
535    StrictJsonSchema,
536    /// Use this variant when the contract needs to represent fast lenient; selecting it has no side effect by itself.
537    FastLenient,
538    /// Use this variant when the contract needs to represent provider assisted; selecting it has no side effect by itself.
539    ProviderAssisted,
540}
541
542macro_rules! output_string {
543    ($name:ident) => {
544        #[doc = concat!(
545            "Typed output-string wrapper for `",
546            stringify!($name),
547            "`. Use it where output contracts need stable schema, hash, or validator refs; ",
548            "constructing it is data-only and performs no side effects."
549        )]
550        #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
551        #[serde(transparent)]
552        pub struct $name(String);
553
554        impl $name {
555            /// Creates a new records::output value with explicit
556            /// caller-provided inputs. This constructor is data-only
557            /// and performs no I/O or external side effects.
558            ///
559            /// # Panics
560            ///
561            /// Panics if constructor invariants fail, such as invalid identifier
562            /// text or constructor-specific bounds. Use a fallible constructor such as
563            /// `try_new` when one is available for untrusted input.
564            pub fn new(value: impl Into<String>) -> Self {
565                Self::try_new(value).expect(concat!(stringify!($name), " must be valid"))
566            }
567
568            /// Creates a new records::output value after validation.
569            /// Returns an SDK error instead of panicking when the
570            /// identifier or input does not satisfy the contract.
571            pub fn try_new(value: impl Into<String>) -> Result<Self, IdValidationError> {
572                let value = value.into();
573                validate_identifier(&value)?;
574                Ok(Self(value))
575            }
576
577            /// Returns this value as str. The accessor is side-effect
578            /// free and keeps ownership with the caller.
579            pub fn as_str(&self) -> &str {
580                &self.0
581            }
582        }
583
584        impl<'de> Deserialize<'de> for $name {
585            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
586            where
587                D: Deserializer<'de>,
588            {
589                let value = String::deserialize(deserializer)?;
590                Self::try_new(value).map_err(D::Error::custom)
591            }
592        }
593
594        impl fmt::Debug for $name {
595            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
596                formatter.write_str(concat!(stringify!($name), "(redacted)"))
597            }
598        }
599
600        impl fmt::Display for $name {
601            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
602                formatter.write_str(concat!(stringify!($name), "(redacted)"))
603            }
604        }
605    };
606}
607
608output_string!(ContentHash);
609output_string!(OutputValidatorRef);
610output_string!(RepairAdapterRef);
611output_string!(SemanticValidatorRef);
612output_string!(TypeName);
613output_string!(CrateName);
614
615impl ContentHash {
616    fn validate_shape(&self) -> Result<(), AgentError> {
617        let Some(digest) = self.as_str().strip_prefix("sha256:") else {
618            return Err(AgentError::contract_violation(
619                "output schema content_hash must be sha256-prefixed",
620            ));
621        };
622        if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
623            return Err(AgentError::contract_violation(
624                "output schema content_hash must be a sha256 hex digest",
625            ));
626        }
627        Ok(())
628    }
629}
630
631fn canonical_content_hash(value: &Value) -> ContentHash {
632    let normalized = normalize_json_value(value.clone());
633    let bytes = serde_json::to_vec(&normalized).expect("serde_json::Value serializes");
634    ContentHash::new(format!("sha256:{:x}", Sha256::digest(bytes)))
635}
636
637fn normalize_json_value(value: Value) -> Value {
638    match value {
639        Value::Array(items) => Value::Array(items.into_iter().map(normalize_json_value).collect()),
640        Value::Object(fields) => {
641            let mut sorted = BTreeMap::new();
642            for (key, value) in fields {
643                sorted.insert(key, value);
644            }
645            let mut normalized = Map::new();
646            for (key, value) in sorted {
647                normalized.insert(key, normalize_json_value(value));
648            }
649            Value::Object(normalized)
650        }
651        scalar => scalar,
652    }
653}