diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 8e3542d..4657669 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -137,9 +137,11 @@ pub fn run_cli(cli: Cli) -> Result<()> { format, } => commands::tools::run_variants(&id, models_dir.as_deref(), &format)?, }, - Some(Commands::Capabilities { format, transforms }) => { - commands::tools::run_capabilities(transforms.as_deref(), &format)? - } + Some(Commands::Capabilities { + format, + transforms, + matrix, + }) => commands::tools::run_capabilities(transforms.as_deref(), &format, matrix)?, Some(Commands::Spec { subcommand }) => match subcommand { SpecCommands::Validate { config, format } => { commands::spec::run_validate(&config, &format)? diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index aea6b48..0b45df0 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -217,6 +217,9 @@ pub enum Commands { /// Optional transform YAML whose dynamic providers should be included. #[arg(long, value_name = "FILE")] transforms: Option, + /// Emit the generated artifact capability conformance matrix. + #[arg(long)] + matrix: bool, }, /// Validate, migrate, and export the Renderflow execution specification. diff --git a/crates/renderflow-core/src/commands/tools.rs b/crates/renderflow-core/src/commands/tools.rs index c76701f..89eba9e 100644 --- a/crates/renderflow-core/src/commands/tools.rs +++ b/crates/renderflow-core/src/commands/tools.rs @@ -6,6 +6,7 @@ use serde::Serialize; use crate::super_resolution::{UpscaylModelCatalog, UPSCAYL_TOOL_ID}; use crate::toolchain::{ToolAvailability, ToolDescriptor, ToolRegistry}; use crate::transforms::yaml_loader::load_tool_registry_from_yaml; +use crate::validation::CapabilityConformanceMatrix; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum StructuredFormat { @@ -184,10 +185,40 @@ pub fn run_variants(id: &str, models_dir: Option<&str>, format: &str) -> Result< Ok(()) } -pub fn run_capabilities(transforms: Option<&str>, format: &str) -> Result<()> { +pub fn run_capabilities(transforms: Option<&str>, format: &str, matrix: bool) -> Result<()> { + let format = StructuredFormat::parse(format)?; + if matrix { + let matrix = CapabilityConformanceMatrix::builtins(); + if format != StructuredFormat::Text { + return emit_serialized(&matrix, format); + } + println!("Renderflow Artifact Capability Conformance"); + println!("=========================================="); + println!( + "{:<12} {:<14} {:<9} Validators", + "Format", "Status", "Executor" + ); + for row in matrix.formats { + let status = serde_json::to_value(row.support_status)? + .as_str() + .unwrap_or("unknown") + .to_string(); + println!( + "{:<12} {:<14} {:<9} {}", + row.format, + status, + if row.executor_implemented { + "yes" + } else { + "no" + }, + row.validator_ids.join(", ") + ); + } + return Ok(()); + } let registry = load_registry(transforms)?; let capabilities: BTreeMap> = registry.capabilities(); - let format = StructuredFormat::parse(format)?; if format != StructuredFormat::Text { return emit_serialized(&capabilities, format); diff --git a/crates/renderflow-core/src/evidence.rs b/crates/renderflow-core/src/evidence.rs index c753042..8069371 100644 --- a/crates/renderflow-core/src/evidence.rs +++ b/crates/renderflow-core/src/evidence.rs @@ -52,6 +52,24 @@ pub enum ValidationState { NotRequested, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ValidationDiagnostic { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ValidatorEvidence { + pub validator_id: String, + pub validator_version: String, + pub provider: String, + pub state: ValidationState, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum CacheDisposition { @@ -139,6 +157,8 @@ pub struct ArtifactEvidence { pub sources: Vec, pub cache: CacheDisposition, pub validation: ValidationState, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub validation_evidence: Vec, pub fidelity: FidelityDeclaration, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, @@ -179,6 +199,7 @@ impl ArtifactEvidence { sources: artifact.sources().iter().map(ToString::to_string).collect(), cache, validation, + validation_evidence: Vec::new(), fidelity, warnings: Vec::new(), metadata: artifact @@ -529,6 +550,7 @@ mod tests { sources: vec!["artifact:sha256:SOURCE123".to_string()], cache: CacheDisposition::Miss, validation: ValidationState::Valid, + validation_evidence: Vec::new(), fidelity: FidelityDeclaration::Lossless, warnings: Vec::new(), metadata: BTreeMap::new(), diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index ec639dc..5b19e96 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -3,6 +3,8 @@ //! Exposes the core subsystems for use by benchmarks, tests, and external //! integrations. The binary entrypoint lives in `main.rs`. +#![recursion_limit = "256"] + mod adapters; pub mod ai; pub mod app; @@ -30,6 +32,7 @@ pub mod strategies; pub mod super_resolution; pub mod toolchain; pub mod transforms; +pub mod validation; pub use evidence::{ArtifactManifest, RunManifest}; pub use sdk::{ diff --git a/crates/renderflow-core/src/planning.rs b/crates/renderflow-core/src/planning.rs index 360c6d2..45ee82c 100644 --- a/crates/renderflow-core/src/planning.rs +++ b/crates/renderflow-core/src/planning.rs @@ -29,8 +29,8 @@ use crate::graph::{ }; use crate::optimization::OptimizationMode; use crate::spec::{ - load_spec, AiPolicy, CollisionPolicy, SelectorSet, SourceKind, SourceSpec, SourceSpecVersion, - SpecV2, TargetSelection, TargetSpec, + load_spec, AiPolicy, CollisionPolicy, RejectedLossClass, SelectorSet, SourceKind, SourceSpec, + SourceSpecVersion, SpecV2, TargetSelection, TargetSpec, ValidationFailureMode, }; use crate::super_resolution::{select_upscayl_variants, UpscaylModelCatalog}; use crate::toolchain::{ @@ -38,6 +38,7 @@ use crate::toolchain::{ ToolRuntimeContext, ToolchainSnapshot, }; use crate::transforms::yaml_loader::build_graph_executor_and_tools_from_yaml; +use crate::validation::{ArtifactValidationOutcome, ValidationRegistry}; const BUILTIN_ADAPTER_EVIDENCE: &str = "builtin.strategy"; @@ -484,8 +485,11 @@ pub fn execute(mut resolved: ResolvedExecution, dry_run: bool) -> Result::new(); + let mut validation_outcomes = HashMap::::new(); + let mut blocked_artifacts = HashSet::::new(); let mut actual_outputs = Vec::new(); let mut target_failures = false; + let mut fatal_validation_failure = false; if let Err(error) = validate_post_execution_budgets(&resolved, &report.artifacts) { target_failures = true; @@ -497,7 +501,8 @@ pub fn execute(mut resolved: ResolvedExecution, dry_run: bool) -> Result Result DiagnosticSeverity::FatalFailure, + ValidationFailureMode::BranchLocal => { + DiagnosticSeverity::RecoverableFailure + } + } + } else { + DiagnosticSeverity::Warning + }, + code: validator_diagnostic.code.clone(), + message: format!( + "{} (validator {}@{}, provider {})", + validator_diagnostic.message, + validator.validator_id, + validator.validator_version, + validator.provider + ), + step_id: step_id.clone(), + }); + } + } + let validation_blocked = outcome.state == ValidationState::Invalid + || (outcome.state == ValidationState::Unavailable + && !resolved.spec.execution.validation.allow_unavailable); + let fidelity = producing_fidelity(artifact, &report.steps); + let fidelity_blocked = resolved + .spec + .execution + .reject_loss_classes + .iter() + .any(|class| rejects_fidelity(*class, fidelity)); + if fidelity_blocked { diagnostics.push(ExecutionDiagnostic { - severity: DiagnosticSeverity::FatalFailure, - code: "validation.empty_artifact".to_string(), - message: format!("Target '{}' produced an empty artifact", target.format), - step_id: producing_step_id(artifact, &report.steps), + severity: match resolved.spec.execution.validation.failure_mode { + ValidationFailureMode::Fatal => DiagnosticSeverity::FatalFailure, + ValidationFailureMode::BranchLocal => DiagnosticSeverity::RecoverableFailure, + }, + code: "fidelity.rejected_loss_class".to_string(), + message: format!( + "Target '{}' has rejected fidelity class '{:?}'", + target.format, fidelity + ) + .to_lowercase(), + step_id, }); + } + if validation_blocked || fidelity_blocked { + target_failures = true; + blocked_artifacts.insert(artifact.id().to_string()); + fatal_validation_failure |= + resolved.spec.execution.validation.failure_mode == ValidationFailureMode::Fatal; + } + validation_outcomes.insert(artifact.id().to_string(), outcome); + } + + for (target, destination) in resolved.targets.iter().zip(predicted.iter()) { + let Some(artifact) = report.artifacts.get(&target.format) else { + continue; + }; + if fatal_validation_failure || blocked_artifacts.contains(artifact.id().as_str()) { continue; } if target_failures @@ -549,6 +635,7 @@ pub fn execute(mut resolved: ResolvedExecution, dry_run: bool) -> Result, diagnostics: &[ExecutionDiagnostic], + validation_outcomes: &HashMap, ) -> Vec { let mut evidence = vec![source_artifact_evidence(resolved, source)]; let mut artifacts = report.artifacts.iter().collect::>(); @@ -842,19 +930,10 @@ fn artifact_evidence( .iter() .any(|artifact_id| artifact_id == artifact.id().as_str()) }); - let invalid = producing_step.is_some_and(|step| { - diagnostics.iter().any(|diagnostic| { - diagnostic.code.starts_with("validation.") - && diagnostic.step_id.as_deref() == Some(step.step_id.as_str()) - }) - }); - let validation = if invalid { - ValidationState::Invalid - } else if resolved.spec.execution.validation.required && target.is_some() { - ValidationState::Valid - } else { - ValidationState::NotRequested - }; + let outcome = validation_outcomes.get(artifact.id().as_str()); + let validation = outcome + .map(|outcome| outcome.state) + .unwrap_or(ValidationState::NotRequested); let producer = producing_step .map(|step| ProducerEvidence { system: "renderflow".to_string(), @@ -874,6 +953,9 @@ fn artifact_evidence( let mut artifact_evidence = ArtifactEvidence::from_artifact( artifact, role, lifecycle, locator, producer, validation, fidelity, ); + artifact_evidence.validation_evidence = outcome + .map(|outcome| outcome.validators.clone()) + .unwrap_or_default(); if let Some(step) = producing_step { artifact_evidence.warnings = diagnostics .iter() @@ -916,6 +998,32 @@ fn producing_step_id(artifact: &Artifact, steps: &[StepEvidence]) -> Option FidelityDeclaration { + steps + .iter() + .find(|step| { + step.output_artifacts + .iter() + .any(|artifact_id| artifact_id == artifact.id().as_str()) + }) + .map(|step| step.fidelity) + .unwrap_or(FidelityDeclaration::Unknown) +} + +fn rejects_fidelity(class: RejectedLossClass, fidelity: FidelityDeclaration) -> bool { + matches!( + (class, fidelity), + (RejectedLossClass::Lossless, FidelityDeclaration::Lossless) + | (RejectedLossClass::Partial, FidelityDeclaration::Partial) + | (RejectedLossClass::Lossy, FidelityDeclaration::Lossy) + | ( + RejectedLossClass::PathDependent, + FidelityDeclaration::PathDependent + ) + | (RejectedLossClass::Unknown, FidelityDeclaration::Unknown) + ) +} + fn enrich_step_versions(steps: &mut [StepEvidence], toolchain: Option<&ToolchainSnapshot>) { for step in steps { let version = step.provider.as_deref().and_then(|provider| { diff --git a/crates/renderflow-core/src/spec.rs b/crates/renderflow-core/src/spec.rs index bcf5452..83fc459 100644 --- a/crates/renderflow-core/src/spec.rs +++ b/crates/renderflow-core/src/spec.rs @@ -208,6 +208,24 @@ pub enum AiPolicy { Allow, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ValidationFailureMode { + Fatal, + #[default] + BranchLocal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RejectedLossClass { + Lossless, + Partial, + Lossy, + PathDependent, + Unknown, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ValidationPolicy { @@ -215,6 +233,10 @@ pub struct ValidationPolicy { pub required: bool, #[serde(default)] pub validators: Vec, + #[serde(default)] + pub failure_mode: ValidationFailureMode, + #[serde(default)] + pub allow_unavailable: bool, } impl Default for ValidationPolicy { @@ -222,6 +244,8 @@ impl Default for ValidationPolicy { Self { required: true, validators: Vec::new(), + failure_mode: ValidationFailureMode::default(), + allow_unavailable: false, } } } @@ -254,6 +278,8 @@ pub struct ExecutionPolicy { #[serde(default)] pub minimum_fidelity: Option, #[serde(default)] + pub reject_loss_classes: Vec, + #[serde(default)] pub publication_policy: Option, #[serde(default)] pub redaction_policy: Option, @@ -274,6 +300,7 @@ impl Default for ExecutionPolicy { timeout_policy: None, validation: ValidationPolicy::default(), minimum_fidelity: None, + reject_loss_classes: Vec::new(), publication_policy: None, redaction_policy: None, } @@ -1079,7 +1106,9 @@ pub fn json_schema() -> Value { "additionalProperties": false, "properties": { "required": {"type": "boolean", "default": true}, - "validators": {"type": "array", "items": {"type": "string"}, "default": []} + "validators": {"type": "array", "items": {"type": "string"}, "default": []}, + "failure_mode": {"enum": ["fatal", "branch_local"], "default": "branch_local"}, + "allow_unavailable": {"type": "boolean", "default": false} } }, "executionPolicy": { @@ -1098,6 +1127,12 @@ pub fn json_schema() -> Value { "timeout_policy": {"type": ["string", "null"]}, "validation": {"$ref": "#/$defs/validation"}, "minimum_fidelity": {"type": ["number", "null"], "minimum": 0.0, "maximum": 1.0}, + "reject_loss_classes": { + "type": "array", + "items": {"enum": ["lossless", "partial", "lossy", "path_dependent", "unknown"]}, + "uniqueItems": true, + "default": [] + }, "publication_policy": {"type": ["string", "null"]}, "redaction_policy": {"type": ["string", "null"]} } diff --git a/crates/renderflow-core/src/validation.rs b/crates/renderflow-core/src/validation.rs new file mode 100644 index 0000000..539bae3 --- /dev/null +++ b/crates/renderflow-core/src/validation.rs @@ -0,0 +1,825 @@ +//! Artifact validation, fidelity policy, and capability conformance evidence. + +use std::collections::HashMap; +use std::io::Read; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::artifact::{Artifact, ArtifactStore}; +use crate::evidence::{ + redact_sensitive_text, ValidationDiagnostic, ValidationState, ValidatorEvidence, +}; +use crate::graph::capability::{ArtifactCapability, FormatCapabilityRegistry, FormatDescriptor}; +use crate::graph::Format; + +pub const CONFORMANCE_MATRIX_SCHEMA_V1: &str = "renderflow.conformance/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ValidatorSupportTier { + Production, + Experimental, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ValidatorDescriptor { + pub id: String, + pub version: String, + pub provider: String, + pub formats: Vec, + pub support_tier: ValidatorSupportTier, +} + +impl ValidatorDescriptor { + pub fn supports(&self, format: Format) -> bool { + self.formats.is_empty() + || self + .formats + .iter() + .any(|candidate| candidate == &format.to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationCheck { + pub state: ValidationState, + pub diagnostics: Vec, +} + +impl ValidationCheck { + pub fn valid() -> Self { + Self { + state: ValidationState::Valid, + diagnostics: Vec::new(), + } + } + + pub fn warning(code: impl Into, message: impl Into) -> Self { + Self { + state: ValidationState::ValidWithWarnings, + diagnostics: vec![ValidationDiagnostic { + code: code.into(), + message: message.into(), + }], + } + } + + pub fn invalid(code: impl Into, message: impl Into) -> Self { + Self { + state: ValidationState::Invalid, + diagnostics: vec![ValidationDiagnostic { + code: code.into(), + message: message.into(), + }], + } + } + + pub fn unavailable(code: impl Into, message: impl Into) -> Self { + Self { + state: ValidationState::Unavailable, + diagnostics: vec![ValidationDiagnostic { + code: code.into(), + message: message.into(), + }], + } + } +} + +pub trait ArtifactValidator: Send + Sync { + fn descriptor(&self) -> ValidatorDescriptor; + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtifactValidationOutcome { + pub state: ValidationState, + pub validators: Vec, +} + +#[derive(Default)] +pub struct ValidationRegistry { + validators: HashMap>, +} + +impl ValidationRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn builtins() -> Self { + let mut registry = Self::new(); + registry.register(Arc::new(NonEmptyValidator)); + registry.register(Arc::new(Utf8Validator)); + registry.register(Arc::new(JsonValidator)); + registry.register(Arc::new(YamlValidator)); + registry.register(Arc::new(MarkupValidator)); + registry.register(Arc::new(DelimitedTextValidator)); + registry.register(Arc::new(PdfValidator)); + registry.register(Arc::new(PngValidator)); + registry.register(Arc::new(JpegValidator)); + registry.register(Arc::new(ZipValidator)); + registry.register(Arc::new(RiffWaveValidator)); + registry.register(Arc::new(DeclaredSignatureValidator)); + registry + } + + pub fn register(&mut self, validator: Arc) -> &mut Self { + let id = validator.descriptor().id; + self.validators.insert(id, validator); + self + } + + pub fn get(&self, id: &str) -> Option> { + self.validators.get(id).cloned() + } + + pub fn descriptors(&self) -> Vec { + let mut descriptors = self + .validators + .values() + .map(|validator| validator.descriptor()) + .collect::>(); + descriptors.sort_by(|left, right| left.id.cmp(&right.id)); + descriptors + } + + pub fn validator_ids_for(&self, format: Format) -> Vec { + let mut ids = self + .validators + .values() + .filter_map(|validator| { + let descriptor = validator.descriptor(); + descriptor.supports(format).then_some(descriptor.id) + }) + .collect::>(); + ids.sort(); + ids + } + + pub fn validate_in_store( + &self, + artifact: &Artifact, + format: Format, + requested: &[String], + store: &ArtifactStore, + ) -> ArtifactValidationOutcome { + let selected = if requested.is_empty() { + self.default_validator_ids(format) + } else { + requested.to_vec() + }; + if selected.is_empty() { + return unavailable_outcome(format); + } + let mut evidence = Vec::new(); + for id in selected { + let Some(validator) = self.get(&id) else { + evidence.push(unavailable_validator( + id.clone(), + format!("Requested validator '{id}' is not registered"), + )); + continue; + }; + let descriptor = validator.descriptor(); + if !descriptor.supports(format) { + evidence.push(ValidatorEvidence { + validator_id: descriptor.id, + validator_version: descriptor.version, + provider: descriptor.provider, + state: ValidationState::Unavailable, + diagnostics: vec![ValidationDiagnostic { + code: "validation.format_unsupported".to_string(), + message: format!("Validator '{id}' does not support '{format}'"), + }], + }); + continue; + } + let check = validator.validate(artifact, store); + evidence.push(match check { + Ok(check) => ValidatorEvidence { + validator_id: descriptor.id, + validator_version: descriptor.version, + provider: descriptor.provider, + state: check.state, + diagnostics: check + .diagnostics + .into_iter() + .map(|diagnostic| ValidationDiagnostic { + code: diagnostic.code, + message: redact_sensitive_text(&diagnostic.message), + }) + .collect(), + }, + Err(error) => ValidatorEvidence { + validator_id: descriptor.id, + validator_version: descriptor.version, + provider: descriptor.provider, + state: ValidationState::Unavailable, + diagnostics: vec![ValidationDiagnostic { + code: "validation.validator_error".to_string(), + message: redact_sensitive_text(&error.to_string()), + }], + }, + }); + } + ArtifactValidationOutcome { + state: aggregate_validation_state(&evidence), + validators: evidence, + } + } + + fn default_validator_ids(&self, format: Format) -> Vec { + let mut ids = vec!["validator.core.non_empty".to_string()]; + let specific = match format { + Format::Json => Some("validator.core.json"), + Format::Yaml => Some("validator.core.yaml"), + Format::Html | Format::Xml | Format::Svg => Some("validator.core.markup"), + Format::Csv | Format::Tsv => Some("validator.core.delimited_text"), + Format::Pdf => Some("validator.core.pdf"), + Format::Png => Some("validator.core.png"), + Format::Jpeg => Some("validator.core.jpeg"), + Format::Zip | Format::Docx | Format::Epub | Format::Cbz => Some("validator.core.zip"), + Format::Wav | Format::Bwf => Some("validator.core.riff_wave"), + Format::Markdown + | Format::Rst + | Format::Latex + | Format::Fountain + | Format::Toml + | Format::Srt + | Format::WebVtt => Some("validator.core.utf8"), + _ => Some("validator.core.declared_signature"), + }; + if let Some(specific) = specific { + ids.push(specific.to_string()); + } + ids + } +} + +fn aggregate_validation_state(evidence: &[ValidatorEvidence]) -> ValidationState { + if evidence + .iter() + .any(|validator| validator.state == ValidationState::Invalid) + { + ValidationState::Invalid + } else if evidence + .iter() + .any(|validator| validator.state == ValidationState::Unavailable) + { + ValidationState::Unavailable + } else if evidence + .iter() + .any(|validator| validator.state == ValidationState::ValidWithWarnings) + { + ValidationState::ValidWithWarnings + } else { + ValidationState::Valid + } +} + +fn unavailable_outcome(format: Format) -> ArtifactValidationOutcome { + ArtifactValidationOutcome { + state: ValidationState::Unavailable, + validators: vec![unavailable_validator( + "none".to_string(), + format!("No validator is registered for format '{format}'"), + )], + } +} + +fn unavailable_validator(id: String, message: String) -> ValidatorEvidence { + ValidatorEvidence { + validator_id: id, + validator_version: "unknown".to_string(), + provider: "unavailable".to_string(), + state: ValidationState::Unavailable, + diagnostics: vec![ValidationDiagnostic { + code: "validation.validator_unavailable".to_string(), + message, + }], + } +} + +fn descriptor( + id: &str, + formats: &[Format], + support_tier: ValidatorSupportTier, +) -> ValidatorDescriptor { + ValidatorDescriptor { + id: id.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + provider: "renderflow.native".to_string(), + formats: formats.iter().map(ToString::to_string).collect(), + support_tier, + } +} + +struct NonEmptyValidator; + +impl ArtifactValidator for NonEmptyValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.non_empty", + &[], + ValidatorSupportTier::Production, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + store.verify(artifact)?; + if artifact.size_bytes() == 0 { + Ok(ValidationCheck::invalid( + "validation.empty_artifact", + "Artifact payload is empty", + )) + } else { + Ok(ValidationCheck::valid()) + } + } +} + +const UTF8_FORMATS: &[Format] = &[ + Format::Markdown, + Format::Html, + Format::Rst, + Format::Latex, + Format::Fountain, + Format::Json, + Format::Yaml, + Format::Toml, + Format::Csv, + Format::Tsv, + Format::Xml, + Format::Svg, + Format::Srt, + Format::WebVtt, +]; + +struct Utf8Validator; + +impl ArtifactValidator for Utf8Validator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.utf8", + UTF8_FORMATS, + ValidatorSupportTier::Production, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + match String::from_utf8(store.read_bytes(artifact)?) { + Ok(_) => Ok(ValidationCheck::valid()), + Err(_) => Ok(ValidationCheck::invalid( + "validation.invalid_utf8", + "Text artifact is not valid UTF-8", + )), + } + } +} + +struct JsonValidator; + +impl ArtifactValidator for JsonValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.json", + &[Format::Json], + ValidatorSupportTier::Production, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + let bytes = store.read_bytes(artifact)?; + match serde_json::from_slice::(&bytes) { + Ok(_) => Ok(ValidationCheck::valid()), + Err(error) => Ok(ValidationCheck::invalid( + "validation.invalid_json", + format!("JSON parse failed: {error}"), + )), + } + } +} + +struct YamlValidator; + +impl ArtifactValidator for YamlValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.yaml", + &[Format::Yaml], + ValidatorSupportTier::Production, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + let text = store.read_text(artifact)?; + match serde_yaml_ng::from_str::(&text) { + Ok(_) => Ok(ValidationCheck::valid()), + Err(error) => Ok(ValidationCheck::invalid( + "validation.invalid_yaml", + format!("YAML parse failed: {error}"), + )), + } + } +} + +struct MarkupValidator; + +impl ArtifactValidator for MarkupValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.markup", + &[Format::Html, Format::Xml, Format::Svg], + ValidatorSupportTier::Experimental, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + let text = store.read_text(artifact)?; + let trimmed = text.trim(); + if !trimmed.starts_with('<') || !trimmed.contains('>') { + return Ok(ValidationCheck::invalid( + "validation.invalid_markup", + "Markup artifact has no recognizable element", + )); + } + Ok(ValidationCheck::warning( + "validation.structural_probe_only", + "Markup passed the native structural probe; full schema validation was not requested", + )) + } +} + +struct DelimitedTextValidator; + +impl ArtifactValidator for DelimitedTextValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.delimited_text", + &[Format::Csv, Format::Tsv], + ValidatorSupportTier::Experimental, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + let text = store.read_text(artifact)?; + let delimiter = if artifact.format().as_str() == "tsv" { + '\t' + } else { + ',' + }; + let mut widths = text + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| line.split(delimiter).count()); + let Some(expected) = widths.next() else { + return Ok(ValidationCheck::invalid( + "validation.empty_table", + "Delimited artifact contains no rows", + )); + }; + if widths.any(|width| width != expected) { + return Ok(ValidationCheck::invalid( + "validation.inconsistent_columns", + "Delimited artifact has inconsistent column counts", + )); + } + Ok(ValidationCheck::warning( + "validation.quoting_not_parsed", + "Column counts passed; quoted-field semantics require an external validator", + )) + } +} + +struct PdfValidator; + +impl ArtifactValidator for PdfValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.pdf", + &[Format::Pdf], + ValidatorSupportTier::Experimental, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + let bytes = store.read_bytes(artifact)?; + if !bytes.starts_with(b"%PDF-") || !bytes.windows(5).rev().take(1024).any(|w| w == b"%%EOF") + { + return Ok(ValidationCheck::invalid( + "validation.invalid_pdf_envelope", + "PDF header or end-of-file marker is missing", + )); + } + Ok(ValidationCheck::warning( + "validation.pdf_envelope_only", + "PDF envelope is intact; deep object validation requires a configured provider", + )) + } +} + +struct PngValidator; + +impl ArtifactValidator for PngValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.png", + &[Format::Png], + ValidatorSupportTier::Production, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + let bytes = store.read_bytes(artifact)?; + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") && bytes.windows(4).any(|w| w == b"IEND") { + Ok(ValidationCheck::valid()) + } else { + Ok(ValidationCheck::invalid( + "validation.invalid_png", + "PNG signature or IEND chunk is missing", + )) + } + } +} + +struct JpegValidator; + +impl ArtifactValidator for JpegValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.jpeg", + &[Format::Jpeg], + ValidatorSupportTier::Production, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + let bytes = store.read_bytes(artifact)?; + if bytes.starts_with(&[0xff, 0xd8]) && bytes.ends_with(&[0xff, 0xd9]) { + Ok(ValidationCheck::valid()) + } else { + Ok(ValidationCheck::invalid( + "validation.invalid_jpeg", + "JPEG start or end marker is missing", + )) + } + } +} + +struct ZipValidator; + +impl ArtifactValidator for ZipValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.zip", + &[Format::Zip, Format::Docx, Format::Epub, Format::Cbz], + ValidatorSupportTier::Experimental, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + let bytes = store.read_bytes(artifact)?; + let starts = bytes.starts_with(b"PK\x03\x04") + || bytes.starts_with(b"PK\x05\x06") + || bytes.starts_with(b"PK\x07\x08"); + let has_eocd = bytes.windows(4).any(|window| window == b"PK\x05\x06"); + if !starts || !has_eocd { + return Ok(ValidationCheck::invalid( + "validation.invalid_zip_envelope", + "ZIP header or end-of-central-directory record is missing", + )); + } + Ok(ValidationCheck::warning( + "validation.zip_envelope_only", + "ZIP envelope is intact; member-specific validation requires a format provider", + )) + } +} + +struct RiffWaveValidator; + +impl ArtifactValidator for RiffWaveValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.riff_wave", + &[Format::Wav, Format::Bwf], + ValidatorSupportTier::Production, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + let mut file = store.open(artifact)?; + let mut header = [0_u8; 12]; + if file.read_exact(&mut header).is_err() + || &header[..4] != b"RIFF" + || &header[8..] != b"WAVE" + { + return Ok(ValidationCheck::invalid( + "validation.invalid_wave", + "RIFF/WAVE header is missing or truncated", + )); + } + Ok(ValidationCheck::valid()) + } +} + +struct DeclaredSignatureValidator; + +impl ArtifactValidator for DeclaredSignatureValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.declared_signature", + &[], + ValidatorSupportTier::Experimental, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + let format = artifact + .format() + .as_str() + .parse::() + .context("artifact carries an unknown canonical format")?; + let registry = FormatCapabilityRegistry::global(); + let Some(format_descriptor) = registry.get(format) else { + return Ok(ValidationCheck::invalid( + "validation.unknown_format", + "Artifact format is absent from the capability registry", + )); + }; + if format_descriptor.magic_signatures.is_empty() { + return Ok(ValidationCheck::unavailable( + "validation.no_signature", + "No native structural signature is registered for this format", + )); + } + let bytes = store.read_bytes(artifact)?; + if format_descriptor + .magic_signatures + .iter() + .any(|signature| signature.matches(&bytes)) + { + Ok(ValidationCheck::warning( + "validation.signature_only", + "Declared signature matched; deep structural validation is unavailable", + )) + } else { + Ok(ValidationCheck::invalid( + "validation.signature_mismatch", + "Artifact does not match any declared format signature", + )) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConformanceSupportStatus { + Implemented, + Experimental, + Unavailable, + Planned, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConformanceRow { + pub format: String, + pub name: String, + pub families: Vec, + pub declared_capabilities: Vec, + pub executor_implemented: bool, + pub required_provider_ids: Vec, + pub validator_ids: Vec, + pub fixture_ids: Vec, + pub supported_platforms: Vec, + pub deterministic_validation: bool, + pub loss_profile: String, + pub support_status: ConformanceSupportStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityConformanceMatrix { + pub schema_version: String, + pub engine_version: String, + pub formats: Vec, +} + +impl CapabilityConformanceMatrix { + pub fn builtins() -> Self { + let formats = FormatCapabilityRegistry::global(); + let validators = ValidationRegistry::builtins(); + let mut rows = formats + .all() + .map(|format| conformance_row(format, &validators)) + .collect::>(); + rows.sort_by(|left, right| left.format.cmp(&right.format)); + Self { + schema_version: CONFORMANCE_MATRIX_SCHEMA_V1.to_string(), + engine_version: env!("CARGO_PKG_VERSION").to_string(), + formats: rows, + } + } +} + +fn conformance_row( + descriptor: &FormatDescriptor, + validators: &ValidationRegistry, +) -> ConformanceRow { + let format = descriptor.id.parse::().ok(); + let validator_ids = format + .map(|format| validators.default_validator_ids(format)) + .unwrap_or_default(); + let executor_implemented = descriptor.has_capability(ArtifactCapability::Convert) + || descriptor.has_capability(ArtifactCapability::Generate); + let has_specific_validator = validator_ids + .iter() + .any(|id| id != "validator.core.non_empty" && id != "validator.core.declared_signature"); + let has_structural_probe = has_specific_validator || !descriptor.magic_signatures.is_empty(); + let has_production_validator = validator_ids.iter().any(|id| { + matches!( + id.as_str(), + "validator.core.utf8" + | "validator.core.json" + | "validator.core.yaml" + | "validator.core.png" + | "validator.core.jpeg" + | "validator.core.riff_wave" + ) + }); + let support_status = if executor_implemented && has_production_validator { + ConformanceSupportStatus::Implemented + } else if executor_implemented && has_structural_probe { + ConformanceSupportStatus::Experimental + } else if descriptor.has_capability(ArtifactCapability::Inspect) + || descriptor.has_capability(ArtifactCapability::Detect) + { + ConformanceSupportStatus::Unavailable + } else { + ConformanceSupportStatus::Planned + }; + ConformanceRow { + format: descriptor.id.to_string(), + name: descriptor.name.to_string(), + families: descriptor + .families + .iter() + .map(ToString::to_string) + .collect(), + declared_capabilities: descriptor + .capabilities + .iter() + .map(ToString::to_string) + .collect(), + executor_implemented, + required_provider_ids: descriptor + .external_requirements + .iter() + .map(|tool| tool.stable_id().to_string()) + .collect(), + validator_ids, + fixture_ids: if descriptor.id == "png" { + vec!["fixture.corrupt.png.truncated".to_string()] + } else { + Vec::new() + }, + supported_platforms: vec![ + "linux".to_string(), + "macos".to_string(), + "windows".to_string(), + ], + deterministic_validation: true, + loss_profile: descriptor.loss_profile.to_string(), + support_status, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::artifact::{ArtifactDescriptor, ArtifactStorageClass}; + + #[test] + fn truncated_png_is_invalid_even_when_non_empty() { + let directory = tempfile::tempdir().unwrap(); + let store = ArtifactStore::new(directory.path()).unwrap(); + let artifact = store + .put_bytes( + b"\x89PNG\r\n\x1a\ntruncated", + ArtifactDescriptor::for_format(Format::Png, ArtifactStorageClass::Terminal), + ) + .unwrap(); + let outcome = + ValidationRegistry::builtins().validate_in_store(&artifact, Format::Png, &[], &store); + assert_eq!(outcome.state, ValidationState::Invalid); + } + + #[test] + fn conformance_matrix_is_deterministic_and_non_empty() { + let first = CapabilityConformanceMatrix::builtins(); + let second = CapabilityConformanceMatrix::builtins(); + assert_eq!(first, second); + assert!(!first.formats.is_empty()); + } +} diff --git a/docs/cli-reference/tools.md b/docs/cli-reference/tools.md index f71de1b..d379462 100644 --- a/docs/cli-reference/tools.md +++ b/docs/cli-reference/tools.md @@ -40,6 +40,15 @@ renderflow capabilities --format json Capability IDs and provider IDs are stable machine-readable identifiers. Human-readable CLI output is rendered from the same data returned by JSON/YAML modes. +Use `--matrix` to emit the generated artifact capability conformance matrix: + +```bash +renderflow capabilities --matrix +renderflow capabilities --matrix --format json +``` + +The matrix reports implemented, experimental, unavailable, and planned support together with executor, provider, validator, fixture, platform, determinism, and loss-profile evidence. + ## Toolchain fingerprints Graph planning fingerprints only providers selected by the final DAG. The fingerprint includes the selected provider IDs, compatible installed versions, relevant executable identity, provider capability metadata, selected variant/model evidence when present, and the target OS/architecture. It does **not** hash the entire host environment. diff --git a/docs/execution-evidence.md b/docs/execution-evidence.md index dc9654c..b2af6cd 100644 --- a/docs/execution-evidence.md +++ b/docs/execution-evidence.md @@ -18,7 +18,9 @@ The CLI exits unsuccessfully for `partial`, `failed`, and `cancelled` outcomes a ## Artifact and step evidence -The artifact manifest contains source, retained intermediate, and terminal artifact records. Each record includes a stable artifact ID, logical role, lifecycle, safe store or bundle locator, canonical format and media type, SHA-256 digest, size, producer identity, source lineage, cache status, validation status, and fidelity declaration. +The artifact manifest contains source, retained intermediate, and terminal artifact records. Each record includes a stable artifact ID, logical role, lifecycle, safe store or bundle locator, canonical format and media type, SHA-256 digest, size, producer identity, source lineage, cache status, validation status, fidelity declaration, and per-validator evidence. Validator evidence identifies the implementation version and provider and carries structured diagnostics. + +Terminal artifacts are validated before materialization. A required invalid artifact is never published; unavailable validation also blocks publication unless the spec explicitly allows it. When validation is disabled, the terminal state is recorded as `skipped` rather than inferred as valid. Each executed DAG edge produces step evidence with transform/capability/provider identity, input and output artifact IDs, a configuration digest, timestamps, duration, cache disposition, validation and fidelity states, and structured diagnostics. Cache hits use `state: reused`; transforms blocked by a failed dependency use `state: skipped` with a reason. diff --git a/docs/user-guide/artifact-validation.md b/docs/user-guide/artifact-validation.md new file mode 100644 index 0000000..32c37a5 --- /dev/null +++ b/docs/user-guide/artifact-validation.md @@ -0,0 +1,35 @@ +# Artifact validation and fidelity gates + +Canonical builds validate every terminal artifact before publication. Validation is part of the execution result: an artifact is `valid`, `valid_with_warnings`, `invalid`, `unavailable`, or `skipped` by explicit policy. Intermediate artifacts remain `not_requested` unless they become selected targets. + +The built-in registry provides deterministic structural validators for text, JSON, YAML, markup, delimited text, PDF, PNG, JPEG, ZIP-based packages, and RIFF/WAVE. Each result records the validator ID, implementation version, provider, state, and structured diagnostics in `renderflow-run.json`. + +```yaml +execution: + validation: + required: true + validators: + - validator.core.non_empty + - validator.core.png + failure_mode: branch_local + allow_unavailable: false + reject_loss_classes: + - lossy + - unknown +``` + +With `branch_local`, a failed target is withheld while independent valid targets may be published, producing a partial run. With `fatal`, any blocking validation or fidelity result prevents every target from being published. Setting `required: false` records terminal validation as `skipped`; it does not silently claim validity. + +An unavailable requested validator blocks publication unless `allow_unavailable: true`. Warnings remain publishable. `reject_loss_classes` can reject `lossless`, `partial`, `lossy`, `path_dependent`, or `unknown` fidelity declarations independently of the numeric planning-time `minimum_fidelity` threshold. + +## Capability conformance matrix + +The generated matrix is the source for format support claims: + +```bash +renderflow capabilities --matrix +renderflow capabilities --matrix --format json +renderflow capabilities --matrix --format yaml +``` + +Each row distinguishes `implemented`, `experimental`, `unavailable`, and `planned` support and names declared capabilities, executors, providers, validators, fixtures, platforms, deterministic validation, and loss profile. Its machine-readable contract is `schemas/renderflow-conformance-v1.schema.json`. diff --git a/docs/user-guide/spec-v2-reference.md b/docs/user-guide/spec-v2-reference.md index e465282..c3cfa87 100644 --- a/docs/user-guide/spec-v2-reference.md +++ b/docs/user-guide/spec-v2-reference.md @@ -59,6 +59,7 @@ Spec v2 describes source intent, derivative selection, execution policy, and det | `optimization` | `speed` / `quality` / `balanced` / `pareto` | no | `"balanced"` | | `publication_policy` | `string` / `null` | no | — | | `redaction_policy` | `string` / `null` | no | — | +| `reject_loss_classes` | `array` | no | `[]` | | `requirements` | `requirements` | no | — | | `retry_policy` | `string` / `null` | no | — | | `timeout_policy` | `string` / `null` | no | — | @@ -150,7 +151,12 @@ execution: ai: deny validation: required: true + failure_mode: branch_local + allow_unavailable: false minimum_fidelity: 0.9 + reject_loss_classes: + - lossy + - unknown output: bundle_root: dist diff --git a/examples/renderflow-v2.yaml b/examples/renderflow-v2.yaml index bd9b085..e20ffa9 100644 --- a/examples/renderflow-v2.yaml +++ b/examples/renderflow-v2.yaml @@ -67,7 +67,12 @@ execution: ai: deny validation: required: true + failure_mode: branch_local + allow_unavailable: false minimum_fidelity: 0.9 + reject_loss_classes: + - lossy + - unknown output: bundle_root: dist diff --git a/mkdocs.yml b/mkdocs.yml index 2364438..30513f5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -111,6 +111,7 @@ nav: - Graph Engine: architecture/graph-engine.md - DAG Execution: architecture/dag-execution.md - Execution Evidence: execution-evidence.md + - Artifact Validation: user-guide/artifact-validation.md - Plugin Architecture: architecture/plugin-architecture.md - Execution Plans: architecture/execution-plans.md - CLI Reference: diff --git a/schemas/renderflow-conformance-v1.schema.json b/schemas/renderflow-conformance-v1.schema.json new file mode 100644 index 0000000..bf9af18 --- /dev/null +++ b/schemas/renderflow-conformance-v1.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-conformance-v1.schema.json", + "title": "Renderflow artifact capability conformance matrix v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "engine_version", "formats"], + "properties": { + "schema_version": { "const": "renderflow.conformance/v1" }, + "engine_version": { "type": "string", "minLength": 1 }, + "formats": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", "name", "families", "declared_capabilities", + "executor_implemented", "required_provider_ids", "validator_ids", + "fixture_ids", "supported_platforms", "deterministic_validation", + "loss_profile", "support_status" + ], + "properties": { + "format": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "families": { "type": "array", "items": { "type": "string" } }, + "declared_capabilities": { "type": "array", "items": { "type": "string" } }, + "executor_implemented": { "type": "boolean" }, + "required_provider_ids": { "type": "array", "items": { "type": "string" } }, + "validator_ids": { "type": "array", "items": { "type": "string" } }, + "fixture_ids": { "type": "array", "items": { "type": "string" } }, + "supported_platforms": { "type": "array", "items": { "type": "string" } }, + "deterministic_validation": { "type": "boolean" }, + "loss_profile": { "type": "string" }, + "support_status": { + "enum": ["implemented", "experimental", "unavailable", "planned"] + } + } + } + } + } +} diff --git a/schemas/renderflow-run-v1.schema.json b/schemas/renderflow-run-v1.schema.json index f2b7978..12ba026 100644 --- a/schemas/renderflow-run-v1.schema.json +++ b/schemas/renderflow-run-v1.schema.json @@ -105,6 +105,10 @@ "not_requested" ] }, + "validation_evidence": { + "type": "array", + "items": { "$ref": "#/$defs/validator_evidence" } + }, "fidelity": { "enum": ["lossless", "partial", "lossy", "path_dependent", "unknown"] }, @@ -133,6 +137,32 @@ } } }, + "validation_diagnostic": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string" } + } + }, + "validator_evidence": { + "type": "object", + "additionalProperties": false, + "required": ["validator_id", "validator_version", "provider", "state"], + "properties": { + "validator_id": { "type": "string", "minLength": 1 }, + "validator_version": { "type": "string", "minLength": 1 }, + "provider": { "type": "string", "minLength": 1 }, + "state": { + "enum": ["valid", "valid_with_warnings", "invalid", "unavailable", "skipped", "not_requested"] + }, + "diagnostics": { + "type": "array", + "items": { "$ref": "#/$defs/validation_diagnostic" } + } + } + }, "step": { "type": "object", "additionalProperties": false, diff --git a/schemas/renderflow-v2.schema.json b/schemas/renderflow-v2.schema.json index fa288bd..af13b41 100644 --- a/schemas/renderflow-v2.schema.json +++ b/schemas/renderflow-v2.schema.json @@ -109,6 +109,20 @@ "null" ] }, + "reject_loss_classes": { + "default": [], + "items": { + "enum": [ + "lossless", + "partial", + "lossy", + "path_dependent", + "unknown" + ] + }, + "type": "array", + "uniqueItems": true + }, "requirements": { "$ref": "#/$defs/requirements" }, @@ -450,6 +464,17 @@ "validation": { "additionalProperties": false, "properties": { + "allow_unavailable": { + "default": false, + "type": "boolean" + }, + "failure_mode": { + "default": "branch_local", + "enum": [ + "fatal", + "branch_local" + ] + }, "required": { "default": true, "type": "boolean"