Skip to main content

agent_sdk_core/application/
validation.rs

1//! Structured-output validation orchestration. Use this module after provider output
2//! is available and before typed results are published. Validation is local and
3//! deterministic unless a caller-provided validator performs additional work.
4//!
5use crate as sdk;
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use sdk::{
11    AgentError, AgentErrorKind, AttemptId, ContentHash, OutputContract, OutputMode,
12    OutputSchemaDialect, OutputSchemaId, OutputSchemaRef, PrivacyClass, RetryClassification,
13    SchemaVersion, ValidationAttemptId,
14    domain::ContentRef,
15    structured_output::{
16        VALIDATION_RECORD_SCHEMA_VERSION, ValidationErrorCode, ValidationErrorSummary,
17        ValidationRecord, ValidationRecordKind,
18    },
19};
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22/// Holds output candidate application-layer state or configuration.
23/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
24pub struct OutputCandidate {
25    /// Stable source attempt id used for typed lineage, lookup, or dedupe.
26    pub source_attempt_id: AttemptId,
27    /// Content reference for the candidate value being validated.
28    pub candidate_content_ref: ContentRef,
29    /// Text used by this record or request.
30    pub text: String,
31    /// Privacy class used for projection, telemetry, and raw-content access
32    /// decisions.
33    pub privacy: PrivacyClass,
34}
35
36impl OutputCandidate {
37    /// Creates a new application::validation value with explicit
38    /// caller-provided inputs. This constructor is data-only and
39    /// performs no I/O or external side effects.
40    pub fn new(
41        source_attempt_id: AttemptId,
42        candidate_content_ref: ContentRef,
43        text: impl Into<String>,
44    ) -> Self {
45        Self {
46            source_attempt_id,
47            candidate_content_ref,
48            text: text.into(),
49            privacy: PrivacyClass::ContentRefsOnly,
50        }
51    }
52
53    /// Returns this value with its privacy setting replaced. The method
54    /// follows builder-style data construction and does not execute
55    /// external work.
56    pub fn with_privacy(mut self, privacy: PrivacyClass) -> Self {
57        self.privacy = privacy;
58        self
59    }
60}
61
62/// Port or behavior contract for structured output validator.
63/// Implementors should preserve policy, redaction, idempotency, and
64/// replay expectations from the surrounding module. Implementations may
65/// perform side effects only as described by the trait methods.
66pub trait StructuredOutputValidator {
67    /// Validates the application::validation invariants and returns a
68    /// typed error on failure. Validation is pure and does not perform
69    /// I/O, dispatch, journal appends, or adapter calls.
70    fn validate_candidate(
71        &self,
72        contract: &OutputContract,
73        validation_attempt_id: ValidationAttemptId,
74        candidate: &OutputCandidate,
75    ) -> Result<ValidationSuccess, Box<ValidationErrorReport>>;
76}
77
78#[derive(Clone, Debug, Eq, PartialEq)]
79/// Holds json schema subset validator application-layer state or configuration.
80/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
81pub struct JsonSchemaSubsetValidator {
82    /// Schema limits used by this record or request.
83    pub schema_limits: HostileSchemaLimits,
84}
85
86impl JsonSchemaSubsetValidator {
87    /// Creates a new application::validation value with explicit
88    /// caller-provided inputs. This constructor is data-only and
89    /// performs no I/O or external side effects.
90    pub fn new(schema_limits: HostileSchemaLimits) -> Self {
91        Self { schema_limits }
92    }
93}
94
95impl Default for JsonSchemaSubsetValidator {
96    fn default() -> Self {
97        Self::new(HostileSchemaLimits::default())
98    }
99}
100
101impl StructuredOutputValidator for JsonSchemaSubsetValidator {
102    fn validate_candidate(
103        &self,
104        contract: &OutputContract,
105        validation_attempt_id: ValidationAttemptId,
106        candidate: &OutputCandidate,
107    ) -> Result<ValidationSuccess, Box<ValidationErrorReport>> {
108        let mut schema_errors = ErrorCollector::new(contract.validation.max_errors_returned);
109
110        if contract.dialect != OutputSchemaDialect::JsonSchema2020_12Subset {
111            schema_errors.push(ValidationErrorSummary::new(
112                ValidationErrorCode::UnsupportedDialect,
113                "/dialect",
114                "unsupported structured output schema dialect",
115            ));
116        }
117
118        if contract.mode != OutputMode::FinalOnly {
119            schema_errors.push(ValidationErrorSummary::new(
120                ValidationErrorCode::SchemaContractViolation,
121                "/mode",
122                "incremental structured output validation is not owned by this phase",
123            ));
124        }
125
126        if let Err(error) = contract.validate_shape() {
127            schema_errors.push(ValidationErrorSummary::new(
128                ValidationErrorCode::SchemaContractViolation,
129                "/",
130                error.context().message,
131            ));
132        }
133
134        let inline_schema = match &contract.schema {
135            OutputSchemaRef::InlineJson {
136                redacted_schema, ..
137            } => Some(redacted_schema),
138            _ => {
139                schema_errors.push(ValidationErrorSummary::new(
140                    ValidationErrorCode::UnsupportedSchemaRef,
141                    "/schema",
142                    "schema content must be inline for local validation in this phase",
143                ));
144                None
145            }
146        };
147
148        if let Some(schema) = inline_schema {
149            validate_schema_limits(schema, &self.schema_limits, &mut schema_errors);
150        }
151
152        if !contract.validation.semantic_validators.is_empty() {
153            schema_errors.push(ValidationErrorSummary::new(
154                ValidationErrorCode::SemanticValidatorUnavailable,
155                "/validation/semantic_validators",
156                "host semantic validators are referenced but no local validator was supplied",
157            ));
158        }
159
160        if !schema_errors.is_empty() {
161            let schema_rejected = schema_errors
162                .errors
163                .iter()
164                .any(|error| error.code.is_schema_rejection());
165            return Err(Box::new(ValidationErrorReport::new(
166                contract,
167                validation_attempt_id,
168                candidate,
169                schema_errors.into_errors(),
170                schema_rejected,
171            )));
172        }
173
174        if candidate.text.len() as u64 > contract.validation.max_candidate_bytes {
175            return Err(Box::new(ValidationErrorReport::new(
176                contract,
177                validation_attempt_id,
178                candidate,
179                vec![ValidationErrorSummary::new(
180                    ValidationErrorCode::CandidateTooLarge,
181                    "/",
182                    "candidate exceeds configured structured output byte limit",
183                )],
184                false,
185            )));
186        }
187
188        let value = match serde_json::from_str::<Value>(&candidate.text) {
189            Ok(value) => value,
190            Err(_) => {
191                return Err(Box::new(ValidationErrorReport::new(
192                    contract,
193                    validation_attempt_id,
194                    candidate,
195                    vec![ValidationErrorSummary::new(
196                        ValidationErrorCode::InvalidJson,
197                        "/",
198                        "candidate is not valid JSON",
199                    )],
200                    false,
201                )));
202            }
203        };
204
205        let schema = inline_schema.expect("inline schema checked above");
206        let mut candidate_errors = ErrorCollector::new(contract.validation.max_errors_returned);
207        validate_value_against_schema(
208            schema,
209            &value,
210            "/",
211            contract.validation.allow_additional_properties,
212            &mut candidate_errors,
213        );
214
215        if !candidate_errors.is_empty() {
216            return Err(Box::new(ValidationErrorReport::new(
217                contract,
218                validation_attempt_id,
219                candidate,
220                candidate_errors.into_errors(),
221                false,
222            )));
223        }
224
225        let record = validation_record_succeeded(
226            contract,
227            validation_attempt_id.clone(),
228            candidate,
229            "structured output candidate validated locally",
230        );
231
232        Ok(ValidationSuccess {
233            schema_id: contract.schema_id.clone(),
234            schema_version: contract.schema_version,
235            schema_fingerprint: contract.schema_fingerprint(),
236            validation_attempt_id,
237            source_attempt_id: candidate.source_attempt_id.clone(),
238            candidate_content_ref: candidate.candidate_content_ref.clone(),
239            canonical_value: value,
240            privacy: candidate.privacy,
241            record,
242        })
243    }
244}
245
246#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
247/// Holds hostile schema limits application-layer state or configuration.
248/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
249pub struct HostileSchemaLimits {
250    /// max schema bytes used for bounds checks, summaries, or truncation
251    /// evidence.
252    pub max_schema_bytes: usize,
253    /// Maximum allowed object depth.
254    /// Use it to keep execution, output, or diagnostics bounded.
255    pub max_object_depth: usize,
256    /// Maximum allowed properties per object.
257    /// Use it to keep execution, output, or diagnostics bounded.
258    pub max_properties_per_object: usize,
259    /// Maximum allowed enum values per field.
260    /// Use it to keep execution, output, or diagnostics bounded.
261    pub max_enum_values_per_field: usize,
262    /// max string pattern bytes used for bounds checks, summaries, or
263    /// truncation evidence.
264    pub max_string_pattern_bytes: usize,
265    /// Typed allow remote refs references. Resolving them is separate from
266    /// constructing this record.
267    pub allow_remote_refs: bool,
268    /// Boolean policy/capability flag for whether allow custom formats is
269    /// enabled.
270    pub allow_custom_formats: bool,
271}
272
273impl Default for HostileSchemaLimits {
274    fn default() -> Self {
275        Self {
276            max_schema_bytes: 64 * 1024,
277            max_object_depth: 24,
278            max_properties_per_object: 256,
279            max_enum_values_per_field: 512,
280            max_string_pattern_bytes: 2 * 1024,
281            allow_remote_refs: false,
282            allow_custom_formats: false,
283        }
284    }
285}
286
287#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
288/// Holds validation success application-layer state or configuration.
289/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
290pub struct ValidationSuccess {
291    /// Stable schema id used for typed lineage, lookup, or dedupe.
292    pub schema_id: OutputSchemaId,
293    /// Wire schema version used for compatibility checks.
294    pub schema_version: SchemaVersion,
295    /// Deterministic schema fingerprint used for stale checks, package
296    /// evidence, or replay comparisons.
297    pub schema_fingerprint: ContentHash,
298    /// Stable validation attempt id used for typed lineage, lookup, or
299    /// dedupe.
300    pub validation_attempt_id: ValidationAttemptId,
301    /// Stable source attempt id used for typed lineage, lookup, or dedupe.
302    pub source_attempt_id: AttemptId,
303    /// Content reference for the candidate value being validated.
304    pub candidate_content_ref: ContentRef,
305    /// Canonical value used by this record or request.
306    pub canonical_value: Value,
307    /// Privacy class used for projection, telemetry, and raw-content access
308    /// decisions.
309    pub privacy: PrivacyClass,
310    /// Record used by this record or request.
311    pub record: ValidationRecord,
312}
313
314#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
315/// Holds validation error report application-layer state or configuration.
316/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
317pub struct ValidationErrorReport {
318    /// Stable schema id used for typed lineage, lookup, or dedupe.
319    pub schema_id: OutputSchemaId,
320    /// Wire schema version used for compatibility checks.
321    pub schema_version: SchemaVersion,
322    /// Deterministic schema fingerprint used for stale checks, package
323    /// evidence, or replay comparisons.
324    pub schema_fingerprint: ContentHash,
325    /// Stable validation attempt id used for typed lineage, lookup, or
326    /// dedupe.
327    pub validation_attempt_id: ValidationAttemptId,
328    /// Stable source attempt id used for typed lineage, lookup, or dedupe.
329    pub source_attempt_id: AttemptId,
330    /// Content reference for the candidate value being validated.
331    pub candidate_content_ref: ContentRef,
332    /// Bounded errors included in this record. Limits and truncation are
333    /// represented by companion metadata when applicable.
334    pub errors: Vec<ValidationErrorSummary>,
335    /// Redacted summary for display, logs, events, or telemetry.
336    /// It should describe the value without exposing raw private content.
337    pub redacted_error_summary: String,
338    /// Whether schema rejected is enabled.
339    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
340    pub schema_rejected: bool,
341    /// Whether retry exhausted is enabled.
342    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
343    pub retry_exhausted: bool,
344    /// Privacy class used for projection, telemetry, and raw-content access
345    /// decisions.
346    pub privacy: PrivacyClass,
347    /// Record used by this record or request.
348    pub record: ValidationRecord,
349}
350
351impl ValidationErrorReport {
352    fn new(
353        contract: &OutputContract,
354        validation_attempt_id: ValidationAttemptId,
355        candidate: &OutputCandidate,
356        errors: Vec<ValidationErrorSummary>,
357        schema_rejected: bool,
358    ) -> Self {
359        let redacted_error_summary = summarize_errors(&errors);
360        let record = if schema_rejected {
361            validation_record_schema_rejected(
362                contract,
363                validation_attempt_id.clone(),
364                candidate,
365                redacted_error_summary.clone(),
366                errors.clone(),
367            )
368        } else {
369            validation_record_failed(
370                contract,
371                validation_attempt_id.clone(),
372                candidate,
373                redacted_error_summary.clone(),
374                errors.clone(),
375            )
376        };
377
378        Self {
379            schema_id: contract.schema_id.clone(),
380            schema_version: contract.schema_version,
381            schema_fingerprint: contract.schema_fingerprint(),
382            validation_attempt_id,
383            source_attempt_id: candidate.source_attempt_id.clone(),
384            candidate_content_ref: candidate.candidate_content_ref.clone(),
385            errors,
386            redacted_error_summary,
387            schema_rejected,
388            retry_exhausted: false,
389            privacy: candidate.privacy,
390            record,
391        }
392    }
393
394    /// Returns this value as agent error. The accessor is side-effect
395    /// free and keeps ownership with the caller.
396    pub fn as_agent_error(&self) -> AgentError {
397        let mut error = AgentError::new(
398            AgentErrorKind::StructuredOutputFailure,
399            if self.retry_exhausted || self.schema_rejected {
400                RetryClassification::NotRetryable
401            } else {
402                RetryClassification::RepairNeeded
403            },
404            self.redacted_error_summary.clone(),
405        );
406        error = error.with_policy_ref(sdk::PolicyRef::with_kind(
407            sdk::PolicyKind::RuntimePackage,
408            self.schema_id.as_str(),
409        ));
410        error
411    }
412}
413
414#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
415/// Holds terminal validation failure application-layer state or configuration.
416/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
417pub struct TerminalValidationFailure {
418    /// Stable schema id used for typed lineage, lookup, or dedupe.
419    pub schema_id: OutputSchemaId,
420    /// Wire schema version used for compatibility checks.
421    pub schema_version: SchemaVersion,
422    /// Validation policy applied before output is accepted as typed data.
423    /// It controls validator selection, bounds, failure visibility, and local validation
424    /// behavior.
425    pub validation_attempts: Vec<ValidationAttemptId>,
426    /// Attempt identifier or attempt history for bounded retry/repair.
427    /// Use it to preserve ordering and avoid retry loops that cannot be audited.
428    pub repair_attempts: Vec<sdk::RepairAttemptId>,
429    /// Attempt identifier or attempt history for bounded retry/repair.
430    /// Use it to preserve ordering and avoid retry loops that cannot be audited.
431    pub source_attempt_ids: Vec<AttemptId>,
432    /// Redacted summary for display, logs, events, or telemetry.
433    /// It should describe the value without exposing raw private content.
434    pub redacted_error_summary: String,
435    /// Content reference for the candidate value being validated.
436    pub candidate_content_ref: ContentRef,
437    /// Whether retry exhausted is enabled.
438    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
439    pub retry_exhausted: bool,
440    /// Privacy class used for projection, telemetry, and raw-content access
441    /// decisions.
442    pub privacy: PrivacyClass,
443    /// Record used by this record or request.
444    pub record: ValidationRecord,
445}
446
447impl TerminalValidationFailure {
448    /// Constructs this value from reports. Use it when adapting
449    /// canonical SDK records without introducing a second behavior
450    /// path.
451    pub fn from_reports(
452        reports: &[ValidationErrorReport],
453        repair_attempts: Vec<sdk::RepairAttemptId>,
454        retry_exhausted: bool,
455    ) -> Self {
456        let last = reports
457            .last()
458            .expect("terminal validation failure needs at least one report");
459        let validation_attempts = reports
460            .iter()
461            .map(|report| report.validation_attempt_id.clone())
462            .collect::<Vec<_>>();
463        let mut source_attempt_ids = Vec::new();
464        for report in reports {
465            if !source_attempt_ids.contains(&report.source_attempt_id) {
466                source_attempt_ids.push(report.source_attempt_id.clone());
467            }
468        }
469        let redacted_error_summary = if retry_exhausted {
470            format!(
471                "structured output validation failed after {} validation attempt(s) and {} repair attempt(s): {}",
472                validation_attempts.len(),
473                repair_attempts.len(),
474                last.redacted_error_summary
475            )
476        } else {
477            last.redacted_error_summary.clone()
478        };
479        let record = validation_record_terminal_failure(
480            last,
481            validation_attempts.clone(),
482            repair_attempts.clone(),
483            source_attempt_ids.clone(),
484            redacted_error_summary.clone(),
485            retry_exhausted,
486        );
487        Self {
488            schema_id: last.schema_id.clone(),
489            schema_version: last.schema_version,
490            validation_attempts,
491            repair_attempts,
492            source_attempt_ids,
493            redacted_error_summary,
494            candidate_content_ref: last.candidate_content_ref.clone(),
495            retry_exhausted,
496            privacy: last.privacy,
497            record,
498        }
499    }
500
501    /// Returns this value as agent error. The accessor is side-effect
502    /// free and keeps ownership with the caller.
503    pub fn as_agent_error(&self) -> AgentError {
504        AgentError::new(
505            AgentErrorKind::StructuredOutputFailure,
506            RetryClassification::NotRetryable,
507            self.redacted_error_summary.clone(),
508        )
509    }
510}
511
512fn validation_record_base(
513    contract: &OutputContract,
514    record_kind: ValidationRecordKind,
515    validation_attempt_id: ValidationAttemptId,
516    candidate: &OutputCandidate,
517    redacted_summary: String,
518) -> ValidationRecord {
519    ValidationRecord {
520        record_schema_version: VALIDATION_RECORD_SCHEMA_VERSION,
521        record_kind,
522        schema_id: contract.schema_id.clone(),
523        output_schema_version: contract.schema_version,
524        schema_fingerprint: contract.schema_fingerprint(),
525        validation_attempt_id,
526        source_attempt_id: candidate.source_attempt_id.clone(),
527        candidate_content_ref: candidate.candidate_content_ref.clone(),
528        privacy: candidate.privacy,
529        redacted_summary,
530        errors: Vec::new(),
531        validation_attempts: Vec::new(),
532        repair_attempts: Vec::new(),
533        source_attempt_ids: Vec::new(),
534        retry_exhausted: None,
535    }
536}
537
538fn validation_record_succeeded(
539    contract: &OutputContract,
540    validation_attempt_id: ValidationAttemptId,
541    candidate: &OutputCandidate,
542    redacted_summary: impl Into<String>,
543) -> ValidationRecord {
544    validation_record_base(
545        contract,
546        ValidationRecordKind::ValidationSucceeded,
547        validation_attempt_id,
548        candidate,
549        redacted_summary.into(),
550    )
551}
552
553fn validation_record_failed(
554    contract: &OutputContract,
555    validation_attempt_id: ValidationAttemptId,
556    candidate: &OutputCandidate,
557    redacted_summary: String,
558    errors: Vec<ValidationErrorSummary>,
559) -> ValidationRecord {
560    let mut record = validation_record_base(
561        contract,
562        ValidationRecordKind::ValidationFailed,
563        validation_attempt_id,
564        candidate,
565        redacted_summary,
566    );
567    record.errors = errors;
568    record
569}
570
571fn validation_record_schema_rejected(
572    contract: &OutputContract,
573    validation_attempt_id: ValidationAttemptId,
574    candidate: &OutputCandidate,
575    redacted_summary: String,
576    errors: Vec<ValidationErrorSummary>,
577) -> ValidationRecord {
578    let mut record = validation_record_base(
579        contract,
580        ValidationRecordKind::SchemaRejected,
581        validation_attempt_id,
582        candidate,
583        redacted_summary,
584    );
585    record.errors = errors;
586    record
587}
588
589fn validation_record_terminal_failure(
590    last_report: &ValidationErrorReport,
591    validation_attempts: Vec<ValidationAttemptId>,
592    repair_attempts: Vec<sdk::RepairAttemptId>,
593    source_attempt_ids: Vec<AttemptId>,
594    redacted_summary: String,
595    retry_exhausted: bool,
596) -> ValidationRecord {
597    ValidationRecord {
598        record_schema_version: VALIDATION_RECORD_SCHEMA_VERSION,
599        record_kind: ValidationRecordKind::TerminalFailure,
600        schema_id: last_report.schema_id.clone(),
601        output_schema_version: last_report.schema_version,
602        schema_fingerprint: last_report.schema_fingerprint.clone(),
603        validation_attempt_id: last_report.validation_attempt_id.clone(),
604        source_attempt_id: last_report.source_attempt_id.clone(),
605        candidate_content_ref: last_report.candidate_content_ref.clone(),
606        privacy: last_report.privacy,
607        redacted_summary,
608        errors: last_report.errors.clone(),
609        validation_attempts,
610        repair_attempts,
611        source_attempt_ids,
612        retry_exhausted: Some(retry_exhausted),
613    }
614}
615
616struct ErrorCollector {
617    max: usize,
618    errors: Vec<ValidationErrorSummary>,
619}
620
621impl ErrorCollector {
622    fn new(max_errors_returned: u16) -> Self {
623        Self {
624            max: usize::from(max_errors_returned.max(1)),
625            errors: Vec::new(),
626        }
627    }
628
629    fn push(&mut self, error: ValidationErrorSummary) {
630        if self.errors.len() < self.max {
631            self.errors.push(error);
632        }
633    }
634
635    fn is_empty(&self) -> bool {
636        self.errors.is_empty()
637    }
638
639    fn into_errors(self) -> Vec<ValidationErrorSummary> {
640        self.errors
641    }
642}
643
644fn summarize_errors(errors: &[ValidationErrorSummary]) -> String {
645    match errors {
646        [] => "structured output validation produced no errors".to_string(),
647        [error] => format!(
648            "structured output validation failed: {} at {}",
649            error.redacted_summary, error.path
650        ),
651        [first, rest @ ..] => format!(
652            "structured output validation failed with {} error(s): {} at {}",
653            rest.len() + 1,
654            first.redacted_summary,
655            first.path
656        ),
657    }
658}
659
660fn validate_schema_limits(
661    schema: &Value,
662    limits: &HostileSchemaLimits,
663    errors: &mut ErrorCollector,
664) {
665    let schema_bytes = serde_json::to_vec(schema).expect("serde_json::Value serializes");
666    if schema_bytes.len() > limits.max_schema_bytes {
667        errors.push(ValidationErrorSummary::new(
668            ValidationErrorCode::HostileSchema,
669            "/schema",
670            "schema exceeds maximum byte limit",
671        ));
672    }
673    inspect_schema_node(schema, "/", 0, limits, errors);
674}
675
676fn inspect_schema_node(
677    value: &Value,
678    path: &str,
679    depth: usize,
680    limits: &HostileSchemaLimits,
681    errors: &mut ErrorCollector,
682) {
683    if depth > limits.max_object_depth {
684        errors.push(ValidationErrorSummary::new(
685            ValidationErrorCode::HostileSchema,
686            path,
687            "schema exceeds maximum object depth",
688        ));
689        return;
690    }
691
692    match value {
693        Value::Object(fields) => {
694            if let Some(Value::String(reference)) = fields.get("$ref") {
695                let is_remote = reference.starts_with("http://")
696                    || reference.starts_with("https://")
697                    || !reference.starts_with('#');
698                if is_remote && !limits.allow_remote_refs {
699                    errors.push(ValidationErrorSummary::new(
700                        ValidationErrorCode::HostileSchema,
701                        join_path(path, "$ref"),
702                        "remote or external schema references are denied",
703                    ));
704                } else {
705                    errors.push(ValidationErrorSummary::new(
706                        ValidationErrorCode::SchemaContractViolation,
707                        join_path(path, "$ref"),
708                        "local schema references are not supported by this validator phase",
709                    ));
710                }
711            }
712
713            if fields.contains_key("format") && !limits.allow_custom_formats {
714                errors.push(ValidationErrorSummary::new(
715                    ValidationErrorCode::HostileSchema,
716                    join_path(path, "format"),
717                    "custom format validators are disabled by default",
718                ));
719            }
720
721            if let Some(Value::String(pattern)) = fields.get("pattern") {
722                if pattern.len() > limits.max_string_pattern_bytes {
723                    errors.push(ValidationErrorSummary::new(
724                        ValidationErrorCode::HostileSchema,
725                        join_path(path, "pattern"),
726                        "schema string pattern exceeds maximum byte limit",
727                    ));
728                }
729            }
730
731            if let Some(Value::Object(properties)) = fields.get("properties") {
732                if properties.len() > limits.max_properties_per_object {
733                    errors.push(ValidationErrorSummary::new(
734                        ValidationErrorCode::HostileSchema,
735                        join_path(path, "properties"),
736                        "schema object has too many properties",
737                    ));
738                }
739            }
740
741            if let Some(Value::Array(values)) = fields.get("enum") {
742                if values.len() > limits.max_enum_values_per_field {
743                    errors.push(ValidationErrorSummary::new(
744                        ValidationErrorCode::HostileSchema,
745                        join_path(path, "enum"),
746                        "schema enum has too many values",
747                    ));
748                }
749            }
750
751            for (field, child) in fields {
752                inspect_schema_node(child, &join_path(path, field), depth + 1, limits, errors);
753            }
754        }
755        Value::Array(items) => {
756            for (index, child) in items.iter().enumerate() {
757                inspect_schema_node(
758                    child,
759                    &join_path(path, &index.to_string()),
760                    depth + 1,
761                    limits,
762                    errors,
763                );
764            }
765        }
766        _ => {}
767    }
768}
769
770fn validate_value_against_schema(
771    schema: &Value,
772    value: &Value,
773    path: &str,
774    allow_additional_properties: bool,
775    errors: &mut ErrorCollector,
776) {
777    if let Some(enum_values) = schema.get("enum").and_then(Value::as_array) {
778        if !enum_values.iter().any(|enum_value| enum_value == value) {
779            errors.push(ValidationErrorSummary::new(
780                ValidationErrorCode::EnumMismatch,
781                path,
782                "candidate value is not one of the allowed schema values",
783            ));
784            return;
785        }
786    }
787
788    let schema_type = schema.get("type").and_then(Value::as_str);
789    match schema_type {
790        Some("object") => {
791            validate_object_schema(schema, value, path, allow_additional_properties, errors)
792        }
793        Some("array") => {
794            validate_array_schema(schema, value, path, allow_additional_properties, errors)
795        }
796        Some("string") => validate_string_schema(schema, value, path, errors),
797        Some("integer") => {
798            if value.as_i64().is_none() && value.as_u64().is_none() {
799                errors.push(type_mismatch(path, "integer"));
800            }
801        }
802        Some("number") => {
803            if !value.is_number() {
804                errors.push(type_mismatch(path, "number"));
805            }
806        }
807        Some("boolean") => {
808            if !value.is_boolean() {
809                errors.push(type_mismatch(path, "boolean"));
810            }
811        }
812        Some("null") => {
813            if !value.is_null() {
814                errors.push(type_mismatch(path, "null"));
815            }
816        }
817        Some(_) => errors.push(ValidationErrorSummary::new(
818            ValidationErrorCode::SchemaContractViolation,
819            path,
820            "schema type is outside the supported local subset",
821        )),
822        None => {
823            if schema.get("properties").is_some() || schema.get("required").is_some() {
824                validate_object_schema(schema, value, path, allow_additional_properties, errors);
825            }
826        }
827    }
828}
829
830fn validate_object_schema(
831    schema: &Value,
832    value: &Value,
833    path: &str,
834    allow_additional_properties: bool,
835    errors: &mut ErrorCollector,
836) {
837    let Some(object) = value.as_object() else {
838        errors.push(type_mismatch(path, "object"));
839        return;
840    };
841
842    let properties = schema.get("properties").and_then(Value::as_object);
843
844    if let Some(required) = schema.get("required").and_then(Value::as_array) {
845        for required_field in required.iter().filter_map(Value::as_str) {
846            if !object.contains_key(required_field) {
847                errors.push(ValidationErrorSummary::new(
848                    ValidationErrorCode::MissingRequiredField,
849                    join_path(path, required_field),
850                    "required schema field is missing",
851                ));
852            }
853        }
854    }
855
856    if let Some(properties) = properties {
857        for (field, field_schema) in properties {
858            if let Some(field_value) = object.get(field) {
859                validate_value_against_schema(
860                    field_schema,
861                    field_value,
862                    &join_path(path, field),
863                    allow_additional_properties,
864                    errors,
865                );
866            }
867        }
868    }
869
870    let schema_denies_additional = schema
871        .get("additionalProperties")
872        .and_then(Value::as_bool)
873        .is_some_and(|allowed| !allowed);
874    let denies_additional = schema_denies_additional || !allow_additional_properties;
875    if denies_additional {
876        for field in object.keys() {
877            let known = properties.is_some_and(|properties| properties.contains_key(field));
878            if !known {
879                errors.push(ValidationErrorSummary::new(
880                    ValidationErrorCode::AdditionalPropertyDenied,
881                    path,
882                    "candidate contains an additional property denied by schema",
883                ));
884            }
885        }
886    }
887}
888
889fn validate_array_schema(
890    schema: &Value,
891    value: &Value,
892    path: &str,
893    allow_additional_properties: bool,
894    errors: &mut ErrorCollector,
895) {
896    let Some(items) = value.as_array() else {
897        errors.push(type_mismatch(path, "array"));
898        return;
899    };
900    if let Some(item_schema) = schema.get("items").filter(|item| item.is_object()) {
901        for (index, item) in items.iter().enumerate() {
902            validate_value_against_schema(
903                item_schema,
904                item,
905                &join_path(path, &index.to_string()),
906                allow_additional_properties,
907                errors,
908            );
909        }
910    }
911}
912
913fn validate_string_schema(schema: &Value, value: &Value, path: &str, errors: &mut ErrorCollector) {
914    let Some(text) = value.as_str() else {
915        errors.push(type_mismatch(path, "string"));
916        return;
917    };
918
919    if let Some(min) = schema.get("minLength").and_then(Value::as_u64) {
920        if text.chars().count() < min as usize {
921            errors.push(ValidationErrorSummary::new(
922                ValidationErrorCode::MinLengthViolation,
923                path,
924                "candidate string is shorter than the schema minimum",
925            ));
926        }
927    }
928    if let Some(max) = schema.get("maxLength").and_then(Value::as_u64) {
929        if text.chars().count() > max as usize {
930            errors.push(ValidationErrorSummary::new(
931                ValidationErrorCode::MaxLengthViolation,
932                path,
933                "candidate string is longer than the schema maximum",
934            ));
935        }
936    }
937}
938
939fn type_mismatch(path: &str, expected: &str) -> ValidationErrorSummary {
940    ValidationErrorSummary::new(
941        ValidationErrorCode::TypeMismatch,
942        path,
943        format!("candidate value does not match schema type {expected}"),
944    )
945}
946
947fn join_path(parent: &str, child: &str) -> String {
948    if parent == "/" {
949        format!("/{child}")
950    } else {
951        format!("{parent}/{child}")
952    }
953}