diff --git a/crates/renderflow-core/data/ai/skills/magazine-candidates-v1.json b/crates/renderflow-core/data/ai/skills/magazine-candidates-v1.json new file mode 100644 index 0000000..91484e0 --- /dev/null +++ b/crates/renderflow-core/data/ai/skills/magazine-candidates-v1.json @@ -0,0 +1,80 @@ +{ + "schema_version": "renderflow.ai-skill/v1", + "id": "skill.magazine.candidates", + "version": "1.0.0", + "purpose": "Propose schema-bound magazine asset-brief and metadata enhancements from sanitized Artifact DNA and a deterministic candidate.", + "artifact_families": ["artifact_dna", "publication", "metadata", "asset_brief"], + "operations": ["generation", "extraction", "schema_constrained_output"], + "input_modalities": ["structured_json"], + "output_modalities": ["structured_json", "metadata"], + "requires_json_schema": true, + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["dna", "deterministic_candidate", "intent"], + "properties": { + "dna": {"type": "string", "minLength": 1, "maxLength": 48000}, + "deterministic_candidate": {"type": "string", "minLength": 1, "maxLength": 32000}, + "intent": {"type": "string", "minLength": 1, "maxLength": 2000} + }, + "additionalProperties": false + }, + "output_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["asset_brief", "metadata", "review_required"], + "properties": { + "asset_brief": { + "type": "object", + "required": ["layout_guidance", "palette_guidance", "typography_guidance", "accessibility_guidance", "negative_guidance"], + "properties": { + "layout_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}}, + "palette_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}}, + "typography_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}}, + "accessibility_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}}, + "negative_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}} + }, + "additionalProperties": false + }, + "metadata": { + "type": "object", + "required": ["descriptive_tags", "accessibility_notes"], + "properties": { + "descriptive_tags": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 120}}, + "accessibility_notes": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}} + }, + "additionalProperties": false + }, + "review_required": {"const": true} + }, + "additionalProperties": false + }, + "templates": { + "system": "You propose conservative, provider-neutral magazine asset guidance. Never generate publication copy, story content, artwork, or named-style imitation. Return only strict JSON.", + "instruction": "Use only sanitized evidence. Preserve the deterministic candidate as the baseline, omit creator, brand, franchise, protected-work, and verbatim-content references, and mark the result review_required true.", + "prompt": "Intent: {{intent}}\n\nValidated sanitized Artifact DNA:\n{{dna}}\n\nDeterministic baseline candidate:\n{{deterministic_candidate}}" + }, + "variables": [ + {"name": "dna", "required": true, "sensitive": false, "max_bytes": 48000}, + {"name": "deterministic_candidate", "required": true, "sensitive": false, "max_bytes": 32000}, + {"name": "intent", "required": true, "sensitive": false, "max_bytes": 2000} + ], + "max_rendered_prompt_bytes": 88000, + "generation": {"temperature": 0.1, "max_tokens": 1800, "seed": 42, "top_p": 0.9}, + "budgets": {"network": true, "remote_execution": true, "max_input_bytes": 84000, "max_output_bytes": 24000, "max_tokens": 2000, "max_duration_ms": 60000, "max_retries": 1, "max_cost_microunits": 50000}, + "hygiene": { + "policy_id": "policy.ai.magazine-candidates.public-safe/v1", + "scan_secrets": true, + "pii_action": "block", + "protected_reference_action": "rewrite", + "protected_references": [{"term": "Example Franchise", "descriptive_replacement": "source-independent visual characteristics"}], + "allow_private_remote_input": false, + "retain_raw_prompts": false, + "post_output_review": true + }, + "approval": {"initial_state": "candidate", "human_review_required": true, "validators": ["validator.json-contract/v1", "validator.ai-hygiene/v1", "validator.prompt-hygiene/v1", "validator.magazine-candidates/v1"]}, + "provenance": {"cache_identity_fields": ["skill", "model", "runtime", "input", "schemas", "settings", "hygiene"], "evidence_fields": ["provider", "runtime", "model", "skill", "digests", "usage", "candidate_state", "approvals"], "redact_raw_inputs": true, "redact_raw_prompts": true}, + "redistribution_notes": "The specification contains no publication or comic content and its fixture is synthetic.", + "license_notes": "Generated guidance remains a candidate; model and provider licenses require independent review.", + "fixture": {"dna": "{\"schema_version\":\"renderflow.artifact-dna/v1\",\"observations\":[]}", "deterministic_candidate": "{\"asset_brief\":{},\"metadata\":{}}", "intent": "Propose original cover-layout guidance without generating content."} +} diff --git a/crates/renderflow-core/src/ai/skill.rs b/crates/renderflow-core/src/ai/skill.rs index 07dc3a0..5842213 100644 --- a/crates/renderflow-core/src/ai/skill.rs +++ b/crates/renderflow-core/src/ai/skill.rs @@ -356,6 +356,7 @@ impl AiSkillRegistry { pub fn bundled() -> Result { let sources = [ include_str!("../../data/ai/skills/metadata-extraction-v1.json"), + include_str!("../../data/ai/skills/magazine-candidates-v1.json"), include_str!("../../data/ai/skills/visual-dna-v1.json"), include_str!("../../data/ai/skills/prompt-from-dna-v1.json"), include_str!("../../data/ai/skills/accessibility-description-v1.json"), @@ -647,7 +648,7 @@ mod tests { #[test] fn bundled_skills_are_valid_and_candidate_first() { let registry = AiSkillRegistry::bundled().unwrap(); - assert_eq!(registry.iter().count(), 4); + assert_eq!(registry.iter().count(), 5); assert!(registry.iter().all(|skill| { skill.approval.initial_state == AiCandidateState::Candidate && skill.approval.human_review_required diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 6ab558d..c54b338 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -283,6 +283,35 @@ pub fn run_cli(cli: Cli) -> Result<()> { EbookCommands::Capabilities { format } => commands::ebook::run_capabilities(&format)?, }, Some(Commands::Publication { subcommand }) => match subcommand { + PublicationCommands::MagazineCandidates { + config, + asset_role, + output, + format, + ai, + ai_catalog, + ai_preference, + allow_remote, + allow_unverified, + source_approved_for_ai, + privacy_approved_for_remote, + openai_endpoint, + openai_api_key_env, + } => commands::publication::run_magazine_candidates( + &config, + &asset_role, + output.as_deref(), + &format, + ai, + ai_catalog.as_deref(), + &ai_preference, + allow_remote, + allow_unverified, + source_approved_for_ai, + privacy_approved_for_remote, + openai_endpoint.as_deref(), + &openai_api_key_env, + )?, PublicationCommands::Lulu { subcommand } => match subcommand { LuluCommands::Rules { format, output } => { commands::publication::run_lulu_rules(&format, output.as_deref())? diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index e6107bd..6c4e54a 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -424,6 +424,48 @@ pub enum VideoCommands { #[derive(Subcommand)] pub enum PublicationCommands { + /// Produce candidate-only magazine briefs and metadata from Artifact DNA. + MagazineCandidates { + /// Renderflow v2 publication specification. + #[arg(long, default_value = "renderflow.yaml", value_name = "FILE")] + config: String, + /// Artwork role whose Artifact DNA sidecar should be consumed. + #[arg(long, value_name = "ROLE")] + asset_role: String, + /// Optional candidate output file. + #[arg(long, value_name = "FILE")] + output: Option, + /// Output format: json (default) or yaml. + #[arg(long, default_value = "json", value_name = "FORMAT")] + format: String, + /// Request an optional model-generated candidate through the AI skill runtime. + #[arg(long)] + ai: bool, + /// AI model catalog with observed availability and exact model provenance. + #[arg(long, value_name = "FILE")] + ai_catalog: Option, + /// Provider-neutral model selection preference. + #[arg(long, default_value = "local-only", value_name = "PREFERENCE")] + ai_preference: String, + /// Explicitly permit selection of a remote provider. + #[arg(long)] + allow_remote: bool, + /// Permit selection of catalog entries whose availability is unverified. + #[arg(long)] + allow_unverified: bool, + /// Confirm that source and rights evidence permit model exposure. + #[arg(long)] + source_approved_for_ai: bool, + /// Confirm that privacy review permits remote exposure. + #[arg(long)] + privacy_approved_for_remote: bool, + /// Optional OpenAI-compatible endpoint; used only after remote opt-in. + #[arg(long, value_name = "URL")] + openai_endpoint: Option, + /// Environment variable containing an OpenAI-compatible API key. + #[arg(long, default_value = "OPENAI_API_KEY", value_name = "NAME")] + openai_api_key_env: String, + }, /// Evaluate candidates with the bundled, offline Lulu provider pack. Lulu { #[command(subcommand)] diff --git a/crates/renderflow-core/src/commands/publication.rs b/crates/renderflow-core/src/commands/publication.rs index e668c87..b3c9af0 100644 --- a/crates/renderflow-core/src/commands/publication.rs +++ b/crates/renderflow-core/src/commands/publication.rs @@ -1,12 +1,130 @@ use std::fs; use std::path::Path; +use std::str::FromStr; use anyhow::{Context, Result}; use serde::Serialize; +use crate::ai::{ + AiExecutionPreferenceV1, AiModelCatalog, AiSkillRegistry, AiSkillRuntime, OllamaProvider, + OpenAiProvider, +}; +use crate::artifact::ArtifactStore; +use crate::dna::ArtifactDna; use crate::publication::lulu::{ evaluate_request, LuluConformanceReport, LuluEligibility, LuluRulePack, }; +use crate::publication::magazine::{ + build_magazine_candidates, create_magazine_ai_request, MagazineCandidatePolicy, +}; +use crate::spec::load_spec; + +#[allow(clippy::too_many_arguments)] +pub fn run_magazine_candidates( + config: &str, + asset_role: &str, + output: Option<&str>, + format: &str, + use_ai: bool, + ai_catalog: Option<&str>, + ai_preference: &str, + allow_remote: bool, + allow_unverified: bool, + source_approved_for_ai: bool, + privacy_approved_for_remote: bool, + openai_endpoint: Option<&str>, + openai_api_key_env: &str, +) -> Result<()> { + if allow_remote && !use_ai { + anyhow::bail!("--allow-remote requires --ai"); + } + if privacy_approved_for_remote && !allow_remote { + anyhow::bail!("--privacy-approved-for-remote requires --allow-remote"); + } + let loaded = load_spec(config)?; + let publication = loaded + .spec + .publication + .as_ref() + .context("magazine candidates require a publication contract")?; + let asset = publication + .artwork + .iter() + .find(|asset| asset.role == asset_role) + .with_context(|| format!("publication artwork role '{asset_role}' was not found"))?; + let dna_reference = asset.artifact_dna.as_deref().with_context(|| { + format!("publication artwork role '{asset_role}' does not reference artifact_dna") + })?; + let config_directory = Path::new(config).parent().unwrap_or_else(|| Path::new(".")); + let dna_path = config_directory.join(dna_reference); + let dna = ArtifactDna::load(&dna_path)?; + let hygiene = loaded + .spec + .execution + .hygiene_policy + .as_deref() + .and_then(|id| loaded.spec.hygiene.get(id)); + let policy = MagazineCandidatePolicy { + protected_references: hygiene + .map(|policy| policy.protected_references.terms.clone()) + .unwrap_or_default(), + reject_pii: true, + reject_secrets: hygiene + .map(|policy| policy.secrets.enabled && policy.secrets.block) + .unwrap_or(true), + secret_markers: hygiene + .map(|policy| policy.secrets.markers.clone()) + .unwrap_or_default(), + }; + let mut candidate = build_magazine_candidates(publication, asset, &dna, &policy)?; + + if use_ai { + let preference = AiExecutionPreferenceV1::from_str(ai_preference)?; + if preference == AiExecutionPreferenceV1::RemoteOnly && !allow_remote { + anyhow::bail!("remote-only AI preference requires --allow-remote"); + } + let catalog = match ai_catalog { + Some(path) => AiModelCatalog::load(path)?, + None => AiModelCatalog::bundled()?, + }; + let skills = AiSkillRegistry::bundled()?; + let ollama = OllamaProvider::default_local(); + let mut openai = OpenAiProvider::new().with_api_key_env(openai_api_key_env); + if let Some(endpoint) = openai_endpoint { + openai = openai.with_endpoint(endpoint); + } + let runtime = AiSkillRuntime::new(&catalog, &skills, vec![&ollama, &openai]); + let rights_approved = publication.rights.reviewed + && asset + .approval_reference + .as_deref() + .is_some_and(|reference| !reference.trim().is_empty()); + let request = create_magazine_ai_request( + &candidate, + &dna, + &policy, + preference, + allow_remote, + allow_unverified, + source_approved_for_ai && rights_approved, + privacy_approved_for_remote, + )?; + let store_root = std::env::temp_dir() + .join("renderflow") + .join("magazine-ai-candidates"); + let store = ArtifactStore::new(store_root)?; + match runtime.execute(&request, &store) { + Ok(outcome) => candidate.attach_ai_outcome(outcome, &store)?, + Err(error) => { + candidate.mark_ai_unavailable(); + eprintln!("Optional AI candidate unavailable: {error:#}"); + } + } + } + + candidate.validate()?; + emit(&candidate, format, output) +} pub fn run_lulu_rules(format: &str, output: Option<&str>) -> Result<()> { emit(&LuluRulePack::builtin()?, format, output) diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index cea152c..326c469 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -68,6 +68,11 @@ pub use intake::{ IntakeSignalKind, ProvenanceValue, ProviderInspection, ResolvedArtifactProfile, INTAKE_SCHEMA_V1, }; +pub use publication::magazine::{ + build_magazine_candidates, create_magazine_ai_request, MagazineAiCandidate, MagazineAiStatus, + MagazineCandidateEnvelope, MagazineCandidatePolicy, MAGAZINE_CANDIDATE_SCHEMA_V1, + MAGAZINE_CANDIDATE_SKILL_ID_V1, +}; pub use sdk::{ ArtifactProfile, CancellationToken, DiagnosticReport, Engine, EngineBuilder, ExecutionRequest, ExecutionResult, InspectionRequest, PlanRequest, ProgressEvent, ProgressReporter, diff --git a/crates/renderflow-core/src/publication.rs b/crates/renderflow-core/src/publication.rs index d161b4a..eeac7bc 100644 --- a/crates/renderflow-core/src/publication.rs +++ b/crates/renderflow-core/src/publication.rs @@ -1,6 +1,7 @@ //! Provider-neutral publication contracts and deterministic release metadata. pub mod lulu; +pub mod magazine; use std::collections::BTreeMap; use std::fs; @@ -52,6 +53,11 @@ pub struct PublicationContributor { pub struct PublicationAsset { pub role: String, pub path: String, + /// Optional validated Artifact DNA sidecar used to derive candidate-only + /// visual and layout guidance. The path is relative to the publication + /// specification. + #[serde(default)] + pub artifact_dna: Option, #[serde(default)] pub alt_text: Option, #[serde(default)] diff --git a/crates/renderflow-core/src/publication/magazine.rs b/crates/renderflow-core/src/publication/magazine.rs new file mode 100644 index 0000000..eda632c --- /dev/null +++ b/crates/renderflow-core/src/publication/magazine.rs @@ -0,0 +1,639 @@ +//! Deterministic, candidate-only magazine guidance derived from Artifact DNA. +//! +//! The compiler consumes only validated visual/layout observations. Optional +//! model assistance is prepared as a request for the provider-neutral AI skill +//! runtime and remains a distinct, review-required candidate. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::ai::{ + AiCandidateState, AiExecutionEvidence, AiExecutionPreferenceV1, AiInputArtifactEvidence, + AiProtectedReferenceRule, AiSkillExecutionOutcome, AiSkillExecutionRequest, +}; +use crate::artifact::ArtifactStore; +use crate::dna::{ + ArtifactDna, DnaExtractorEvidence, DnaHygieneStatus, DnaModality, DnaValidationStatus, +}; +use crate::evidence::DigestEvidence; +use crate::publication::{PublicationAsset, PublicationContract}; + +pub const MAGAZINE_CANDIDATE_SCHEMA_V1: &str = "renderflow.magazine-candidates/v1"; +pub const MAGAZINE_CANDIDATE_SKILL_ID_V1: &str = "skill.magazine.candidates"; +const MAGAZINE_CANDIDATE_POLICY_V1: &str = "policy.magazine-candidates.public-safe/v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MagazineCandidatePolicy { + #[serde(default)] + pub protected_references: Vec, + #[serde(default = "default_true")] + pub reject_pii: bool, + #[serde(default = "default_true")] + pub reject_secrets: bool, + #[serde(default)] + pub secret_markers: Vec, +} + +impl Default for MagazineCandidatePolicy { + fn default() -> Self { + Self { + protected_references: Vec::new(), + reject_pii: true, + reject_secrets: true, + secret_markers: Vec::new(), + } + } +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MagazineGuidanceItem { + pub observation_id: String, + pub dimension: String, + pub value: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub confidence: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MagazineAssetBriefCandidate { + pub state: AiCandidateState, + pub asset_role: String, + pub purpose: String, + pub layout_guidance: Vec, + pub palette_guidance: Vec, + pub typography_guidance: Vec, + pub accessibility_guidance: Vec, + pub negative_guidance: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MagazineMetadataCandidate { + pub state: AiCandidateState, + pub asset_role: String, + pub source_format: String, + pub source_media_type: String, + pub descriptive_properties: BTreeMap, + pub accessibility_review_required: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MagazineAiStatus { + NotRequested, + Unavailable, + Produced, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MagazineAiCandidate { + pub status: MagazineAiStatus, + pub skill_id: String, + pub skill_version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub candidate: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MagazineCandidateSourceEvidence { + pub publication_issue_id: String, + pub asset_role: String, + pub source_artifact_id: String, + pub source_digest: DigestEvidence, + pub dna_digest: DigestEvidence, + pub dna_schema_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MagazineCandidateProvenance { + pub generator: String, + pub generator_version: String, + pub policy_id: String, + pub policy_digest: DigestEvidence, + pub settings_digest: DigestEvidence, + pub source_digest: DigestEvidence, + pub dna_digest: DigestEvidence, + pub dna_policy_id: String, + pub dna_policy_digest: DigestEvidence, + pub dna_configuration_digest: DigestEvidence, + pub dna_extractors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MagazineCandidateValidation { + pub schema_valid: bool, + pub status: String, + pub validators: Vec, + pub publication_content_generated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MagazineCandidateApproval { + pub state: AiCandidateState, + pub human_review_required: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_reference: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MagazineCandidateEnvelope { + pub schema_version: String, + pub candidate_id: String, + pub source: MagazineCandidateSourceEvidence, + pub asset_brief: MagazineAssetBriefCandidate, + pub metadata: MagazineMetadataCandidate, + pub ai: MagazineAiCandidate, + pub provenance: MagazineCandidateProvenance, + pub validation: MagazineCandidateValidation, + pub approval: MagazineCandidateApproval, +} + +impl MagazineCandidateEnvelope { + /// Validate the stable envelope invariants before serialization or use by + /// downstream publication tooling. + pub fn validate(&self) -> Result<()> { + if self.schema_version != MAGAZINE_CANDIDATE_SCHEMA_V1 { + anyhow::bail!("unsupported magazine candidate schema"); + } + if !self.candidate_id.starts_with("magazine:sha256:") + || self.candidate_id.len() != "magazine:sha256:".len() + 64 + { + anyhow::bail!("magazine candidate id is not a SHA-256 identity"); + } + if self.asset_brief.state != AiCandidateState::Candidate + || self.metadata.state != AiCandidateState::Candidate + || self.approval.state != AiCandidateState::Candidate + || !self.approval.human_review_required + { + anyhow::bail!( + "magazine asset briefs and metadata must remain review-required candidates" + ); + } + if !self.validation.schema_valid || self.validation.status != "valid_review_required" { + anyhow::bail!("magazine candidate validation evidence is incomplete"); + } + if self.validation.publication_content_generated { + anyhow::bail!("magazine candidate infrastructure cannot generate publication content"); + } + for digest in [ + &self.source.source_digest, + &self.source.dna_digest, + &self.provenance.policy_digest, + &self.provenance.settings_digest, + &self.provenance.source_digest, + &self.provenance.dna_digest, + &self.provenance.dna_policy_digest, + &self.provenance.dna_configuration_digest, + ] { + if digest.algorithm != "sha256" + || digest.value.len() != 64 + || !digest.value.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + anyhow::bail!("magazine candidate contains invalid digest evidence"); + } + } + match self.ai.status { + MagazineAiStatus::Produced => { + let execution = self + .ai + .execution + .as_ref() + .context("produced AI candidate is missing execution evidence")?; + if self.ai.candidate.is_none() + || self.ai.provider_id.as_deref() != Some(&execution.identity.provider_id) + || self.ai.model_id.as_deref() != Some(&execution.identity.model_id) + || execution.output_state != AiCandidateState::Candidate + || !execution.approval_required + { + anyhow::bail!("produced AI guidance must remain an evidenced candidate"); + } + } + MagazineAiStatus::NotRequested | MagazineAiStatus::Unavailable => { + if self.ai.candidate.is_some() || self.ai.execution.is_some() { + anyhow::bail!("non-produced AI state cannot contain model output"); + } + } + } + Ok(()) + } + + pub fn attach_ai_outcome( + &mut self, + outcome: AiSkillExecutionOutcome, + store: &ArtifactStore, + ) -> Result<()> { + let bytes = store.read_bytes(&outcome.artifact)?; + let candidate: Value = serde_json::from_slice(&bytes) + .context("AI magazine candidate artifact is not valid JSON")?; + self.ai = MagazineAiCandidate { + status: MagazineAiStatus::Produced, + skill_id: outcome.evidence.skill_id.clone(), + skill_version: outcome.evidence.skill_version.clone(), + provider_id: Some(outcome.evidence.identity.provider_id.clone()), + model_id: Some(outcome.evidence.identity.model_id.clone()), + candidate: Some(candidate), + execution: Some(outcome.evidence), + diagnostics: Vec::new(), + }; + Ok(()) + } + + pub fn mark_ai_unavailable(&mut self) { + self.ai.status = MagazineAiStatus::Unavailable; + self.ai + .diagnostics + .push("magazine.ai.compatible_execution_unavailable".to_string()); + } +} + +pub fn build_magazine_candidates( + publication: &PublicationContract, + asset: &PublicationAsset, + dna: &ArtifactDna, + policy: &MagazineCandidatePolicy, +) -> Result { + validate_dna_for_magazine(dna, policy)?; + let dna_bytes = serde_json::to_vec(dna)?; + let dna_digest = digest_bytes(&dna_bytes); + let policy_digest = digest_json(policy)?; + let settings_digest = digest_json(&serde_json::json!({ + "schema_version": MAGAZINE_CANDIDATE_SCHEMA_V1, + "asset_role": asset.role, + "compiler": "renderflow.builtin.magazine-candidates", + "compiler_version": env!("CARGO_PKG_VERSION"), + }))?; + + let excluded = dna + .similarity_guidance + .excluded_dimensions + .iter() + .map(String::as_str) + .collect::>(); + let mut layout_guidance = Vec::new(); + let mut palette_guidance = Vec::new(); + let mut typography_guidance = Vec::new(); + let mut accessibility_guidance = Vec::new(); + let mut descriptive_properties = BTreeMap::new(); + + for observation in &dna.observations { + if !observation.similarity_eligible + || excluded.contains(observation.dimension.as_str()) + || is_identity_or_content_dimension(&observation.dimension) + { + continue; + } + let item = MagazineGuidanceItem { + observation_id: observation.id.clone(), + dimension: observation.dimension.clone(), + value: observation.value.clone(), + description: observation.description.clone(), + confidence: observation.evidence.confidence, + }; + if observation.dimension.starts_with("typography.") + || observation.dimension.starts_with("layout.typography") + { + typography_guidance.push(item); + } else if observation.dimension.starts_with("layout.") + || observation.dimension.starts_with("visual.canvas") + { + layout_guidance.push(item); + } else if observation.dimension.starts_with("visual.palette") + || observation.dimension.starts_with("visual.color") + { + palette_guidance.push(item); + } else if observation.dimension.starts_with("accessibility.") { + accessibility_guidance.push(item); + } + descriptive_properties.insert(observation.dimension.clone(), observation.value.clone()); + } + + let source = MagazineCandidateSourceEvidence { + publication_issue_id: publication.issue_id.clone(), + asset_role: asset.role.clone(), + source_artifact_id: dna.source.artifact_id.clone(), + source_digest: dna.source.digest.clone(), + dna_digest: dna_digest.clone(), + dna_schema_version: dna.schema_version.clone(), + }; + let mut envelope = MagazineCandidateEnvelope { + schema_version: MAGAZINE_CANDIDATE_SCHEMA_V1.to_string(), + candidate_id: String::new(), + source, + asset_brief: MagazineAssetBriefCandidate { + state: AiCandidateState::Candidate, + asset_role: asset.role.clone(), + purpose: format!( + "Original {} asset for publication issue {}; guidance only, no content generation", + asset.role, publication.issue_id + ), + layout_guidance, + palette_guidance, + typography_guidance, + accessibility_guidance, + negative_guidance: vec![ + "Do not imitate or name a creator, brand, franchise, or protected work".to_string(), + "Do not generate or rewrite editorial or comic content".to_string(), + "Do not treat this candidate as approved publication metadata".to_string(), + ], + }, + metadata: MagazineMetadataCandidate { + state: AiCandidateState::Candidate, + asset_role: asset.role.clone(), + source_format: dna.source.format.clone(), + source_media_type: dna.source.media_type.clone(), + descriptive_properties, + accessibility_review_required: true, + }, + ai: MagazineAiCandidate { + status: MagazineAiStatus::NotRequested, + skill_id: MAGAZINE_CANDIDATE_SKILL_ID_V1.to_string(), + skill_version: "1.0.0".to_string(), + provider_id: None, + model_id: None, + candidate: None, + execution: None, + diagnostics: Vec::new(), + }, + provenance: MagazineCandidateProvenance { + generator: "renderflow.builtin.magazine-candidates".to_string(), + generator_version: env!("CARGO_PKG_VERSION").to_string(), + policy_id: MAGAZINE_CANDIDATE_POLICY_V1.to_string(), + policy_digest, + settings_digest, + source_digest: dna.source.digest.clone(), + dna_digest, + dna_policy_id: dna.provenance.policy_id.clone(), + dna_policy_digest: dna.provenance.policy_digest.clone(), + dna_configuration_digest: dna.provenance.configuration_digest.clone(), + dna_extractors: dna.provenance.extractors.clone(), + }, + validation: MagazineCandidateValidation { + schema_valid: true, + status: "valid_review_required".to_string(), + validators: vec![ + "validator.magazine-candidates/v1".to_string(), + "validator.artifact-dna/v1".to_string(), + "validator.prompt-hygiene/v1".to_string(), + ], + publication_content_generated: false, + }, + approval: MagazineCandidateApproval { + state: AiCandidateState::Candidate, + human_review_required: true, + approval_reference: None, + }, + }; + envelope.candidate_id = format!( + "magazine:sha256:{}", + digest_json(&serde_json::json!({ + "source": envelope.source, + "asset_brief": envelope.asset_brief, + "metadata": envelope.metadata, + "provenance": envelope.provenance, + }))? + .value + ); + envelope.validate()?; + Ok(envelope) +} + +#[allow(clippy::too_many_arguments)] +pub fn create_magazine_ai_request( + candidate: &MagazineCandidateEnvelope, + dna: &ArtifactDna, + policy: &MagazineCandidatePolicy, + preference: AiExecutionPreferenceV1, + allow_remote: bool, + allow_unverified: bool, + source_approved: bool, + privacy_approved_for_remote: bool, +) -> Result { + validate_dna_for_magazine(dna, policy)?; + let dna_json = serde_json::to_string(dna)?; + let deterministic_candidate = serde_json::to_string(&serde_json::json!({ + "asset_brief": candidate.asset_brief, + "metadata": candidate.metadata, + }))?; + Ok(AiSkillExecutionRequest { + skill_id: MAGAZINE_CANDIDATE_SKILL_ID_V1.to_string(), + skill_version: Some("1.0.0".to_string()), + input: serde_json::json!({ + "dna": dna_json, + "deterministic_candidate": deterministic_candidate, + "intent": candidate.asset_brief.purpose, + }), + input_artifacts: vec![AiInputArtifactEvidence { + artifact_id: dna.source.artifact_id.clone(), + digest: dna.source.digest.value.clone(), + media_type: dna.source.media_type.clone(), + approved_for_ai: source_approved, + }], + preference, + allow_remote, + allow_unverified, + source_approved, + privacy_approved_for_remote, + additional_protected_references: policy + .protected_references + .iter() + .map(|term| AiProtectedReferenceRule { + term: term.clone(), + descriptive_replacement: Some( + "source-independent visual characteristics".to_string(), + ), + }) + .collect(), + }) +} + +fn validate_dna_for_magazine(dna: &ArtifactDna, policy: &MagazineCandidatePolicy) -> Result<()> { + dna.validate()?; + if !dna.validation.schema_valid + || dna.validation.status == DnaValidationStatus::Blocked + || dna.hygiene.status == DnaHygieneStatus::Blocked + { + anyhow::bail!("magazine candidates require validated, non-blocked Artifact DNA"); + } + if dna.hygiene.raw_payload_retained || dna.hygiene.identifying_metadata_retained { + anyhow::bail!( + "magazine candidates require sanitized Artifact DNA without raw or identifying data" + ); + } + if !dna + .modalities + .iter() + .any(|modality| matches!(modality, DnaModality::Visual | DnaModality::Layout)) + { + anyhow::bail!("magazine candidates require visual or layout Artifact DNA"); + } + if dna.similarity_guidance.direct_imitation_allowed { + anyhow::bail!("magazine candidates reject Artifact DNA that permits direct imitation"); + } + let eligible_text = dna + .observations + .iter() + .filter(|observation| observation.similarity_eligible) + .map(|observation| { + format!( + "{} {} {}", + observation.dimension, + observation.description.as_deref().unwrap_or_default(), + observation.value + ) + }) + .collect::>() + .join("\n"); + let lower = eligible_text.to_ascii_lowercase(); + if policy.reject_secrets && contains_secret(&eligible_text, &policy.secret_markers) { + anyhow::bail!("Artifact DNA failed the magazine secret-hygiene gate"); + } + if policy.reject_pii && contains_email(&eligible_text) { + anyhow::bail!("Artifact DNA failed the magazine privacy gate"); + } + if policy + .protected_references + .iter() + .any(|term| !term.trim().is_empty() && lower.contains(&term.to_ascii_lowercase())) + { + anyhow::bail!("Artifact DNA failed the magazine protected-reference gate"); + } + Ok(()) +} + +fn is_identity_or_content_dimension(dimension: &str) -> bool { + dimension.starts_with("identity.") + || dimension.starts_with("textual.content") + || dimension.starts_with("copyright.") +} + +fn contains_email(value: &str) -> bool { + value.split_ascii_whitespace().any(|word| { + let word = word.trim_matches(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '@' | '.' | '_' | '-' | '+') + }); + let Some((local, domain)) = word.split_once('@') else { + return false; + }; + !local.is_empty() + && domain.contains('.') + && !domain.starts_with('.') + && !domain.ends_with('.') + }) +} + +fn contains_secret(value: &str, additional_markers: &[String]) -> bool { + let lower = value.to_ascii_lowercase(); + [ + "-----begin private key-----", + "api_key=", + "api-key:", + "bearer eyj", + ] + .iter() + .any(|needle| lower.contains(needle)) + || additional_markers + .iter() + .any(|marker| !marker.is_empty() && lower.contains(&marker.to_ascii_lowercase())) +} + +fn digest_json(value: &impl Serialize) -> Result { + Ok(digest_bytes(&serde_json::to_vec(value)?)) +} + +fn digest_bytes(bytes: &[u8]) -> DigestEvidence { + let mut hasher = Sha256::new(); + hasher.update(bytes); + DigestEvidence { + algorithm: "sha256".to_string(), + value: format!("{:x}", hasher.finalize()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_dna() -> ArtifactDna { + ArtifactDna::from_json(include_str!( + "../../../../tests/fixtures/artifact-dna/visual.json" + )) + .unwrap() + } + + fn publication() -> PublicationContract { + serde_yaml_ng::from_str(include_str!( + "../../../../examples/magazine/renderflow.yaml" + )) + .map(|spec: crate::spec::SpecV2| spec.publication.unwrap()) + .unwrap() + } + + #[test] + fn deterministic_candidate_uses_only_safe_visual_layout_evidence() { + let publication = publication(); + let asset = publication + .artwork + .iter() + .find(|asset| asset.role == "cover") + .unwrap(); + let candidate = build_magazine_candidates( + &publication, + asset, + &fixture_dna(), + &MagazineCandidatePolicy::default(), + ) + .unwrap(); + assert_eq!(candidate.ai.status, MagazineAiStatus::NotRequested); + assert_eq!(candidate.approval.state, AiCandidateState::Candidate); + assert_eq!(candidate.asset_brief.layout_guidance.len(), 1); + assert_eq!(candidate.asset_brief.palette_guidance.len(), 1); + assert!(candidate + .metadata + .descriptive_properties + .contains_key("visual.palette.hex")); + let encoded = serde_json::to_string(&candidate).unwrap(); + serde_json::from_str::(&encoded) + .unwrap() + .validate() + .unwrap(); + } + + #[test] + fn protected_reference_gate_runs_before_ai_request_creation() { + let publication = publication(); + let asset = &publication.artwork[0]; + let mut dna = fixture_dna(); + dna.observations[0].description = Some("Example Protected Franchise layout".to_string()); + let policy = MagazineCandidatePolicy { + protected_references: vec!["Example Protected Franchise".to_string()], + ..MagazineCandidatePolicy::default() + }; + assert!(build_magazine_candidates(&publication, asset, &dna, &policy).is_err()); + } +} diff --git a/crates/renderflow-core/src/spec.rs b/crates/renderflow-core/src/spec.rs index c52370a..7860db1 100644 --- a/crates/renderflow-core/src/spec.rs +++ b/crates/renderflow-core/src/spec.rs @@ -1378,6 +1378,7 @@ pub fn json_schema() -> Value { "properties": { "role": {"type": "string", "minLength": 1}, "path": {"type": "string", "minLength": 1}, + "artifact_dna": {"type": ["string", "null"]}, "alt_text": {"type": ["string", "null"]}, "approval_reference": {"type": ["string", "null"]} } diff --git a/docs/cli-reference/publication.md b/docs/cli-reference/publication.md index 6e3a2aa..be2e549 100644 --- a/docs/cli-reference/publication.md +++ b/docs/cli-reference/publication.md @@ -3,6 +3,22 @@ Inspect pinned provider rules and preflight local publication candidates. These commands never authenticate, upload, allocate an ISBN, order a proof, or publish. +## Magazine candidates + +```bash +renderflow publication magazine-candidates \ + --config "examples/magazine/renderflow.yaml" \ + --asset-role "cover" \ + --output "cover-candidates.json" +``` + +This local-only default consumes the artwork role's validated `artifact_dna` +sidecar and emits a schema-bound, review-required asset-brief and metadata +candidate. Add `--ai` and an execution-ready model catalog to request an +additional candidate through the registered AI skill runtime. Local models are +preferred; remote selection also requires `--allow-remote`, and remote exposure +of privacy-reviewed input requires `--privacy-approved-for-remote`. + ## Lulu rules ```bash diff --git a/docs/user-guide/magazine-publications.md b/docs/user-guide/magazine-publications.md index 4203c34..99d5f9a 100644 --- a/docs/user-guide/magazine-publications.md +++ b/docs/user-guide/magazine-publications.md @@ -8,6 +8,52 @@ Add a `publication` block using `renderflow.publication/v1`. It records the publ Use `examples/magazine/renderflow.yaml` as a complete redistribution-safe starting point. Keep editorial content and approved assets in the content repository; Renderflow owns derivative planning, execution evidence, hygiene, validation, and packaging metadata. +An artwork entry may reference a validated `renderflow.artifact-dna/v1` sidecar +with `artifact_dna`. Generate a deterministic, review-required asset brief and +metadata candidate without a network or model: + +```bash +renderflow publication magazine-candidates \ + --config "examples/magazine/renderflow.yaml" \ + --asset-role "cover" \ + --output "cover-candidates.json" +``` + +The output uses `renderflow.magazine-candidates/v1` and records source and DNA +digests, compiler settings, hygiene policy, validation, approval state, and AI +status. Only reusable visual/layout evidence is copied. Identity, protected-work, +and verbatim-content dimensions are excluded, and the result is never promoted +from `candidate` automatically. + +## Optional AI candidate + +AI is disabled unless `--ai` is present. It resolves the registered +`skill.magazine.candidates@1.0.0` contract through the provider-neutral skill +runtime. Local/open models are preferred by default: + +```bash +renderflow publication magazine-candidates \ + --config "examples/magazine/renderflow.yaml" \ + --asset-role "cover" \ + --ai \ + --ai-catalog "local-models.json" \ + --source-approved-for-ai \ + --output "cover-candidates.json" +``` + +The catalog must record the selected local model as available plus its exact +runtime/model provenance. If no compatible execution-ready model is available, +the command still emits the deterministic candidate and records AI as +`unavailable`. + +Remote providers require `--allow-remote`; privacy-reviewed remote input also +requires `--privacy-approved-for-remote`. Rights/source approval, privacy, +secret, protected-reference, copyright/imitation, and prompt/output hygiene +gates run before or around execution. Provider output remains a separate +schema-validated candidate with full #396 execution evidence; it is never +silently merged into publication metadata or used to generate publication or +comic content. + Statuses are `draft`, `reviewed`, `approved`, and `released`. An `approved` or `released` contract is rejected unless it contains a license, rights holder, approval reference, an explicit rights review, and a selected hygiene policy whose rights gate is required and reviewed. Renderflow records that decision; it does not make a legal conclusion. ## Preview the artifact forest diff --git a/examples/magazine/assets/cover.dna.json b/examples/magazine/assets/cover.dna.json new file mode 100644 index 0000000..48141b9 --- /dev/null +++ b/examples/magazine/assets/cover.dna.json @@ -0,0 +1,224 @@ +{ + "schema_version": "renderflow.artifact-dna/v1", + "source": { + "artifact_id": "artifact:sha256:a7f7235672c1aa8bc012ff2b9dab401243c294ffae0866694cd773c7a82e81d2", + "digest": { + "algorithm": "sha256", + "value": "7808c6c0e63810064064b336b52dca4eb741f9ddbd9be20477d04d7977a2e2a4" + }, + "format": "svg", + "media_type": "image/svg+xml", + "size_bytes": 921 + }, + "modalities": [ + "visual", + "layout" + ], + "observations": [ + { + "id": "technical.format", + "modality": "visual", + "dimension": "technical.format", + "value": "svg", + "evidence": { + "origin": "provider_observed", + "provider_id": "renderflow.builtin.artifact-dna", + "provider_version": "0.2.1", + "determinism": "deterministic", + "confidence": 1.0 + }, + "similarity_eligible": false + }, + { + "id": "technical.media-type", + "modality": "visual", + "dimension": "technical.media_type", + "value": "image/svg+xml", + "evidence": { + "origin": "provider_observed", + "provider_id": "renderflow.builtin.artifact-dna", + "provider_version": "0.2.1", + "determinism": "deterministic", + "confidence": 1.0 + }, + "similarity_eligible": false + }, + { + "id": "technical.size-bytes", + "modality": "visual", + "dimension": "technical.size_bytes", + "value": 921, + "evidence": { + "origin": "provider_observed", + "provider_id": "renderflow.builtin.artifact-dna", + "provider_version": "0.2.1", + "determinism": "deterministic", + "confidence": 1.0 + }, + "similarity_eligible": false + }, + { + "id": "visual.canvas.height", + "modality": "visual", + "dimension": "visual.canvas.height_pixels", + "value": 1600, + "evidence": { + "origin": "provider_observed", + "provider_id": "renderflow.builtin.artifact-dna", + "provider_version": "0.2.1", + "determinism": "deterministic", + "confidence": 1.0 + }, + "similarity_eligible": true + }, + { + "id": "visual.canvas.width", + "modality": "visual", + "dimension": "visual.canvas.width_pixels", + "value": 1200, + "evidence": { + "origin": "provider_observed", + "provider_id": "renderflow.builtin.artifact-dna", + "provider_version": "0.2.1", + "determinism": "deterministic", + "confidence": 1.0 + }, + "similarity_eligible": true + }, + { + "id": "visual.svg.palette", + "modality": "visual", + "dimension": "visual.palette.hex", + "value": [ + "#30306f", + "#e3a857", + "#f5f0df" + ], + "evidence": { + "origin": "provider_observed", + "provider_id": "renderflow.builtin.artifact-dna", + "provider_version": "0.2.1", + "determinism": "deterministic", + "confidence": 1.0 + }, + "similarity_eligible": true + }, + { + "id": "visual.canvas.aspect-ratio", + "modality": "layout", + "dimension": "layout.canvas.aspect_ratio", + "value": 0.75, + "evidence": { + "origin": "provider_observed", + "provider_id": "renderflow.builtin.artifact-dna", + "provider_version": "0.2.1", + "determinism": "deterministic", + "confidence": 1.0 + }, + "similarity_eligible": true + }, + { + "id": "visual.canvas.orientation", + "modality": "layout", + "dimension": "layout.canvas.orientation", + "value": "portrait", + "evidence": { + "origin": "provider_observed", + "provider_id": "renderflow.builtin.artifact-dna", + "provider_version": "0.2.1", + "determinism": "deterministic", + "confidence": 1.0 + }, + "similarity_eligible": true + }, + { + "id": "layout.svg.element-counts", + "modality": "layout", + "dimension": "layout.svg.element_counts", + "value": { + "circle": 1, + "rect": 3, + "text": 3 + }, + "evidence": { + "origin": "provider_observed", + "provider_id": "renderflow.builtin.artifact-dna", + "provider_version": "0.2.1", + "determinism": "deterministic", + "confidence": 1.0 + }, + "similarity_eligible": true + }, + { + "id": "layout.svg.text-elements", + "modality": "layout", + "dimension": "layout.typography.text_element_count", + "value": 3, + "evidence": { + "origin": "provider_observed", + "provider_id": "renderflow.builtin.artifact-dna", + "provider_version": "0.2.1", + "determinism": "deterministic", + "confidence": 1.0 + }, + "similarity_eligible": true + } + ], + "similarity_guidance": { + "purpose": "Compare reusable descriptive characteristics for related, original assets", + "direct_imitation_allowed": false, + "legal_clearance_claimed": false, + "dimension_weights": { + "layout.canvas.aspect_ratio": 1.0, + "layout.canvas.orientation": 1.0, + "layout.svg.element_counts": 1.0, + "layout.typography.text_element_count": 1.0, + "visual.canvas.height_pixels": 1.0, + "visual.canvas.width_pixels": 1.0, + "visual.palette.hex": 1.0 + }, + "excluded_dimensions": [ + "identity.creator", + "identity.brand", + "identity.protected_work" + ] + }, + "hygiene": { + "policy_id": "policy.artifact-dna.public-safe/v1", + "status": "passed", + "raw_payload_retained": false, + "identifying_metadata_retained": false + }, + "provenance": { + "source_digest": { + "algorithm": "sha256", + "value": "7808c6c0e63810064064b336b52dca4eb741f9ddbd9be20477d04d7977a2e2a4" + }, + "extractors": [ + { + "provider_id": "renderflow.builtin.artifact-dna", + "provider_version": "0.2.1", + "locality": "local", + "determinism": "deterministic", + "ai_assisted": false + } + ], + "policy_id": "policy.artifact-dna.public-safe/v1", + "policy_digest": { + "algorithm": "sha256", + "value": "3a046c836a0f0b0ab442643787755fc6149ef60cb9284b0c77111f98e103759f" + }, + "configuration_digest": { + "algorithm": "sha256", + "value": "3a046c836a0f0b0ab442643787755fc6149ef60cb9284b0c77111f98e103759f" + } + }, + "approval": { + "state": "candidate", + "human_review_required": false + }, + "validation": { + "status": "valid", + "schema_valid": true + } +} diff --git a/examples/magazine/renderflow.yaml b/examples/magazine/renderflow.yaml index f073979..484de0f 100644 --- a/examples/magazine/renderflow.yaml +++ b/examples/magazine/renderflow.yaml @@ -21,6 +21,7 @@ publication: artwork: - role: cover path: assets/cover.svg + artifact_dna: assets/cover.dna.json alt_text: Indigo and cream geometric cover for The Small Systems Review, Synthetic Issue Zero. approval_reference: synthetic-fixture-approval geometry: diff --git a/schemas/renderflow-magazine-candidates-v1.schema.json b/schemas/renderflow-magazine-candidates-v1.schema.json new file mode 100644 index 0000000..63cc0e8 --- /dev/null +++ b/schemas/renderflow-magazine-candidates-v1.schema.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-magazine-candidates-v1.schema.json", + "title": "Renderflow magazine asset-brief and metadata candidates v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "candidate_id", "source", "asset_brief", "metadata", "ai", "provenance", "validation", "approval"], + "properties": { + "schema_version": {"const": "renderflow.magazine-candidates/v1"}, + "candidate_id": {"type": "string", "pattern": "^magazine:sha256:[a-f0-9]{64}$"}, + "source": {"$ref": "#/$defs/source"}, + "asset_brief": {"$ref": "#/$defs/assetBrief"}, + "metadata": {"$ref": "#/$defs/metadata"}, + "ai": {"$ref": "#/$defs/ai"}, + "provenance": {"$ref": "#/$defs/provenance"}, + "validation": {"$ref": "#/$defs/validation"}, + "approval": {"$ref": "#/$defs/approval"} + }, + "$defs": { + "digest": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "value"], + "properties": { + "algorithm": {"const": "sha256"}, + "value": {"type": "string", "pattern": "^[a-f0-9]{64}$"} + } + }, + "guidance": { + "type": "object", + "additionalProperties": false, + "required": ["observation_id", "dimension", "value", "confidence"], + "properties": { + "observation_id": {"type": "string", "minLength": 1}, + "dimension": {"type": "string", "minLength": 1}, + "value": {}, + "description": {"type": ["string", "null"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1} + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["publication_issue_id", "asset_role", "source_artifact_id", "source_digest", "dna_digest", "dna_schema_version"], + "properties": { + "publication_issue_id": {"type": "string", "minLength": 1}, + "asset_role": {"type": "string", "minLength": 1}, + "source_artifact_id": {"type": "string", "minLength": 1}, + "source_digest": {"$ref": "#/$defs/digest"}, + "dna_digest": {"$ref": "#/$defs/digest"}, + "dna_schema_version": {"const": "renderflow.artifact-dna/v1"} + } + }, + "assetBrief": { + "type": "object", + "additionalProperties": false, + "required": ["state", "asset_role", "purpose", "layout_guidance", "palette_guidance", "typography_guidance", "accessibility_guidance", "negative_guidance"], + "properties": { + "state": {"const": "candidate"}, + "asset_role": {"type": "string", "minLength": 1}, + "purpose": {"type": "string", "minLength": 1}, + "layout_guidance": {"type": "array", "items": {"$ref": "#/$defs/guidance"}}, + "palette_guidance": {"type": "array", "items": {"$ref": "#/$defs/guidance"}}, + "typography_guidance": {"type": "array", "items": {"$ref": "#/$defs/guidance"}}, + "accessibility_guidance": {"type": "array", "items": {"$ref": "#/$defs/guidance"}}, + "negative_guidance": {"type": "array", "minItems": 1, "items": {"type": "string"}} + } + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": ["state", "asset_role", "source_format", "source_media_type", "descriptive_properties", "accessibility_review_required"], + "properties": { + "state": {"const": "candidate"}, + "asset_role": {"type": "string", "minLength": 1}, + "source_format": {"type": "string", "minLength": 1}, + "source_media_type": {"type": "string", "minLength": 1}, + "descriptive_properties": {"type": "object"}, + "accessibility_review_required": {"const": true} + } + }, + "ai": { + "type": "object", + "additionalProperties": false, + "required": ["status", "skill_id", "skill_version"], + "properties": { + "status": {"enum": ["not_requested", "unavailable", "produced"]}, + "skill_id": {"const": "skill.magazine.candidates"}, + "skill_version": {"type": "string", "minLength": 1}, + "provider_id": {"type": ["string", "null"]}, + "model_id": {"type": ["string", "null"]}, + "candidate": {"type": ["object", "null"]}, + "execution": {"type": ["object", "null"]}, + "diagnostics": {"type": "array", "items": {"type": "string"}} + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["generator", "generator_version", "policy_id", "policy_digest", "settings_digest", "source_digest", "dna_digest", "dna_policy_id", "dna_policy_digest", "dna_configuration_digest", "dna_extractors"], + "properties": { + "generator": {"const": "renderflow.builtin.magazine-candidates"}, + "generator_version": {"type": "string", "minLength": 1}, + "policy_id": {"const": "policy.magazine-candidates.public-safe/v1"}, + "policy_digest": {"$ref": "#/$defs/digest"}, + "settings_digest": {"$ref": "#/$defs/digest"}, + "source_digest": {"$ref": "#/$defs/digest"}, + "dna_digest": {"$ref": "#/$defs/digest"}, + "dna_policy_id": {"type": "string", "minLength": 1}, + "dna_policy_digest": {"$ref": "#/$defs/digest"}, + "dna_configuration_digest": {"$ref": "#/$defs/digest"}, + "dna_extractors": {"type": "array", "minItems": 1, "items": {"type": "object"}} + } + }, + "validation": { + "type": "object", + "additionalProperties": false, + "required": ["schema_valid", "status", "validators", "publication_content_generated"], + "properties": { + "schema_valid": {"const": true}, + "status": {"const": "valid_review_required"}, + "validators": {"type": "array", "minItems": 1, "items": {"type": "string"}}, + "publication_content_generated": {"const": false} + } + }, + "approval": { + "type": "object", + "additionalProperties": false, + "required": ["state", "human_review_required"], + "properties": { + "state": {"const": "candidate"}, + "human_review_required": {"const": true}, + "approval_reference": {"type": ["string", "null"]} + } + } + } +} diff --git a/schemas/renderflow-v2.schema.json b/schemas/renderflow-v2.schema.json index d1ea826..ba7e7c3 100644 --- a/schemas/renderflow-v2.schema.json +++ b/schemas/renderflow-v2.schema.json @@ -492,6 +492,12 @@ "null" ] }, + "artifact_dna": { + "type": [ + "string", + "null" + ] + }, "path": { "minLength": 1, "type": "string"