From 268b697afc6c09c66d5f4da259d369680f6c9aac Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 10 Sep 2026 20:55:06 -0400 Subject: [PATCH] feat: add composable artifact forest profiles --- .../data/profiles/everything-v1.yaml | 7 + crates/renderflow-core/src/app.rs | 8 + crates/renderflow-core/src/cli.rs | 27 +- crates/renderflow-core/src/commands/build.rs | 18 +- crates/renderflow-core/src/commands/graph.rs | 31 +- crates/renderflow-core/src/evidence.rs | 2 + .../src/graph/execution_plan.rs | 54 +++ crates/renderflow-core/src/graph/mod.rs | 5 +- crates/renderflow-core/src/planning.rs | 424 +++++++++++++++++- crates/renderflow-core/src/spec.rs | 132 +++++- .../renderflow-core/src/super_resolution.rs | 1 + docs/user-guide/configuration.md | 53 +++ docs/user-guide/spec-v2-reference.md | 2 +- schemas/renderflow-run-v1.schema.json | 34 +- schemas/renderflow-v2.schema.json | 359 +++++++++++---- 15 files changed, 1051 insertions(+), 106 deletions(-) create mode 100644 crates/renderflow-core/data/profiles/everything-v1.yaml diff --git a/crates/renderflow-core/data/profiles/everything-v1.yaml b/crates/renderflow-core/data/profiles/everything-v1.yaml new file mode 100644 index 0000000..773f7ff --- /dev/null +++ b/crates/renderflow-core/data/profiles/everything-v1.yaml @@ -0,0 +1,7 @@ +schema: renderflow.profile/v1 +description: Maximal policy-allowed artifact forest for the detected source +all_reachable: true +intermediates: cache_only +targets: [] +include: {} +exclude: {} diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 5a9c159..97aa552 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -32,6 +32,8 @@ pub fn run_cli(cli: Cli) -> Result<()> { resume, optimization, target, + profile, + exclude, all, }) => commands::build::run_selection( &config, @@ -39,6 +41,8 @@ pub fn run_cli(cli: Cli) -> Result<()> { resume, optimization, target.as_deref(), + profile.as_deref(), + &exclude, all, )?, Some(Commands::Watch { config, debounce }) => commands::watch::run(&config, debounce)?, @@ -105,12 +109,16 @@ pub fn run_cli(cli: Cli) -> Result<()> { config, format, target, + profile, + exclude, export, optimization, } => commands::graph::run_plan( &config, &format, target.as_deref(), + profile.as_deref(), + &exclude, export.as_deref(), optimization, )?, diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index 455a824..9ac4a20 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -60,7 +60,7 @@ pub enum Commands { renderflow build --optimization speed Build using speed optimization mode\n \ renderflow build --optimization pareto Build with Pareto-optimal path selection\n \ renderflow build --target pdf Build only the PDF output via graph resolution\n \ - renderflow build --all Build all reachable outputs via graph resolution")] + renderflow build --profile everything Build the maximal available artifact forest")] Build { /// Path to the renderflow configuration file #[arg(long, default_value = "renderflow.yaml", value_name = "FILE")] @@ -84,13 +84,21 @@ pub enum Commands { /// Build only the specified output format using the canonical capability graph. /// Built-in document/image/audio capabilities and optional configured transforms are /// resolved through the same planner. Cannot be combined with --all. - #[arg(long, value_name = "FORMAT", conflicts_with = "all")] + #[arg(long, value_name = "FORMAT", conflicts_with_all = ["all", "profile"])] target: Option, + /// Build a named, versioned derivative profile. `everything` is bundled. + #[arg(long, value_name = "PROFILE", conflicts_with_all = ["target", "all"])] + profile: Option, + + /// Exclude a branch selector (for example `family:video` or `provider:tool.ffmpeg`). + #[arg(long, value_name = "SELECTOR")] + exclude: Vec, + /// Build all policy-allowed output formats reachable through the canonical capability graph. /// Built-in capabilities and optional configured transforms participate equally. /// Cannot be combined with --target. - #[arg(long, conflicts_with = "target")] + #[arg(long, conflicts_with_all = ["target", "profile"])] all: bool, }, @@ -437,7 +445,8 @@ pub enum GraphCommands { renderflow graph plan\n \ renderflow graph plan --format mermaid\n \ renderflow graph plan --format json --export plan.json\n \ - renderflow graph plan --target pdf")] + renderflow graph plan --target pdf\n \ + renderflow graph plan --profile everything")] Plan { /// Path to the renderflow configuration file #[arg(long, default_value = "renderflow.yaml", value_name = "FILE")] @@ -449,9 +458,17 @@ pub enum GraphCommands { format: String, /// Limit the plan to this output format only. - #[arg(long, value_name = "FORMAT")] + #[arg(long, value_name = "FORMAT", conflicts_with = "profile")] target: Option, + /// Resolve a named, versioned derivative profile. `everything` is bundled. + #[arg(long, value_name = "PROFILE", conflicts_with = "target")] + profile: Option, + + /// Exclude a branch selector such as `family:video`. + #[arg(long, value_name = "SELECTOR")] + exclude: Vec, + /// Write the output to a file instead of stdout. #[arg(long, short = 'o', value_name = "FILE")] export: Option, diff --git a/crates/renderflow-core/src/commands/build.rs b/crates/renderflow-core/src/commands/build.rs index fc98cde..12703c7 100644 --- a/crates/renderflow-core/src/commands/build.rs +++ b/crates/renderflow-core/src/commands/build.rs @@ -7,7 +7,16 @@ use crate::planning::{execute, resolve, PlanningRequest}; /// Run the canonical Renderflow execution lifecycle using the target intent /// declared in the v1/v2 configuration. pub fn run(config_path: &str, dry_run: bool, optimization: Option) -> Result<()> { - run_selection(config_path, dry_run, false, optimization, None, false) + run_selection( + config_path, + dry_run, + false, + optimization, + None, + None, + &[], + false, + ) } /// Compatibility entrypoint for watch mode. @@ -26,6 +35,8 @@ pub(crate) fn run_selection( resume: bool, optimization: Option, target: Option<&str>, + profile: Option<&str>, + exclude: &[String], all_reachable: bool, ) -> Result<()> { if dry_run { @@ -40,9 +51,14 @@ pub(crate) fn run_selection( } if let Some(target) = target { request = request.with_target(target); + } else if let Some(profile) = profile { + request = request.with_profile(profile); } else if all_reachable { request = request.with_all_reachable(); } + for selector in exclude { + request = request.with_exclude(selector)?; + } let resolved = resolve(request)?.with_resume(resume); info!( diff --git a/crates/renderflow-core/src/commands/graph.rs b/crates/renderflow-core/src/commands/graph.rs index a4b6064..864c017 100644 --- a/crates/renderflow-core/src/commands/graph.rs +++ b/crates/renderflow-core/src/commands/graph.rs @@ -51,10 +51,27 @@ pub fn run_plan( config_path: &str, format: &str, target: Option<&str>, + profile: Option<&str>, + exclude: &[String], export: Option<&str>, optimization: Option, ) -> Result<()> { - let (plan, _) = load_plan(config_path, target, optimization)?; + let (plan, _) = if profile.is_some() || !exclude.is_empty() { + let mut request = PlanningRequest::from_path(config_path); + if let Some(profile) = profile { + request = request.with_profile(profile); + } + for selector in exclude { + request = request.with_exclude(selector)?; + } + if let Some(optimization) = optimization { + request = request.with_optimization(optimization); + } + let resolved = resolve(request)?; + (resolved.plan().clone(), resolved.target_formats()) + } else { + load_plan(config_path, target, optimization)? + }; let renderer = renderer_for(format).ok_or_else(|| { anyhow::anyhow!( @@ -79,7 +96,7 @@ pub fn run_render( export: Option<&str>, optimization: Option, ) -> Result<()> { - run_plan(config_path, format, target, export, optimization) + run_plan(config_path, format, target, None, &[], export, optimization) } // ── explain ────────────────────────────────────────────────────────────────── @@ -126,7 +143,15 @@ pub fn run_export( target: Option<&str>, optimization: Option, ) -> Result<()> { - run_plan(config_path, format, target, Some(output_path), optimization) + run_plan( + config_path, + format, + target, + None, + &[], + Some(output_path), + optimization, + ) } // ── doctor ─────────────────────────────────────────────────────────────────── diff --git a/crates/renderflow-core/src/evidence.rs b/crates/renderflow-core/src/evidence.rs index 8045dd3..838ae0d 100644 --- a/crates/renderflow-core/src/evidence.rs +++ b/crates/renderflow-core/src/evidence.rs @@ -311,6 +311,8 @@ pub struct RunManifest { pub diagnostics: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub toolchain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_forest: Option, } impl RunManifest { diff --git a/crates/renderflow-core/src/graph/execution_plan.rs b/crates/renderflow-core/src/graph/execution_plan.rs index 0d0fd5e..0b09e36 100644 --- a/crates/renderflow-core/src/graph/execution_plan.rs +++ b/crates/renderflow-core/src/graph/execution_plan.rs @@ -187,6 +187,43 @@ pub struct PlanDiagnostic { pub message: String, } +/// Resolution state for one requested artifact-forest branch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ForestBranchState { + Selected, + Excluded, + Unavailable, + BudgetPruned, +} + +/// Machine-readable evidence for a branch considered during profile expansion. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForestBranch { + pub format: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + pub requirement: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub options: BTreeMap, + pub state: ForestBranchState, + pub reason_code: String, + pub reason: String, +} + +/// Requested and resolved artifact forest attached to plans and run manifests. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ArtifactForest { + #[serde(default)] + pub profiles: Vec, + pub intermediates: String, + #[serde(default)] + pub branches: Vec, + /// Artifact ids actually produced; empty in a pre-execution plan. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub produced_artifacts: Vec, +} + impl PlanDiagnostic { fn info(message: impl Into) -> Self { PlanDiagnostic { @@ -254,6 +291,9 @@ pub struct ExecutionPlan { /// Reproducible evidence for providers selected by this exact plan. #[serde(default, skip_serializing_if = "Option::is_none")] pub toolchain: Option, + /// Profile expansion and branch-local selection evidence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_forest: Option, } impl ExecutionPlan { @@ -366,6 +406,7 @@ impl ExecutionPlan { diagnostics, source_artifact: None, toolchain: None, + artifact_forest: None, } } @@ -394,6 +435,19 @@ impl ExecutionPlan { self.source_artifact = Some(PlanSourceArtifact::from(report)); } + pub fn attach_artifact_forest(&mut self, forest: ArtifactForest) { + let selected = forest + .branches + .iter() + .filter(|branch| branch.state == ForestBranchState::Selected) + .count(); + let pruned = forest.branches.len().saturating_sub(selected); + self.diagnostics.push(PlanDiagnostic::info(format!( + "Artifact forest selected {selected} branch(es) and recorded {pruned} excluded, unavailable, or budget-pruned branch(es)." + ))); + self.artifact_forest = Some(forest); + } + /// Surface an unavailable/unsupported provider observation in plan diagnostics. pub fn add_tool_diagnostic(&mut self, message: impl Into) { self.diagnostics.push(PlanDiagnostic::warning(message)); diff --git a/crates/renderflow-core/src/graph/mod.rs b/crates/renderflow-core/src/graph/mod.rs index 33fd0da..2572a0c 100644 --- a/crates/renderflow-core/src/graph/mod.rs +++ b/crates/renderflow-core/src/graph/mod.rs @@ -13,7 +13,10 @@ mod transform_edge; pub use dag_executor::{DagExecutionReport, DagExecutor}; pub use definition::TransformDefinition; pub use definition_registry::TransformDefinitionRegistry; -pub use execution_plan::{DiagnosticLevel, ExecutionPlan, PlanSourceArtifact}; +pub use execution_plan::{ + ArtifactForest, DiagnosticLevel, ExecutionPlan, ForestBranch, ForestBranchState, + PlanSourceArtifact, +}; pub use format::Format; pub use input_kind::InputKind; pub use multi_target::MultiTargetDag; diff --git a/crates/renderflow-core/src/planning.rs b/crates/renderflow-core/src/planning.rs index 698e4f7..511c642 100644 --- a/crates/renderflow-core/src/planning.rs +++ b/crates/renderflow-core/src/planning.rs @@ -4,7 +4,7 @@ //! Execution consumes a previously resolved [`ResolvedExecution`] and never //! performs implicit target/path re-planning. -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; use std::io::Write; use std::path::{Component, Path, PathBuf}; @@ -25,16 +25,16 @@ use crate::evidence::{ }; use crate::graph::capability::{FormatCapabilityRegistry, FormatFamily}; use crate::graph::{ - DagExecutionReport, DagExecutor, DiagnosticLevel, ExecutionPlan, Format, MultiTargetDag, - TransformEdge, TransformGraph, + ArtifactForest, DagExecutionReport, DagExecutor, DiagnosticLevel, ExecutionPlan, ForestBranch, + ForestBranchState, Format, MultiTargetDag, TransformEdge, TransformGraph, }; use crate::hygiene::{HygieneEngine, HygieneEvidence}; use crate::intake::{IntakeEngine, IntakeRequest, ResolvedArtifactProfile}; use crate::optimization::OptimizationMode; use crate::spec::{ - load_spec, AiPolicy, CollisionPolicy, HygienePolicy, RejectedLossClass, SelectorSet, - SourceKind, SourceSpec, SourceSpecVersion, SpecV2, TargetSelection, TargetSpec, - ValidationFailureMode, + load_spec, AiPolicy, CollisionPolicy, DerivativeProfile, HygienePolicy, IntermediatePolicy, + RejectedLossClass, SelectorSet, SourceKind, SourceSpec, SourceSpecVersion, SpecV2, + TargetRequirement, TargetSelection, TargetSpec, ValidationFailureMode, }; use crate::super_resolution::{select_upscayl_variants, UpscaylModelCatalog}; use crate::toolchain::{ @@ -51,6 +51,8 @@ pub struct PlanningRequest { pub config_path: PathBuf, pub target: Option, pub all_reachable: bool, + pub profile: Option, + pub exclude: SelectorSet, pub optimization: Option, } @@ -60,6 +62,8 @@ impl PlanningRequest { config_path: path.as_ref().to_path_buf(), target: None, all_reachable: false, + profile: None, + exclude: SelectorSet::default(), optimization: None, } } @@ -67,15 +71,42 @@ impl PlanningRequest { pub fn with_target(mut self, target: impl Into) -> Self { self.target = Some(target.into()); self.all_reachable = false; + self.profile = None; self } pub fn with_all_reachable(mut self) -> Self { self.target = None; + self.profile = None; self.all_reachable = true; self } + pub fn with_profile(mut self, profile: impl Into) -> Self { + self.target = None; + self.all_reachable = false; + self.profile = Some(profile.into()); + self + } + + pub fn with_exclude(mut self, expression: &str) -> Result { + let (kind, value) = expression.split_once(':').ok_or_else(|| { + anyhow::anyhow!("invalid exclude selector '{expression}'; expected kind:value") + })?; + let destination = match kind { + "format" => &mut self.exclude.formats, + "family" => &mut self.exclude.families, + "capability" => &mut self.exclude.capabilities, + "provider" => &mut self.exclude.providers, + "role" => &mut self.exclude.roles, + "transform" => &mut self.exclude.transforms, + "variant" => &mut self.exclude.variants, + _ => anyhow::bail!("unknown exclude selector kind '{kind}'"), + }; + destination.push(value.to_string()); + Ok(self) + } + pub fn with_optimization(mut self, optimization: OptimizationMode) -> Self { self.optimization = Some(optimization); self @@ -90,6 +121,8 @@ pub struct ResolvedTarget { pub preset: Option, pub template: Option, pub variant: Option, + pub requirement: TargetRequirement, + pub options: std::collections::BTreeMap, } impl ResolvedTarget { @@ -101,6 +134,8 @@ impl ResolvedTarget { preset: None, template: None, variant: None, + requirement: TargetRequirement::Optional, + options: Default::default(), } } @@ -112,6 +147,8 @@ impl ResolvedTarget { preset: target.preset.clone(), template: target.template.clone(), variant: target.variant.clone(), + requirement: target.requirement, + options: target.options.clone(), } } } @@ -241,6 +278,7 @@ pub fn resolve(request: PlanningRequest) -> Result { let source_version = loaded.source_version; let mut spec = loaded.spec; apply_request_overrides(&mut spec, &request)?; + compose_selected_profiles(&mut spec)?; let source = select_primary_source(&spec)?; let source_path = resolve_path_relative_to_config( @@ -299,15 +337,44 @@ pub fn resolve(request: PlanningRequest) -> Result { register_builtin_strategy_edges(&mut graph, &mut tool_registry)?; let policy_graph = apply_execution_policy(&graph, &tool_registry, &spec); - let targets = resolve_target_intent(&spec, &policy_graph, source_format)?; + let mut targets = resolve_target_intent(&spec, &policy_graph, source_format)?; if targets.is_empty() { anyhow::bail!("target selection resolved to no executable artifact formats"); } - let target_formats: Vec = targets.iter().map(|target| target.format).collect(); let provider_inventory = tool_registry.assess_ids_current(policy_graph.provider_ids()); let available_graph = policy_graph.filtered_by_available_providers(&provider_inventory.available_ids()); + let available_formats = available_graph + .reachable_from(source_format) + .into_iter() + .collect::>(); + let mut pruned_unavailable = Vec::new(); + targets.retain(|target| { + let available = available_formats.contains(&target.format); + if !available + && (spec.targets.all_reachable || target.requirement == TargetRequirement::Optional) + { + pruned_unavailable.push(target.clone()); + false + } else { + true + } + }); + let mut pruned_budget = Vec::new(); + if let Some(max_artifacts) = spec.execution.budgets.max_artifacts { + let limit = usize::try_from(max_artifacts).unwrap_or(usize::MAX); + if targets.len() > limit { + pruned_budget.extend(targets[limit..].iter().cloned()); + targets.truncate(limit); + } + } + if targets.is_empty() { + anyhow::bail!( + "target selection resolved to no available branches; inspect the artifact-forest plan or relax provider/policy constraints" + ); + } + let target_formats: Vec = targets.iter().map(|target| target.format).collect(); let optimization = spec.execution.optimization; let (dag, used_blocked_provider_fallback) = match available_graph .build_multi_target_dag_with_mode(source_format, &target_formats, optimization) @@ -333,6 +400,14 @@ pub fn resolve(request: PlanningRequest) -> Result { )?; let mut plan = ExecutionPlan::from_dag(&dag, source_format, &target_formats, optimization); + plan.attach_artifact_forest(build_artifact_forest( + &spec, + &policy_graph, + source_format, + &targets, + &pruned_unavailable, + &pruned_budget, + )); plan.attach_source_artifact(&source_intake); if !source_intake.profile.conflicts.is_empty() { plan.add_tool_diagnostic(format!( @@ -993,6 +1068,15 @@ fn build_run_manifest( let execution_plan_digest = sha256_serialized(&resolved.plan)?; let source_spec_digest = sha256_serialized(&resolved.spec)?; let run_id = run_id(&execution_plan_digest, started_at_unix_ms); + let mut artifact_forest = resolved.plan.artifact_forest.clone(); + if let Some(forest) = &mut artifact_forest { + forest.produced_artifacts = artifacts + .iter() + .map(|artifact| artifact.artifact_id.clone()) + .collect(); + forest.produced_artifacts.sort(); + forest.produced_artifacts.dedup(); + } Ok(RunManifest { schema_version: RUN_MANIFEST_SCHEMA_V1.to_string(), run_id: run_id.clone(), @@ -1012,6 +1096,7 @@ fn build_run_manifest( steps, diagnostics, toolchain: resolved.plan.toolchain.clone(), + artifact_forest, }) } @@ -1244,10 +1329,27 @@ fn apply_request_overrides(spec: &mut SpecV2, request: &PlanningRequest) -> Resu variant: None, preset: None, template: None, + requirement: TargetRequirement::Required, + options: Default::default(), }], intermediates: spec.targets.intermediates, ..TargetSelection::default() }; + } else if let Some(profile) = &request.profile { + if profile == "everything" && !spec.profiles.contains_key(profile) { + let bundled: DerivativeProfile = + serde_yaml_ng::from_str(include_str!("../data/profiles/everything-v1.yaml")) + .context("bundled everything profile is invalid")?; + spec.profiles.insert(profile.clone(), bundled); + } + if !spec.profiles.contains_key(profile) { + anyhow::bail!("target profile '{profile}' is not defined"); + } + spec.targets = TargetSelection { + profiles: vec![profile.clone()], + intermediates: spec.targets.intermediates, + ..TargetSelection::default() + }; } else if request.all_reachable { let include = spec.targets.include.clone(); let exclude = spec.targets.exclude.clone(); @@ -1259,9 +1361,169 @@ fn apply_request_overrides(spec: &mut SpecV2, request: &PlanningRequest) -> Resu ..TargetSelection::default() }; } + merge_selector_set(&mut spec.targets.exclude, &request.exclude); + Ok(()) +} + +fn compose_selected_profiles(spec: &mut SpecV2) -> Result<()> { + let selected = spec.targets.profiles.clone(); + let mut cache = BTreeMap::new(); + for name in selected { + let profile = resolve_profile(spec, &name, &mut Vec::new(), &mut cache)?; + spec.targets.all_reachable |= profile.all_reachable; + if let Some(intermediates) = profile.intermediates { + spec.targets.intermediates = intermediates; + } + apply_profile_policy(&mut spec.execution, &profile.policy); + spec.profiles.insert(name, profile); + } Ok(()) } +fn resolve_profile( + spec: &SpecV2, + name: &str, + stack: &mut Vec, + cache: &mut BTreeMap, +) -> Result { + if let Some(profile) = cache.get(name) { + return Ok(profile.clone()); + } + if let Some(position) = stack.iter().position(|item| item == name) { + let mut cycle = stack[position..].to_vec(); + cycle.push(name.to_string()); + anyhow::bail!( + "derivative profile inheritance cycle: {}", + cycle.join(" -> ") + ); + } + let declared = spec + .profiles + .get(name) + .with_context(|| format!("target profile '{name}' is not defined"))?; + if declared.schema != "renderflow.profile/v1" { + anyhow::bail!( + "profile '{name}' uses unsupported schema '{}'; expected renderflow.profile/v1", + declared.schema + ); + } + stack.push(name.to_string()); + let mut result = DerivativeProfile::default(); + let mut inherited_hygiene = BTreeSet::new(); + for parent_name in &declared.extends { + let parent = resolve_profile(spec, parent_name, stack, cache)?; + if let Some(policy) = &parent.hygiene_policy { + inherited_hygiene.insert(policy.clone()); + } + merge_profile(&mut result, &parent); + } + stack.pop(); + if declared.hygiene_policy.is_none() && inherited_hygiene.len() > 1 { + anyhow::bail!( + "profile '{name}' inherits conflicting hygiene policies {}; declare hygiene_policy to resolve the conflict", + inherited_hygiene.into_iter().collect::>().join(", ") + ); + } + merge_profile(&mut result, declared); + result.extends = declared.extends.clone(); + cache.insert(name.to_string(), result.clone()); + Ok(result) +} + +fn merge_profile(destination: &mut DerivativeProfile, source: &DerivativeProfile) { + destination.schema = source.schema.clone(); + if source.description.is_some() { + destination.description = source.description.clone(); + } + for target in &source.targets { + if let Some(id) = &target.id { + if let Some(existing) = destination + .targets + .iter_mut() + .find(|candidate| candidate.id.as_ref() == Some(id)) + { + *existing = target.clone(); + continue; + } + } + if !destination.targets.contains(target) { + destination.targets.push(target.clone()); + } + } + merge_selector_set(&mut destination.include, &source.include); + merge_selector_set(&mut destination.exclude, &source.exclude); + destination.all_reachable |= source.all_reachable; + if source.intermediates.is_some() { + destination.intermediates = source.intermediates; + } + if source.hygiene_policy.is_some() { + destination.hygiene_policy = source.hygiene_policy.clone(); + } + macro_rules! overlay { + ($field:ident) => { + if source.policy.$field.is_some() { + destination.policy.$field = source.policy.$field.clone(); + } + }; + } + overlay!(validation); + overlay!(minimum_fidelity); + overlay!(requirements); + overlay!(network); + overlay!(ai); + overlay!(budgets); + overlay!(publication_policy); + overlay!(redaction_policy); +} + +fn apply_profile_policy( + execution: &mut crate::spec::ExecutionPolicy, + policy: &crate::spec::ProfilePolicy, +) { + if let Some(value) = &policy.validation { + execution.validation = value.clone(); + } + if let Some(value) = policy.minimum_fidelity { + execution.minimum_fidelity = Some(value); + } + if let Some(value) = &policy.requirements { + execution.requirements = value.clone(); + } + if let Some(value) = policy.network { + execution.network = value; + } + if let Some(value) = policy.ai { + execution.ai = value; + } + if let Some(value) = &policy.budgets { + execution.budgets = value.clone(); + } + if let Some(value) = &policy.publication_policy { + execution.publication_policy = Some(value.clone()); + } + if let Some(value) = &policy.redaction_policy { + execution.redaction_policy = Some(value.clone()); + } +} + +fn merge_selector_set(destination: &mut SelectorSet, source: &SelectorSet) { + macro_rules! merge { + ($field:ident) => {{ + destination.$field.extend(source.$field.iter().cloned()); + destination.$field.sort(); + destination.$field.dedup(); + }}; + } + merge!(formats); + merge!(families); + merge!(capabilities); + merge!(transforms); + merge!(providers); + merge!(roles); + merge!(profiles); + merge!(variants); +} + fn select_primary_source(spec: &SpecV2) -> Result { let artifacts: Vec<&SourceSpec> = spec .sources @@ -1551,6 +1813,7 @@ fn resolve_target_intent( ) -> Result> { let reachable = graph.reachable_from(source); let mut selected = Vec::new(); + let mut profile_exclusions = SelectorSet::default(); for target in &spec.targets.exact { extend_target_spec(&mut selected, target, graph, source, &reachable)?; @@ -1564,7 +1827,7 @@ fn resolve_target_intent( extend_target_spec(&mut selected, target, graph, source, &reachable)?; } extend_selector(&mut selected, &profile.include, graph, source, &reachable)?; - apply_exclusions(&mut selected, &profile.exclude, graph); + merge_selector_set(&mut profile_exclusions, &profile.exclude); } if spec.targets.all_reachable { for format in &reachable { @@ -1572,13 +1835,120 @@ fn resolve_target_intent( } } if !spec.targets.include.is_empty() { - selected.retain(|target| selector_matches(target.format, &spec.targets.include, graph)); + selected.retain(|target| selector_matches(target, &spec.targets.include, graph)); } + apply_exclusions(&mut selected, &profile_exclusions, graph); apply_exclusions(&mut selected, &spec.targets.exclude, graph); selected.sort_by(|left, right| left.format.to_string().cmp(&right.format.to_string())); Ok(selected) } +fn build_artifact_forest( + spec: &SpecV2, + graph: &TransformGraph, + source: Format, + selected: &[ResolvedTarget], + unavailable: &[ResolvedTarget], + budget_pruned: &[ResolvedTarget], +) -> ArtifactForest { + let selected_formats = selected + .iter() + .map(|target| target.format) + .collect::>(); + let unavailable_formats = unavailable + .iter() + .map(|target| target.format) + .collect::>(); + let budget_pruned_formats = budget_pruned + .iter() + .map(|target| target.format) + .collect::>(); + let mut exclusions = spec.targets.exclude.clone(); + for profile_name in &spec.targets.profiles { + if let Some(profile) = spec.profiles.get(profile_name) { + merge_selector_set(&mut exclusions, &profile.exclude); + } + } + let mut formats = graph.reachable_from(source); + formats.sort_by_key(ToString::to_string); + let branches = formats + .into_iter() + .filter_map(|format| { + let target = ResolvedTarget::generated(format); + let (state, reason_code, reason) = if selected_formats.contains(&format) { + ( + ForestBranchState::Selected, + "branch.selected", + "reachable, policy-allowed, and provider-available", + ) + } else if unavailable_formats.contains(&format) { + ( + ForestBranchState::Unavailable, + "branch.provider_unavailable", + "a required provider is unavailable on this host", + ) + } else if budget_pruned_formats.contains(&format) { + ( + ForestBranchState::BudgetPruned, + "branch.max_artifacts", + "pruned by execution.budgets.max_artifacts", + ) + } else if selector_matches(&target, &exclusions, graph) { + ( + ForestBranchState::Excluded, + "branch.selector_excluded", + "matched a profile, spec, or CLI exclusion selector", + ) + } else if spec.targets.all_reachable { + return None; + } else { + return None; + }; + let resolved_target = selected + .iter() + .chain(unavailable.iter()) + .chain(budget_pruned.iter()) + .find(|candidate| candidate.format == format) + .unwrap_or(&target); + Some(ForestBranch { + format: format.to_string(), + role: resolved_target.role.clone(), + requirement: match resolved_target.requirement { + TargetRequirement::Required => "required", + TargetRequirement::Optional => "optional", + } + .to_string(), + options: resolved_target.options.clone(), + state, + reason_code: reason_code.to_string(), + reason: reason.to_string(), + }) + }) + .collect(); + ArtifactForest { + profiles: spec + .targets + .profiles + .iter() + .map(|name| { + let version = spec + .profiles + .get(name) + .map(|profile| profile.schema.as_str()) + .unwrap_or("unknown"); + format!("{name}@{version}") + }) + .collect(), + intermediates: match spec.targets.intermediates { + IntermediatePolicy::CacheOnly => "cache_only", + IntermediatePolicy::Retain => "retain", + } + .to_string(), + branches, + produced_artifacts: Vec::new(), + } +} + fn extend_target_spec( selected: &mut Vec, target: &TargetSpec, @@ -1630,8 +2000,9 @@ fn extend_selector( reachable: &[Format], ) -> Result<()> { for format in reachable { - if selector_matches(*format, selector, graph) { - insert_target(selected, ResolvedTarget::generated(*format))?; + let target = ResolvedTarget::generated(*format); + if selector_matches(&target, selector, graph) { + insert_target(selected, target)?; } } Ok(()) @@ -1675,10 +2046,15 @@ fn apply_exclusions( if selector.is_empty() { return; } - selected.retain(|target| !selector_matches(target.format, selector, graph)); + selected.retain(|target| !selector_matches(target, selector, graph)); } -fn selector_matches(format: Format, selector: &SelectorSet, graph: &TransformGraph) -> bool { +fn selector_matches( + target: &ResolvedTarget, + selector: &SelectorSet, + graph: &TransformGraph, +) -> bool { + let format = target.format; let mut has_format_selector = false; let mut matches = false; if !selector.formats.is_empty() { @@ -1711,6 +2087,25 @@ fn selector_matches(format: Format, selector: &SelectorSet, graph: &TransformGra .is_some_and(|transform| selector.transforms.contains(transform)) }); } + if !selector.providers.is_empty() { + has_format_selector = true; + matches |= graph.transforms_to(format).iter().any(|edge| { + edge.provider_id + .as_ref() + .is_some_and(|provider| selector.providers.contains(provider)) + || edge + .required_provider_ids + .iter() + .any(|provider| selector.providers.contains(provider)) + }); + } + if !selector.roles.is_empty() { + has_format_selector = true; + matches |= target + .role + .as_ref() + .is_some_and(|role| selector.roles.contains(role)); + } if !selector.profiles.is_empty() { has_format_selector = true; } @@ -2007,6 +2402,7 @@ mod tests { variant: None, preset: None, template: None, + ..TargetSpec::default() }); let mut graph = TransformGraph::new(); graph.add_transform(TransformEdge::new(Format::Markdown, Format::Html, 1.0, 1.0)); diff --git a/crates/renderflow-core/src/spec.rs b/crates/renderflow-core/src/spec.rs index c737ff3..f77563a 100644 --- a/crates/renderflow-core/src/spec.rs +++ b/crates/renderflow-core/src/spec.rs @@ -72,6 +72,10 @@ pub struct SelectorSet { #[serde(default)] pub transforms: Vec, #[serde(default)] + pub providers: Vec, + #[serde(default)] + pub roles: Vec, + #[serde(default)] pub profiles: Vec, #[serde(default)] pub variants: Vec, @@ -83,12 +87,14 @@ impl SelectorSet { && self.families.is_empty() && self.capabilities.is_empty() && self.transforms.is_empty() + && self.providers.is_empty() + && self.roles.is_empty() && self.profiles.is_empty() && self.variants.is_empty() } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TargetSpec { #[serde(default)] @@ -109,6 +115,12 @@ pub struct TargetSpec { pub preset: Option, #[serde(default)] pub template: Option, + /// Whether failure to resolve this branch fails the complete request. + #[serde(default)] + pub requirement: TargetRequirement, + /// Provider-neutral options passed to the selected target adapter. + #[serde(default)] + pub options: BTreeMap, } impl TargetSpec { @@ -120,6 +132,14 @@ impl TargetSpec { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetRequirement { + #[default] + Required, + Optional, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum IntermediatePolicy { @@ -145,20 +165,77 @@ pub struct TargetSelection { pub intermediates: IntermediatePolicy, } -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DerivativeProfile { + /// Profile contract version. Only `renderflow.profile/v1` is currently supported. + #[serde(default = "default_profile_schema")] + pub schema: String, #[serde(default)] pub description: Option, + /// Parent profiles, composed from left to right before this profile. + #[serde(default)] + pub extends: Vec, #[serde(default)] pub targets: Vec, #[serde(default)] pub include: SelectorSet, #[serde(default)] pub exclude: SelectorSet, + /// Expand to every policy-allowed branch reachable from the detected source. + #[serde(default)] + pub all_reachable: bool, + /// Override intermediate retention when the profile is selected. + #[serde(default)] + pub intermediates: Option, /// Named publication-hygiene policy applied to artifacts selected by this profile. #[serde(default)] pub hygiene_policy: Option, + /// Optional execution gates contributed by this profile. + #[serde(default)] + pub policy: ProfilePolicy, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfilePolicy { + #[serde(default)] + pub validation: Option, + #[serde(default)] + pub minimum_fidelity: Option, + #[serde(default)] + pub requirements: Option, + #[serde(default)] + pub network: Option, + #[serde(default)] + pub ai: Option, + #[serde(default)] + pub budgets: Option, + #[serde(default)] + pub publication_policy: Option, + #[serde(default)] + pub redaction_policy: Option, +} + +fn default_profile_schema() -> String { + "renderflow.profile/v1".to_string() +} + +impl Default for DerivativeProfile { + fn default() -> Self { + Self { + schema: default_profile_schema(), + description: None, + extends: Vec::new(), + targets: Vec::new(), + include: SelectorSet::default(), + exclude: SelectorSet::default(), + all_reachable: false, + intermediates: None, + hygiene_policy: None, + policy: ProfilePolicy::default(), + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -706,7 +783,27 @@ impl SpecV2 { "profile names must use only ASCII letters, digits, '.', '_', or '-'", )); } - if profile.targets.is_empty() && profile.include.is_empty() { + if profile.schema != "renderflow.profile/v1" { + diagnostics.push(SpecDiagnostic::new( + format!("{base}.schema"), + "profile.schema.unsupported", + "supported profile schema is renderflow.profile/v1", + )); + } + for (index, parent) in profile.extends.iter().enumerate() { + if !self.profiles.contains_key(parent) { + diagnostics.push(SpecDiagnostic::new( + format!("{base}.extends[{index}]"), + "profile.parent.unknown", + format!("parent profile '{parent}' is not declared in $.profiles"), + )); + } + } + if profile.targets.is_empty() + && profile.include.is_empty() + && profile.extends.is_empty() + && !profile.all_reachable + { diagnostics.push(SpecDiagnostic::new( base.clone(), "profile.empty", @@ -1163,6 +1260,8 @@ pub(crate) fn migrate_v1_config(config: &Config) -> SpecV2 { variant: None, preset: output.profile.clone(), template: output.template.clone(), + requirement: TargetRequirement::Required, + options: BTreeMap::new(), }) .collect(); @@ -1265,6 +1364,8 @@ pub fn json_schema() -> Value { "families": {"type": "array", "items": {"type": "string"}, "default": []}, "capabilities": {"type": "array", "items": {"type": "string"}, "default": []}, "transforms": {"type": "array", "items": {"type": "string"}, "default": []}, + "providers": {"type": "array", "items": {"$ref": "#/$defs/stableId"}, "default": []}, + "roles": {"type": "array", "items": {"type": "string"}, "default": []}, "profiles": {"type": "array", "items": {"$ref": "#/$defs/stableId"}, "default": []}, "variants": {"type": "array", "items": {"$ref": "#/$defs/stableId"}, "default": []} } @@ -1281,7 +1382,9 @@ pub fn json_schema() -> Value { "transform": {"type": ["string", "null"]}, "variant": {"anyOf": [{"$ref": "#/$defs/stableId"}, {"type": "null"}]}, "preset": {"type": ["string", "null"]}, - "template": {"type": ["string", "null"]} + "template": {"type": ["string", "null"]}, + "requirement": {"enum": ["required", "optional"], "default": "required"}, + "options": {"type": "object", "default": {}} }, "anyOf": [ {"required": ["format"]}, @@ -1306,11 +1409,30 @@ pub fn json_schema() -> Value { "type": "object", "additionalProperties": false, "properties": { + "schema": {"const": "renderflow.profile/v1", "default": "renderflow.profile/v1"}, "description": {"type": ["string", "null"]}, + "extends": {"type": "array", "items": {"$ref": "#/$defs/stableId"}, "default": []}, "targets": {"type": "array", "items": {"$ref": "#/$defs/target"}, "default": []}, "include": {"$ref": "#/$defs/selectorSet"}, "exclude": {"$ref": "#/$defs/selectorSet"}, - "hygiene_policy": {"anyOf": [{"$ref": "#/$defs/stableId"}, {"type": "null"}]} + "all_reachable": {"type": "boolean", "default": false}, + "intermediates": {"enum": ["cache_only", "retain"]}, + "hygiene_policy": {"anyOf": [{"$ref": "#/$defs/stableId"}, {"type": "null"}]}, + "policy": {"$ref": "#/$defs/profilePolicy"} + } + }, + "profilePolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "validation": {"$ref": "#/$defs/validation"}, + "minimum_fidelity": {"type": ["number", "null"], "minimum": 0.0, "maximum": 1.0}, + "requirements": {"$ref": "#/$defs/requirements"}, + "network": {"enum": ["deny", "allow"]}, + "ai": {"enum": ["deny", "local_only", "allow"]}, + "budgets": {"$ref": "#/$defs/budgets"}, + "publication_policy": {"type": ["string", "null"]}, + "redaction_policy": {"type": ["string", "null"]} } }, "hygienePolicy": { diff --git a/crates/renderflow-core/src/super_resolution.rs b/crates/renderflow-core/src/super_resolution.rs index ced2d7e..066ba9e 100644 --- a/crates/renderflow-core/src/super_resolution.rs +++ b/crates/renderflow-core/src/super_resolution.rs @@ -1205,6 +1205,7 @@ mod tests { variant: Some("variant.upscayl-ncnn.digital-art-4x".to_string()), preset: None, template: None, + ..TargetSpec::default() }); let report = select_upscayl_variants(&spec, &UpscaylModelCatalog::builtins()); assert_eq!(report.variants.len(), 1); diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 9e8c098..bd1400a 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -37,6 +37,59 @@ renderflow spec schema --output schemas/renderflow-v2.schema.json See the generated [Spec v2 Reference](spec-v2-reference.md) for the canonical field matrix and complete example. +## Versioned derivative profiles and artifact forests + +Profiles use the `renderflow.profile/v1` contract. They may inherit other profiles with +`extends`; parents are composed in declaration order and the child is applied last. Target +entries with the same `id` are replaced by the later definition, while selector lists are +unioned and sorted. Inheritance cycles and unresolved parent names are rejected. A child must +explicitly choose `hygiene_policy` when its parents disagree. + +```yaml +profiles: + publication.base: + schema: renderflow.profile/v1 + targets: + - id: web + role: web + format: html + requirement: required + options: + standalone: true + + publication.complete: + schema: renderflow.profile/v1 + extends: [publication.base] + all_reachable: true + intermediates: cache_only + exclude: + families: [video] + providers: [tool.remote-video] + policy: + network: deny + ai: deny + minimum_fidelity: 0.9 + validation: + required: true + failure_mode: branch_local +``` + +The bundled `everything` profile is data-defined and expands from the detected source through +every policy-allowed branch. Provider-unavailable optional branches are recorded rather than +failing unrelated branches. AI and network transforms remain excluded unless the effective +profile/spec policy opts in. Intermediate artifacts remain cache-only unless retention is +explicitly requested. + +```bash +renderflow graph plan --profile everything --format json +renderflow build --profile everything --exclude family:video --dry-run +renderflow build --profile publication.complete --exclude provider:tool.remote-video +``` + +JSON plans and run manifests include `artifact_forest` evidence. Each considered branch is +classified as `selected`, `excluded`, `unavailable`, or `budget_pruned` with a stable reason +code. The final artifact manifest remains the authority for actually produced artifacts. + ## V1 compatibility format ### Minimal config diff --git a/docs/user-guide/spec-v2-reference.md b/docs/user-guide/spec-v2-reference.md index 37b31e6..c84a29b 100644 --- a/docs/user-guide/spec-v2-reference.md +++ b/docs/user-guide/spec-v2-reference.md @@ -54,13 +54,13 @@ Spec v2 describes source intent, derivative selection, execution policy, and det | --- | --- | --- | --- | | `ai` | `deny` / `local_only` / `allow` | no | `"deny"` | | `budgets` | `budgets` | no | — | +| `hygiene_policy` | `object` | no | — | | `max_parallel` | `integer` | no | `1` | | `minimum_fidelity` | `number` / `null` | no | — | | `network` | `deny` / `allow` | no | `"deny"` | | `optimization` | `speed` / `quality` / `balanced` / `pareto` | no | `"balanced"` | | `publication_policy` | `string` / `null` | no | — | | `redaction_policy` | `string` / `null` | no | — | -| `hygiene_policy` | `object` | no | — | | `reject_loss_classes` | `array` | no | `[]` | | `requirements` | `requirements` | no | — | | `retry_policy` | `string` / `null` | no | — | diff --git a/schemas/renderflow-run-v1.schema.json b/schemas/renderflow-run-v1.schema.json index c3dd1ed..2f99a2c 100644 --- a/schemas/renderflow-run-v1.schema.json +++ b/schemas/renderflow-run-v1.schema.json @@ -37,9 +37,41 @@ "type": "array", "items": { "$ref": "#/$defs/diagnostic" } }, - "toolchain": { "type": "object" } + "toolchain": { "type": "object" }, + "artifact_forest": { "$ref": "#/$defs/artifact_forest" } }, "$defs": { + "artifact_forest": { + "type": "object", + "additionalProperties": false, + "required": ["profiles", "intermediates", "branches"], + "properties": { + "profiles": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "intermediates": { "enum": ["cache_only", "retain"] }, + "branches": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["format", "requirement", "state", "reason_code", "reason"], + "properties": { + "format": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "minLength": 1 }, + "requirement": { "enum": ["required", "optional"] }, + "options": { "type": "object" }, + "state": { "enum": ["selected", "excluded", "unavailable", "budget_pruned"] }, + "reason_code": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 } + } + } + }, + "produced_artifacts": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + } + }, "digest": { "type": "object", "additionalProperties": false, diff --git a/schemas/renderflow-v2.schema.json b/schemas/renderflow-v2.schema.json index fef0f94..a742988 100644 --- a/schemas/renderflow-v2.schema.json +++ b/schemas/renderflow-v2.schema.json @@ -54,6 +54,38 @@ }, "type": "object" }, + "contentRedaction": { + "additionalProperties": false, + "properties": { + "classes": { + "default": [], + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "determinism": { + "default": "deterministic", + "enum": [ + "deterministic", + "probabilistic" + ] + }, + "provider": { + "minLength": 1, + "type": "string" + }, + "reviewed": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "provider" + ], + "type": "object" + }, "executionPolicy": { "additionalProperties": false, "properties": { @@ -68,6 +100,16 @@ "budgets": { "$ref": "#/$defs/budgets" }, + "hygiene_policy": { + "anyOf": [ + { + "$ref": "#/$defs/stableId" + }, + { + "type": "null" + } + ] + }, "max_parallel": { "default": 1, "minimum": 1, @@ -109,12 +151,6 @@ "null" ] }, - "hygiene_policy": { - "anyOf": [ - { "$ref": "#/$defs/stableId" }, - { "type": "null" } - ] - }, "reject_loss_classes": { "default": [], "items": { @@ -156,6 +192,69 @@ }, "type": "object" }, + "hygienePolicy": { + "additionalProperties": false, + "properties": { + "audience": { + "default": "candidate", + "enum": [ + "candidate", + "private", + "public", + "commercial" + ] + }, + "metadata": { + "$ref": "#/$defs/metadataHygiene" + }, + "protected_references": { + "$ref": "#/$defs/protectedReferences" + }, + "redaction": { + "anyOf": [ + { + "$ref": "#/$defs/contentRedaction" + }, + { + "type": "null" + } + ] + }, + "rights": { + "$ref": "#/$defs/rightsHygiene" + }, + "secrets": { + "$ref": "#/$defs/secretHygiene" + } + }, + "type": "object" + }, + "metadataHygiene": { + "additionalProperties": false, + "properties": { + "allow": { + "default": [], + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "allowlist_only": { + "default": false, + "type": "boolean" + }, + "deny": { + "default": [], + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "outputLayout": { "additionalProperties": false, "properties": { @@ -180,120 +279,203 @@ }, "type": "object" }, - "hygienePolicy": { + "profile": { "additionalProperties": false, "properties": { - "audience": { - "default": "candidate", - "enum": ["candidate", "private", "public", "commercial"] + "all_reachable": { + "default": false, + "type": "boolean" }, - "metadata": { "$ref": "#/$defs/metadataHygiene" }, - "secrets": { "$ref": "#/$defs/secretHygiene" }, - "protected_references": { "$ref": "#/$defs/protectedReferences" }, - "redaction": { + "description": { + "type": [ + "string", + "null" + ] + }, + "exclude": { + "$ref": "#/$defs/selectorSet" + }, + "extends": { + "default": [], + "items": { + "$ref": "#/$defs/stableId" + }, + "type": "array" + }, + "hygiene_policy": { "anyOf": [ - { "$ref": "#/$defs/contentRedaction" }, - { "type": "null" } + { + "$ref": "#/$defs/stableId" + }, + { + "type": "null" + } ] }, - "rights": { "$ref": "#/$defs/rightsHygiene" } - }, - "type": "object" - }, - "metadataHygiene": { - "additionalProperties": false, - "properties": { - "allow": { "default": [], "items": { "minLength": 1, "type": "string" }, "type": "array" }, - "deny": { "default": [], "items": { "minLength": 1, "type": "string" }, "type": "array" }, - "allowlist_only": { "default": false, "type": "boolean" } + "include": { + "$ref": "#/$defs/selectorSet" + }, + "intermediates": { + "enum": [ + "cache_only", + "retain" + ] + }, + "policy": { + "$ref": "#/$defs/profilePolicy" + }, + "schema": { + "const": "renderflow.profile/v1", + "default": "renderflow.profile/v1" + }, + "targets": { + "default": [], + "items": { + "$ref": "#/$defs/target" + }, + "type": "array" + } }, "type": "object" }, - "secretHygiene": { + "profilePolicy": { "additionalProperties": false, "properties": { - "enabled": { "default": true, "type": "boolean" }, - "block": { "default": true, "type": "boolean" }, - "markers": { "default": [], "items": { "minLength": 1, "type": "string" }, "type": "array" } + "ai": { + "enum": [ + "deny", + "local_only", + "allow" + ] + }, + "budgets": { + "$ref": "#/$defs/budgets" + }, + "minimum_fidelity": { + "maximum": 1.0, + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "network": { + "enum": [ + "deny", + "allow" + ] + }, + "publication_policy": { + "type": [ + "string", + "null" + ] + }, + "redaction_policy": { + "type": [ + "string", + "null" + ] + }, + "requirements": { + "$ref": "#/$defs/requirements" + }, + "validation": { + "$ref": "#/$defs/validation" + } }, "type": "object" }, "protectedReferences": { "additionalProperties": false, "properties": { - "terms": { "default": [], "items": { "minLength": 1, "type": "string" }, "type": "array" }, - "block": { "default": true, "type": "boolean" }, - "case_sensitive": { "default": false, "type": "boolean" } + "block": { + "default": true, + "type": "boolean" + }, + "case_sensitive": { + "default": false, + "type": "boolean" + }, + "terms": { + "default": [], + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + } }, "type": "object" }, - "contentRedaction": { + "requirements": { "additionalProperties": false, "properties": { - "provider": { "minLength": 1, "type": "string" }, - "determinism": { "default": "deterministic", "enum": ["deterministic", "probabilistic"] }, - "classes": { "default": [], "items": { "minLength": 1, "type": "string" }, "type": "array" }, - "reviewed": { "default": false, "type": "boolean" } + "deterministic": { + "default": false, + "type": "boolean" + }, + "local_only": { + "default": false, + "type": "boolean" + }, + "offline": { + "default": false, + "type": "boolean" + } }, - "required": ["provider"], "type": "object" }, "rightsHygiene": { "additionalProperties": false, "properties": { - "required": { "default": false, "type": "boolean" }, - "license": { "type": ["string", "null"] }, - "rights_holder": { "type": ["string", "null"] }, - "approval_reference": { "type": ["string", "null"] }, - "reviewed": { "default": false, "type": "boolean" } - }, - "type": "object" - }, - "profile": { - "additionalProperties": false, - "properties": { - "description": { + "approval_reference": { "type": [ "string", "null" ] }, - "exclude": { - "$ref": "#/$defs/selectorSet" + "license": { + "type": [ + "string", + "null" + ] }, - "include": { - "$ref": "#/$defs/selectorSet" + "required": { + "default": false, + "type": "boolean" }, - "hygiene_policy": { - "anyOf": [ - { "$ref": "#/$defs/stableId" }, - { "type": "null" } - ] + "reviewed": { + "default": false, + "type": "boolean" }, - "targets": { - "default": [], - "items": { - "$ref": "#/$defs/target" - }, - "type": "array" + "rights_holder": { + "type": [ + "string", + "null" + ] } }, "type": "object" }, - "requirements": { + "secretHygiene": { "additionalProperties": false, "properties": { - "deterministic": { - "default": false, + "block": { + "default": true, "type": "boolean" }, - "local_only": { - "default": false, + "enabled": { + "default": true, "type": "boolean" }, - "offline": { - "default": false, - "type": "boolean" + "markers": { + "default": [], + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" } }, "type": "object" @@ -329,6 +511,20 @@ }, "type": "array" }, + "providers": { + "default": [], + "items": { + "$ref": "#/$defs/stableId" + }, + "type": "array" + }, + "roles": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "transforms": { "default": [], "items": { @@ -468,12 +664,23 @@ } ] }, + "options": { + "default": {}, + "type": "object" + }, "preset": { "type": [ "string", "null" ] }, + "requirement": { + "default": "required", + "enum": [ + "required", + "optional" + ] + }, "role": { "type": [ "string", @@ -580,7 +787,9 @@ "$ref": "#/$defs/executionPolicy" }, "hygiene": { - "additionalProperties": { "$ref": "#/$defs/hygienePolicy" }, + "additionalProperties": { + "$ref": "#/$defs/hygienePolicy" + }, "default": {}, "type": "object" },