diff --git a/crates/renderflow-core/data/ai/fixtures/synthetic-layout.svg b/crates/renderflow-core/data/ai/fixtures/synthetic-layout.svg
new file mode 100644
index 0000000..02244aa
--- /dev/null
+++ b/crates/renderflow-core/data/ai/fixtures/synthetic-layout.svg
@@ -0,0 +1,14 @@
+
diff --git a/crates/renderflow-core/data/ai/model-catalog-v1.json b/crates/renderflow-core/data/ai/model-catalog-v1.json
new file mode 100644
index 0000000..fd3df54
--- /dev/null
+++ b/crates/renderflow-core/data/ai/model-catalog-v1.json
@@ -0,0 +1,140 @@
+{
+ "schema_version": "renderflow.ai-model-catalog/v1",
+ "revision": "2026-09-11.1",
+ "providers": [
+ {
+ "id": "provider.ollama",
+ "adapter": "ollama",
+ "display_name": "Ollama local runtime",
+ "locality": "local",
+ "requires_network_permission": false,
+ "runtime": {
+ "id": "runtime.ollama",
+ "source": "https://github.com/ollama/ollama",
+ "license": "MIT",
+ "hardware_requirements": ["Model-specific RAM or accelerator capacity"],
+ "availability_probe": {
+ "kind": "http",
+ "bounded_timeout_ms": 1500,
+ "endpoint": "http://localhost:11434/api/tags"
+ }
+ },
+ "models": [
+ {
+ "id": "llama3.2",
+ "family": "llama3.2",
+ "input_modalities": ["text", "structured_json", "artifact_dna"],
+ "output_modalities": ["text", "structured_json", "metadata"],
+ "operations": ["generation", "extraction", "classification", "schema_constrained_output"],
+ "limits": {"context_tokens": 131072},
+ "features": {
+ "native_json": true,
+ "json_schema": true,
+ "seed": true,
+ "sampler_controls": true,
+ "streaming": true,
+ "batch": false,
+ "tool_use": true,
+ "multi_input": false
+ },
+ "determinism": "probabilistic",
+ "license": {
+ "model": "Llama 3.2 Community License",
+ "weights": "Llama 3.2 Community License",
+ "code": "Runtime-specific",
+ "commercial_use": "conditional",
+ "human_review_required": true
+ },
+ "commercial_constraints": ["Verify the selected model revision and license before redistribution or commercial use"],
+ "advisory": {"cost_tier": 1, "quality_tier": 2, "latency_tier": 2, "observed_at": "2026-09-11"},
+ "maturity": "experimental",
+ "conformance": "contract_fixture",
+ "availability": "unverified",
+ "availability_reason": "Catalog entry does not imply that model weights are installed locally",
+ "required_provenance_fields": ["runtime_revision", "runtime_digest", "model_revision", "weights_digest", "settings", "skill_version", "input_digest", "output_digest"]
+ },
+ {
+ "id": "llava",
+ "family": "llava",
+ "input_modalities": ["text", "image", "structured_json", "artifact_dna"],
+ "output_modalities": ["text", "structured_json", "artifact_dna", "metadata"],
+ "operations": ["generation", "extraction", "classification", "multimodal_reasoning"],
+ "limits": {"max_images": 1},
+ "features": {
+ "native_json": true,
+ "json_schema": false,
+ "seed": true,
+ "sampler_controls": true,
+ "streaming": true,
+ "batch": false,
+ "tool_use": false,
+ "multi_input": true
+ },
+ "determinism": "probabilistic",
+ "license": {
+ "model": "Unknown until runtime discovery",
+ "commercial_use": "unknown",
+ "human_review_required": true
+ },
+ "commercial_constraints": ["Resolve the exact model revision and upstream licenses before use"],
+ "advisory": {"cost_tier": 1, "quality_tier": 1, "latency_tier": 3, "observed_at": "2026-09-11"},
+ "maturity": "experimental",
+ "conformance": "declared_only",
+ "availability": "unverified",
+ "availability_reason": "Catalog entry does not imply that model weights are installed locally",
+ "required_provenance_fields": ["runtime_revision", "model_revision", "weights_digest", "settings", "skill_version", "input_digest", "output_digest"]
+ }
+ ]
+ },
+ {
+ "id": "provider.openai-compatible",
+ "adapter": "openai",
+ "display_name": "Explicit OpenAI-compatible endpoint",
+ "locality": "remote",
+ "requires_network_permission": true,
+ "runtime": {
+ "id": "runtime.openai-compatible",
+ "source": "Configured endpoint",
+ "license": "Provider-specific terms",
+ "availability_probe": {
+ "kind": "credential_and_endpoint",
+ "bounded_timeout_ms": 1500
+ }
+ },
+ "models": [
+ {
+ "id": "gpt-4o-mini",
+ "family": "gpt-4o-mini",
+ "input_modalities": ["text", "image", "structured_json"],
+ "output_modalities": ["text", "structured_json", "artifact_dna", "metadata"],
+ "operations": ["generation", "editing", "extraction", "classification", "multimodal_reasoning", "schema_constrained_output"],
+ "limits": {"context_tokens": 128000, "output_tokens": 16384, "max_images": 20},
+ "features": {
+ "native_json": true,
+ "json_schema": true,
+ "seed": true,
+ "sampler_controls": true,
+ "streaming": true,
+ "batch": true,
+ "tool_use": true,
+ "multi_input": true
+ },
+ "determinism": "probabilistic",
+ "license": {
+ "model": "Hosted service; no model-weight license",
+ "provider_terms": "Endpoint-specific terms",
+ "commercial_use": "provider_terms_apply",
+ "human_review_required": true
+ },
+ "commercial_constraints": ["Remote use requires explicit network, privacy, budget, and provider-terms approval"],
+ "advisory": {"cost_tier": 2, "quality_tier": 3, "latency_tier": 2, "observed_at": "2026-09-11"},
+ "maturity": "experimental",
+ "conformance": "declared_only",
+ "availability": "unverified",
+ "availability_reason": "Availability depends on the configured endpoint, credentials, and provider terms",
+ "required_provenance_fields": ["endpoint_identity", "provider_terms_revision", "model_revision", "settings", "skill_version", "input_digest", "output_digest", "usage", "cost"]
+ }
+ ]
+ }
+ ]
+}
diff --git a/crates/renderflow-core/data/ai/skills/accessibility-description-v1.json b/crates/renderflow-core/data/ai/skills/accessibility-description-v1.json
new file mode 100644
index 0000000..6d68c67
--- /dev/null
+++ b/crates/renderflow-core/data/ai/skills/accessibility-description-v1.json
@@ -0,0 +1,24 @@
+{
+ "schema_version": "renderflow.ai-skill/v1",
+ "id": "skill.accessibility.describe-candidate",
+ "version": "1.0.0",
+ "purpose": "Draft a concise accessibility description from approved visual evidence for human review.",
+ "artifact_families": ["image", "publication", "accessibility"],
+ "operations": ["generation", "multimodal_reasoning"],
+ "input_modalities": ["text", "image", "structured_json"],
+ "output_modalities": ["structured_json", "metadata"],
+ "requires_json_schema": false,
+ "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["image_digest", "context"], "properties": {"image_digest": {"type": "string", "minLength": 8, "maxLength": 160}, "context": {"type": "string", "maxLength": 2000}}, "additionalProperties": false},
+ "output_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["short_description", "long_description", "uncertainties", "review_required"], "properties": {"short_description": {"type": "string", "minLength": 1, "maxLength": 300}, "long_description": {"type": "string", "minLength": 1, "maxLength": 3000}, "uncertainties": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 240}}, "review_required": {"const": true}}, "additionalProperties": false},
+ "templates": {"system": "You draft factual accessibility descriptions. Do not identify people, infer sensitive traits, or claim certainty about ambiguous content.", "instruction": "Return strict JSON, list uncertainties, and require human review before publication.", "prompt": "Describe the separately supplied approved image {{image_digest}} using this context: {{context}}"},
+ "variables": [{"name": "image_digest", "required": true, "sensitive": false, "max_bytes": 160}, {"name": "context", "required": true, "sensitive": false, "max_bytes": 2000}],
+ "max_rendered_prompt_bytes": 5000,
+ "generation": {"temperature": 0.1, "max_tokens": 1500, "seed": 42, "top_p": 0.9},
+ "budgets": {"network": false, "remote_execution": false, "max_input_bytes": 25000000, "max_output_bytes": 16384, "max_tokens": 1800, "max_duration_ms": 120000, "max_retries": 1},
+ "hygiene": {"policy_id": "policy.ai.accessibility-safe/v1", "scan_secrets": true, "pii_action": "block", "protected_reference_action": "review", "protected_references": [{"term": "Example Franchise"}], "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.accessibility-human-review/v1"]},
+ "provenance": {"cache_identity_fields": ["skill", "model", "runtime", "input_artifacts", "schemas", "settings", "hygiene"], "evidence_fields": ["provider", "runtime", "model", "skill", "input_artifacts", "digests", "usage", "candidate_state", "approvals"], "redact_raw_inputs": true, "redact_raw_prompts": true},
+ "redistribution_notes": "The bundled fixture is synthetic and does not require a paid API or model download.",
+ "license_notes": "Accessibility drafts are candidates; factual accuracy and publication rights require human review.",
+ "fixture": {"image_digest": "sha256:d1c1808924bc17331ac936d83c918e3ee978c2baf0b708aa3cd883743768032f", "context": "The bundled redistribution-safe synthetic-layout.svg fixture."}
+}
diff --git a/crates/renderflow-core/data/ai/skills/metadata-extraction-v1.json b/crates/renderflow-core/data/ai/skills/metadata-extraction-v1.json
new file mode 100644
index 0000000..3f114a3
--- /dev/null
+++ b/crates/renderflow-core/data/ai/skills/metadata-extraction-v1.json
@@ -0,0 +1,47 @@
+{
+ "schema_version": "renderflow.ai-skill/v1",
+ "id": "skill.metadata.extract",
+ "version": "1.0.0",
+ "purpose": "Extract conservative publication metadata from approved text into a strict candidate record.",
+ "artifact_families": ["document", "publication", "metadata"],
+ "operations": ["extraction", "schema_constrained_output"],
+ "input_modalities": ["text", "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": ["source_text"],
+ "properties": {
+ "source_text": {"type": "string", "minLength": 1, "maxLength": 65536}
+ },
+ "additionalProperties": false
+ },
+ "output_schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "required": ["title", "summary", "keywords", "review_required"],
+ "properties": {
+ "title": {"type": "string", "minLength": 1, "maxLength": 240},
+ "summary": {"type": "string", "minLength": 1, "maxLength": 2000},
+ "keywords": {"type": "array", "maxItems": 20, "items": {"type": "string", "maxLength": 80}},
+ "review_required": {"const": true}
+ },
+ "additionalProperties": false
+ },
+ "templates": {
+ "system": "You are a conservative publication metadata extractor. Return only JSON matching the supplied output contract.",
+ "instruction": "Describe only evidence present in the approved source. Do not infer ownership, rights clearance, or legal conclusions.",
+ "prompt": "Extract a title, summary, and keywords from this approved source. Mark review_required true.\n\n{{source_text}}"
+ },
+ "variables": [{"name": "source_text", "required": true, "sensitive": false, "max_bytes": 65536}],
+ "max_rendered_prompt_bytes": 70000,
+ "generation": {"temperature": 0.1, "max_tokens": 1200, "seed": 42, "top_p": 0.9},
+ "budgets": {"network": true, "remote_execution": true, "max_input_bytes": 65536, "max_output_bytes": 16384, "max_tokens": 1400, "max_duration_ms": 60000, "max_retries": 1, "max_cost_microunits": 50000},
+ "hygiene": {"policy_id": "policy.ai.publication-safe/v1", "scan_secrets": true, "pii_action": "review", "protected_reference_action": "rewrite", "protected_references": [{"term": "Example Franchise", "descriptive_replacement": "genre-specific 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"]},
+ "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 and synthetic fixture contain no third-party source material.",
+ "license_notes": "Model and provider licenses remain independent evidence and require review.",
+ "fixture": {"source_text": "A small synthetic booklet explains mindful pauses through geometric shapes."}
+}
diff --git a/crates/renderflow-core/data/ai/skills/prompt-from-dna-v1.json b/crates/renderflow-core/data/ai/skills/prompt-from-dna-v1.json
new file mode 100644
index 0000000..1df8420
--- /dev/null
+++ b/crates/renderflow-core/data/ai/skills/prompt-from-dna-v1.json
@@ -0,0 +1,24 @@
+{
+ "schema_version": "renderflow.ai-skill/v1",
+ "id": "skill.prompt.from-sanitized-dna",
+ "version": "1.0.0",
+ "purpose": "Construct provider-independent prompt guidance from sanitized Artifact DNA.",
+ "artifact_families": ["artifact_dna", "prompt", "publication"],
+ "operations": ["generation", "schema_constrained_output"],
+ "input_modalities": ["artifact_dna", "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", "intent"], "properties": {"dna": {"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": ["prompt", "negative_guidance", "review_required"], "properties": {"prompt": {"type": "string", "minLength": 1, "maxLength": 8000}, "negative_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 200}}, "review_required": {"const": true}}, "additionalProperties": false},
+ "templates": {"system": "You construct provider-neutral generation guidance from sanitized descriptive properties. Never request imitation of a named creator, artist, brand, or franchise.", "instruction": "Use only the supplied DNA and intent. Return strict JSON and mark review_required true.", "prompt": "Intent: {{intent}}\n\nSanitized Artifact DNA:\n{{dna}}"},
+ "variables": [{"name": "dna", "required": true, "sensitive": false, "max_bytes": 32000}, {"name": "intent", "required": true, "sensitive": false, "max_bytes": 2000}],
+ "max_rendered_prompt_bytes": 38000,
+ "generation": {"temperature": 0.2, "max_tokens": 1800, "seed": 42, "top_p": 0.9},
+ "budgets": {"network": false, "remote_execution": false, "max_input_bytes": 34000, "max_output_bytes": 16384, "max_tokens": 2000, "max_duration_ms": 60000, "max_retries": 1},
+ "hygiene": {"policy_id": "policy.ai.protected-reference-safe/v1", "scan_secrets": true, "pii_action": "block", "protected_reference_action": "rewrite", "protected_references": [{"term": "Example Franchise", "descriptive_replacement": "weathered post-industrial design 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"]},
+ "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 fixture is synthetic and provider independent.",
+ "license_notes": "Prompt sanitization reduces leakage risk but is not a legal determination.",
+ "fixture": {"dna": "Asymmetrical modular panels; muted mineral palette; worn metal texture; condensed geometric headings.", "intent": "Create an original editorial divider page."}
+}
diff --git a/crates/renderflow-core/data/ai/skills/visual-dna-v1.json b/crates/renderflow-core/data/ai/skills/visual-dna-v1.json
new file mode 100644
index 0000000..a336913
--- /dev/null
+++ b/crates/renderflow-core/data/ai/skills/visual-dna-v1.json
@@ -0,0 +1,24 @@
+{
+ "schema_version": "renderflow.ai-skill/v1",
+ "id": "skill.visual-dna.describe",
+ "version": "1.0.0",
+ "purpose": "Describe approved synthetic visual and layout evidence as neutral Visual DNA without imitation labels.",
+ "artifact_families": ["image", "publication", "artifact_dna"],
+ "operations": ["extraction", "multimodal_reasoning"],
+ "input_modalities": ["text", "image", "structured_json"],
+ "output_modalities": ["structured_json", "artifact_dna"],
+ "requires_json_schema": false,
+ "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["image_digest", "context"], "properties": {"image_digest": {"type": "string", "minLength": 8, "maxLength": 160}, "context": {"type": "string", "minLength": 1, "maxLength": 2000}}, "additionalProperties": false},
+ "output_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["composition", "palette", "texture", "typography", "review_required"], "properties": {"composition": {"type": "array", "maxItems": 20, "items": {"type": "string", "maxLength": 160}}, "palette": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 80}}, "texture": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 120}}, "typography": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 120}}, "review_required": {"const": true}}, "additionalProperties": false},
+ "templates": {"system": "You describe observable visual and layout properties without naming artists, living creators, brands, franchises, or copyrighted characters.", "instruction": "Return strict JSON. Prefer geometry, spatial relationships, palette behavior, texture, and typographic characteristics. Never claim rights clearance.", "prompt": "Analyze the separately supplied approved image identified by {{image_digest}}. Context: {{context}}. Produce neutral Visual DNA and mark review_required true."},
+ "variables": [{"name": "image_digest", "required": true, "sensitive": false, "max_bytes": 160}, {"name": "context", "required": true, "sensitive": false, "max_bytes": 2000}],
+ "max_rendered_prompt_bytes": 5000,
+ "generation": {"temperature": 0.1, "max_tokens": 1800, "seed": 42, "top_p": 0.9},
+ "budgets": {"network": false, "remote_execution": false, "max_input_bytes": 25000000, "max_output_bytes": 32768, "max_tokens": 2000, "max_duration_ms": 120000, "max_retries": 1},
+ "hygiene": {"policy_id": "policy.ai.protected-reference-safe/v1", "scan_secrets": true, "pii_action": "review", "protected_reference_action": "rewrite", "protected_references": [{"term": "Example Franchise", "descriptive_replacement": "weathered post-industrial design 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.visual-dna-review/v1"]},
+ "provenance": {"cache_identity_fields": ["skill", "model", "runtime", "input_artifacts", "schemas", "settings", "hygiene"], "evidence_fields": ["provider", "runtime", "model", "skill", "input_artifacts", "digests", "usage", "candidate_state", "approvals"], "redact_raw_inputs": true, "redact_raw_prompts": true},
+ "redistribution_notes": "Use only approved inputs. The bundled fixture references a synthetic image digest and contains no model weights.",
+ "license_notes": "Visual descriptions are not legal clearance; model, source, and output rights require review.",
+ "fixture": {"image_digest": "sha256:d1c1808924bc17331ac936d83c918e3ee978c2baf0b708aa3cd883743768032f", "context": "The bundled redistribution-safe synthetic-layout.svg fixture."}
+}
diff --git a/crates/renderflow-core/src/ai/catalog.rs b/crates/renderflow-core/src/ai/catalog.rs
new file mode 100644
index 0000000..dc6852d
--- /dev/null
+++ b/crates/renderflow-core/src/ai/catalog.rs
@@ -0,0 +1,707 @@
+//! Versioned provider/model compatibility catalog and deterministic resolver.
+
+use std::cmp::Ordering;
+use std::fmt;
+use std::path::Path;
+use std::str::FromStr;
+
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+
+pub const AI_MODEL_CATALOG_SCHEMA_V1: &str = "renderflow.ai-model-catalog/v1";
+pub const AI_RESOLUTION_SCHEMA_V1: &str = "renderflow.ai-resolution/v1";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AiLocality {
+ Local,
+ Remote,
+ Hybrid,
+}
+
+impl fmt::Display for AiLocality {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::Local => "local",
+ Self::Remote => "remote",
+ Self::Hybrid => "hybrid",
+ })
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AiModality {
+ Text,
+ Image,
+ Audio,
+ Video,
+ Document,
+ StructuredJson,
+ ArtifactDna,
+ Embeddings,
+ Mask,
+ Metadata,
+}
+
+impl fmt::Display for AiModality {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::Text => "text",
+ Self::Image => "image",
+ Self::Audio => "audio",
+ Self::Video => "video",
+ Self::Document => "document",
+ Self::StructuredJson => "structured_json",
+ Self::ArtifactDna => "artifact_dna",
+ Self::Embeddings => "embeddings",
+ Self::Mask => "mask",
+ Self::Metadata => "metadata",
+ })
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AiOperation {
+ Generation,
+ Editing,
+ Extraction,
+ Classification,
+ Transcription,
+ Embeddings,
+ MultimodalReasoning,
+ SchemaConstrainedOutput,
+}
+
+impl fmt::Display for AiOperation {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::Generation => "generation",
+ Self::Editing => "editing",
+ Self::Extraction => "extraction",
+ Self::Classification => "classification",
+ Self::Transcription => "transcription",
+ Self::Embeddings => "embeddings",
+ Self::MultimodalReasoning => "multimodal_reasoning",
+ Self::SchemaConstrainedOutput => "schema_constrained_output",
+ })
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AiDeterminism {
+ ByteDeterministic,
+ ConfigurationRepeatable,
+ Probabilistic,
+ Unknown,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AiAvailability {
+ Available,
+ Unavailable,
+ Unverified,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiRuntimeDescriptor {
+ pub id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub revision: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub digest: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub license: Option,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub hardware_requirements: Vec,
+ pub availability_probe: AiAvailabilityProbe,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiAvailabilityProbe {
+ pub kind: String,
+ pub bounded_timeout_ms: u64,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub endpoint: Option,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiModelLimits {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub context_tokens: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub output_tokens: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub max_input_bytes: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub max_images: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub max_audio_seconds: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub max_video_seconds: Option,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiModelFeatures {
+ pub native_json: bool,
+ pub json_schema: bool,
+ pub seed: bool,
+ pub sampler_controls: bool,
+ pub streaming: bool,
+ pub batch: bool,
+ pub tool_use: bool,
+ pub multi_input: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiLicenseEvidence {
+ pub model: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub weights: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub code: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub provider_terms: Option,
+ pub commercial_use: String,
+ pub human_review_required: bool,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiAdvisoryHints {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cost_tier: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub quality_tier: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub latency_tier: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub observed_at: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiModelCatalogEntry {
+ pub id: String,
+ pub family: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub revision: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub quantization: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub weights_digest: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub configuration_digest: Option,
+ pub input_modalities: Vec,
+ pub output_modalities: Vec,
+ pub operations: Vec,
+ #[serde(default)]
+ pub limits: AiModelLimits,
+ #[serde(default)]
+ pub features: AiModelFeatures,
+ pub determinism: AiDeterminism,
+ pub license: AiLicenseEvidence,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub commercial_constraints: Vec,
+ #[serde(default)]
+ pub advisory: AiAdvisoryHints,
+ pub maturity: String,
+ pub conformance: String,
+ pub availability: AiAvailability,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub availability_reason: Option,
+ pub required_provenance_fields: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiProviderCatalogEntry {
+ pub id: String,
+ pub adapter: String,
+ pub display_name: String,
+ pub locality: AiLocality,
+ pub requires_network_permission: bool,
+ pub runtime: AiRuntimeDescriptor,
+ pub models: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiModelCatalog {
+ pub schema_version: String,
+ pub revision: String,
+ pub providers: Vec,
+}
+
+impl AiModelCatalog {
+ pub fn bundled() -> Result {
+ Self::from_json(include_str!("../../data/ai/model-catalog-v1.json"))
+ }
+
+ pub fn load(path: impl AsRef) -> Result {
+ let path = path.as_ref();
+ let contents = std::fs::read_to_string(path)
+ .with_context(|| format!("failed to read AI model catalog '{}'", path.display()))?;
+ Self::from_json(&contents)
+ .with_context(|| format!("invalid AI model catalog '{}'", path.display()))
+ }
+
+ pub fn from_json(contents: &str) -> Result {
+ let catalog: Self = serde_json::from_str(contents).context("catalog is not valid JSON")?;
+ catalog.validate()?;
+ Ok(catalog)
+ }
+
+ pub fn validate(&self) -> Result<()> {
+ if self.schema_version != AI_MODEL_CATALOG_SCHEMA_V1 {
+ anyhow::bail!(
+ "unsupported AI model catalog schema '{}'; expected '{}'",
+ self.schema_version,
+ AI_MODEL_CATALOG_SCHEMA_V1
+ );
+ }
+ if self.revision.trim().is_empty() {
+ anyhow::bail!("AI model catalog revision must not be empty");
+ }
+ let mut provider_ids = std::collections::BTreeSet::new();
+ for provider in &self.providers {
+ validate_stable_id("provider", &provider.id)?;
+ if !provider_ids.insert(provider.id.as_str()) {
+ anyhow::bail!("duplicate AI provider id '{}'", provider.id);
+ }
+ if provider.models.is_empty() {
+ anyhow::bail!(
+ "AI provider '{}' must declare at least one model",
+ provider.id
+ );
+ }
+ let mut model_ids = std::collections::BTreeSet::new();
+ for model in &provider.models {
+ validate_stable_id("model", &model.id)?;
+ if !model_ids.insert(model.id.as_str()) {
+ anyhow::bail!(
+ "duplicate AI model id '{}' for provider '{}'",
+ model.id,
+ provider.id
+ );
+ }
+ if model.input_modalities.is_empty()
+ || model.output_modalities.is_empty()
+ || model.operations.is_empty()
+ {
+ anyhow::bail!(
+ "AI model '{}:{}' must declare model-specific modalities and operations",
+ provider.id,
+ model.id
+ );
+ }
+ if model.required_provenance_fields.is_empty() {
+ anyhow::bail!(
+ "AI model '{}:{}' must declare required provenance fields",
+ provider.id,
+ model.id
+ );
+ }
+ }
+ }
+ Ok(())
+ }
+
+ pub fn resolve(&self, request: &AiResolutionRequest) -> AiResolutionReport {
+ let mut assessments = Vec::new();
+ for provider in &self.providers {
+ for model in &provider.models {
+ assessments.push(assess(provider, model, request));
+ }
+ }
+
+ let mut compatible: Vec = assessments
+ .iter()
+ .enumerate()
+ .filter_map(|(index, candidate)| candidate.reasons.is_empty().then_some(index))
+ .collect();
+ compatible.sort_by(|left, right| {
+ rank_candidate(
+ &assessments[*left],
+ &assessments[*right],
+ request.preference,
+ )
+ });
+
+ let selected = compatible.first().map(|index| {
+ let candidate = &mut assessments[*index];
+ candidate.status = AiCandidateStatus::Selected;
+ candidate.reasons.push(AiResolutionReason {
+ code: "resolver.selected".to_string(),
+ message: format!("Selected by deterministic '{}' ranking", request.preference),
+ });
+ AiModelSelection {
+ provider_id: candidate.provider_id.clone(),
+ adapter: candidate.adapter.clone(),
+ runtime_id: candidate.runtime_id.clone(),
+ runtime_revision: candidate.runtime_revision.clone(),
+ runtime_digest: candidate.runtime_digest.clone(),
+ model_id: candidate.model_id.clone(),
+ model_revision: candidate.model_revision.clone(),
+ weights_digest: candidate.weights_digest.clone(),
+ locality: candidate.locality,
+ availability: candidate.availability,
+ determinism: candidate.determinism,
+ execution_ready: candidate.availability == AiAvailability::Available,
+ }
+ });
+
+ for index in compatible.into_iter().skip(1) {
+ assessments[index].status = AiCandidateStatus::Compatible;
+ assessments[index].reasons.push(AiResolutionReason {
+ code: "resolver.compatible_not_selected".to_string(),
+ message: "Compatible candidate ranked below the selected model".to_string(),
+ });
+ }
+
+ AiResolutionReport {
+ schema_version: AI_RESOLUTION_SCHEMA_V1.to_string(),
+ catalog_revision: self.revision.clone(),
+ request: request.clone(),
+ selected,
+ candidates: assessments,
+ }
+ }
+}
+
+fn validate_stable_id(kind: &str, value: &str) -> Result<()> {
+ if value.is_empty()
+ || !value.chars().all(|character| {
+ character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
+ })
+ {
+ anyhow::bail!("{kind} id '{value}' must use only ASCII letters, digits, '.', '_' or '-'");
+ }
+ Ok(())
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AiExecutionPreferenceV1 {
+ LocalOnly,
+ RemoteOnly,
+ #[default]
+ LocalPreferred,
+ LowestCost,
+ HighestQuality,
+}
+
+impl fmt::Display for AiExecutionPreferenceV1 {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::LocalOnly => "local-only",
+ Self::RemoteOnly => "remote-only",
+ Self::LocalPreferred => "local-preferred",
+ Self::LowestCost => "lowest-cost",
+ Self::HighestQuality => "highest-quality",
+ })
+ }
+}
+
+impl FromStr for AiExecutionPreferenceV1 {
+ type Err = anyhow::Error;
+
+ fn from_str(value: &str) -> Result {
+ match value {
+ "local-only" => Ok(Self::LocalOnly),
+ "remote-only" => Ok(Self::RemoteOnly),
+ "local-preferred" => Ok(Self::LocalPreferred),
+ "lowest-cost" => Ok(Self::LowestCost),
+ "highest-quality" => Ok(Self::HighestQuality),
+ _ => anyhow::bail!(
+ "unknown AI execution preference '{value}'; expected local-only, remote-only, local-preferred, lowest-cost, or highest-quality"
+ ),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiResolutionRequest {
+ pub operations: Vec,
+ pub input_modalities: Vec,
+ pub output_modalities: Vec,
+ pub requires_json_schema: bool,
+ #[serde(default)]
+ pub preference: AiExecutionPreferenceV1,
+ #[serde(default)]
+ pub allow_remote: bool,
+ #[serde(default)]
+ pub remote_policy_allows: bool,
+ #[serde(default)]
+ pub allow_unverified: bool,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AiCandidateStatus {
+ Selected,
+ Compatible,
+ Rejected,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiResolutionReason {
+ pub code: String,
+ pub message: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiCandidateAssessment {
+ pub provider_id: String,
+ pub adapter: String,
+ pub runtime_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub runtime_revision: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub runtime_digest: Option,
+ pub model_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub model_revision: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub weights_digest: Option,
+ pub locality: AiLocality,
+ pub availability: AiAvailability,
+ pub determinism: AiDeterminism,
+ pub status: AiCandidateStatus,
+ pub advisory: AiAdvisoryHints,
+ pub reasons: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiModelSelection {
+ pub provider_id: String,
+ pub adapter: String,
+ pub runtime_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub runtime_revision: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub runtime_digest: Option,
+ pub model_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub model_revision: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub weights_digest: Option,
+ pub locality: AiLocality,
+ pub availability: AiAvailability,
+ pub determinism: AiDeterminism,
+ pub execution_ready: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiResolutionReport {
+ pub schema_version: String,
+ pub catalog_revision: String,
+ pub request: AiResolutionRequest,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub selected: Option,
+ pub candidates: Vec,
+}
+
+fn assess(
+ provider: &AiProviderCatalogEntry,
+ model: &AiModelCatalogEntry,
+ request: &AiResolutionRequest,
+) -> AiCandidateAssessment {
+ let mut reasons = Vec::new();
+ let remote = provider.locality == AiLocality::Remote;
+ if remote && !request.allow_remote {
+ reasons.push(reason(
+ "policy.remote_not_approved",
+ "Remote execution requires explicit --allow-remote approval",
+ ));
+ }
+ if (remote || provider.requires_network_permission) && !request.remote_policy_allows {
+ reasons.push(reason(
+ "policy.skill_remote_forbidden",
+ "The selected AI skill forbids network or remote execution",
+ ));
+ }
+ match request.preference {
+ AiExecutionPreferenceV1::LocalOnly if remote => reasons.push(reason(
+ "policy.local_only",
+ "Local-only policy forbids this remote model",
+ )),
+ AiExecutionPreferenceV1::RemoteOnly if provider.locality == AiLocality::Local => reasons
+ .push(reason(
+ "policy.remote_only",
+ "Remote-only policy excludes this local model",
+ )),
+ _ => {}
+ }
+ for operation in &request.operations {
+ if !model.operations.contains(operation) {
+ reasons.push(reason(
+ "capability.operation_missing",
+ &format!("Model does not support operation '{operation}'"),
+ ));
+ }
+ }
+ for modality in &request.input_modalities {
+ if !model.input_modalities.contains(modality) {
+ reasons.push(reason(
+ "capability.input_modality_missing",
+ &format!("Model does not accept '{modality}' input"),
+ ));
+ }
+ }
+ for modality in &request.output_modalities {
+ if !model.output_modalities.contains(modality) {
+ reasons.push(reason(
+ "capability.output_modality_missing",
+ &format!("Model does not produce '{modality}' output"),
+ ));
+ }
+ }
+ if request.requires_json_schema && !model.features.json_schema {
+ reasons.push(reason(
+ "capability.json_schema_missing",
+ "Model adapter does not support schema-constrained output",
+ ));
+ }
+ match model.availability {
+ AiAvailability::Unavailable => reasons.push(reason(
+ "availability.unavailable",
+ model
+ .availability_reason
+ .as_deref()
+ .unwrap_or("Model is unavailable"),
+ )),
+ AiAvailability::Unverified if !request.allow_unverified => reasons.push(reason(
+ "availability.unverified",
+ model
+ .availability_reason
+ .as_deref()
+ .unwrap_or("Model availability has not been verified"),
+ )),
+ _ => {}
+ }
+ AiCandidateAssessment {
+ provider_id: provider.id.clone(),
+ adapter: provider.adapter.clone(),
+ runtime_id: provider.runtime.id.clone(),
+ runtime_revision: provider.runtime.revision.clone(),
+ runtime_digest: provider.runtime.digest.clone(),
+ model_id: model.id.clone(),
+ model_revision: model.revision.clone(),
+ weights_digest: model.weights_digest.clone(),
+ locality: provider.locality,
+ availability: model.availability,
+ determinism: model.determinism,
+ status: AiCandidateStatus::Rejected,
+ advisory: model.advisory.clone(),
+ reasons,
+ }
+}
+
+fn reason(code: &str, message: &str) -> AiResolutionReason {
+ AiResolutionReason {
+ code: code.to_string(),
+ message: message.to_string(),
+ }
+}
+
+fn rank_candidate(
+ left: &AiCandidateAssessment,
+ right: &AiCandidateAssessment,
+ preference: AiExecutionPreferenceV1,
+) -> Ordering {
+ let locality_rank = |locality| match locality {
+ AiLocality::Local => 0_u8,
+ AiLocality::Hybrid => 1,
+ AiLocality::Remote => 2,
+ };
+ let ordering = match preference {
+ AiExecutionPreferenceV1::LowestCost => left
+ .advisory
+ .cost_tier
+ .unwrap_or(u8::MAX)
+ .cmp(&right.advisory.cost_tier.unwrap_or(u8::MAX)),
+ AiExecutionPreferenceV1::HighestQuality => right
+ .advisory
+ .quality_tier
+ .unwrap_or(0)
+ .cmp(&left.advisory.quality_tier.unwrap_or(0)),
+ AiExecutionPreferenceV1::LocalOnly | AiExecutionPreferenceV1::LocalPreferred => {
+ locality_rank(left.locality).cmp(&locality_rank(right.locality))
+ }
+ AiExecutionPreferenceV1::RemoteOnly => Ordering::Equal,
+ };
+ ordering
+ .then(left.provider_id.cmp(&right.provider_id))
+ .then(left.model_id.cmp(&right.model_id))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bundled_catalog_is_valid_and_model_specific() {
+ let catalog = AiModelCatalog::bundled().unwrap();
+ assert_eq!(catalog.schema_version, AI_MODEL_CATALOG_SCHEMA_V1);
+ assert!(catalog.providers.iter().all(|provider| provider
+ .models
+ .iter()
+ .all(|model| !model.operations.is_empty())));
+ }
+
+ #[test]
+ fn local_only_never_falls_back_to_remote() {
+ let catalog = AiModelCatalog::bundled().unwrap();
+ let report = catalog.resolve(&AiResolutionRequest {
+ operations: vec![AiOperation::SchemaConstrainedOutput],
+ input_modalities: vec![AiModality::Text],
+ output_modalities: vec![AiModality::StructuredJson],
+ requires_json_schema: true,
+ preference: AiExecutionPreferenceV1::LocalOnly,
+ allow_remote: true,
+ remote_policy_allows: true,
+ allow_unverified: true,
+ });
+ assert!(report
+ .candidates
+ .iter()
+ .filter(|candidate| candidate.locality == AiLocality::Remote)
+ .all(|candidate| candidate.status == AiCandidateStatus::Rejected));
+ }
+
+ #[test]
+ fn remote_models_require_explicit_permission() {
+ let catalog = AiModelCatalog::bundled().unwrap();
+ let report = catalog.resolve(&AiResolutionRequest {
+ operations: vec![AiOperation::Generation],
+ input_modalities: vec![AiModality::Text],
+ output_modalities: vec![AiModality::Text],
+ requires_json_schema: false,
+ preference: AiExecutionPreferenceV1::RemoteOnly,
+ allow_remote: false,
+ remote_policy_allows: true,
+ allow_unverified: true,
+ });
+ assert!(report.selected.is_none());
+ assert!(report.candidates.iter().any(|candidate| candidate
+ .reasons
+ .iter()
+ .any(|reason| reason.code == "policy.remote_not_approved")));
+ }
+}
diff --git a/crates/renderflow-core/src/ai/mod.rs b/crates/renderflow-core/src/ai/mod.rs
index 3c3eea3..2ee1554 100644
--- a/crates/renderflow-core/src/ai/mod.rs
+++ b/crates/renderflow-core/src/ai/mod.rs
@@ -34,19 +34,38 @@
//! println!("{}", response.content);
//! ```
+pub mod catalog;
pub mod metrics;
pub mod output;
pub mod provider;
pub mod providers;
pub mod request;
pub mod retry;
+pub mod runtime;
+pub mod skill;
+pub use catalog::{
+ AiAvailability, AiCandidateAssessment, AiCandidateStatus, AiDeterminism,
+ AiExecutionPreferenceV1, AiLocality, AiModality, AiModelCatalog, AiModelCatalogEntry,
+ AiModelSelection, AiOperation, AiProviderCatalogEntry, AiResolutionReason, AiResolutionReport,
+ AiResolutionRequest, AI_MODEL_CATALOG_SCHEMA_V1, AI_RESOLUTION_SCHEMA_V1,
+};
pub use metrics::{AiExecutionMetrics, SharedMetrics};
pub use output::validate_output;
pub use provider::{AiCapabilities, AiCapability, AiExecutionPreference, AiModel, AiProvider};
pub use providers::{OllamaProvider, OpenAiProvider};
pub use request::{AiRequest, AiResponse, GenerationParameters, OutputFormat};
pub use retry::RetryConfig;
+pub use runtime::{
+ AiExecutionEvidence, AiHygieneEvidence, AiHygieneFindingEvidence, AiHygieneStatus,
+ AiInputArtifactEvidence, AiSkillExecutionOutcome, AiSkillExecutionRequest, AiSkillRuntime,
+ AI_EXECUTION_EVIDENCE_SCHEMA_V1,
+};
+pub use skill::{
+ validate_json_instance, validate_json_schema_definition, AiCandidateState, AiHygieneAction,
+ AiProtectedReferenceRule, AiSkillRegistry, AiSkillSpec, AiSkillValidationResult,
+ AI_SKILL_SCHEMA_V1,
+};
// ── compute_ai_cache_key ──────────────────────────────────────────────────────
diff --git a/crates/renderflow-core/src/ai/providers/ollama.rs b/crates/renderflow-core/src/ai/providers/ollama.rs
index 1825291..6f59ffa 100644
--- a/crates/renderflow-core/src/ai/providers/ollama.rs
+++ b/crates/renderflow-core/src/ai/providers/ollama.rs
@@ -5,6 +5,7 @@
//! for local-first execution.
use std::fmt;
+use std::time::Duration;
use anyhow::{Context, Result};
use serde_json::json;
@@ -62,11 +63,36 @@ impl OllamaProvider {
fn call_api(&self, request: &AiRequest) -> Result {
let url = format!("{}/api/generate", self.endpoint.trim_end_matches('/'));
- let body = json!({
+ let mut body = json!({
"model": request.model,
"prompt": request.prompt,
"stream": false,
});
+ let body_object = body.as_object_mut().expect("Ollama request is an object");
+ if let Some(schema) = &request.output_schema {
+ body_object.insert("format".to_string(), schema.clone());
+ } else if request.output_format == Some(crate::ai::OutputFormat::Json) {
+ body_object.insert("format".to_string(), json!("json"));
+ }
+ let mut options = serde_json::Map::new();
+ if let Some(temperature) = request.params.temperature {
+ options.insert("temperature".to_string(), json!(temperature));
+ }
+ if let Some(max_tokens) = request.params.max_tokens {
+ options.insert("num_predict".to_string(), json!(max_tokens));
+ }
+ if let Some(seed) = request.params.seed {
+ options.insert("seed".to_string(), json!(seed));
+ }
+ if let Some(top_p) = request.params.top_p {
+ options.insert("top_p".to_string(), json!(top_p));
+ }
+ if !request.params.stop.is_empty() {
+ options.insert("stop".to_string(), json!(request.params.stop));
+ }
+ if !options.is_empty() {
+ body_object.insert("options".to_string(), serde_json::Value::Object(options));
+ }
debug!(
provider = "ollama",
@@ -75,7 +101,13 @@ impl OllamaProvider {
"Sending request to Ollama"
);
- let body_str = ureq::post(&url)
+ let mut agent_builder = ureq::AgentBuilder::new();
+ if let Some(timeout_ms) = request.timeout_ms {
+ agent_builder = agent_builder.timeout(Duration::from_millis(timeout_ms));
+ }
+ let agent = agent_builder.build();
+ let body_str = agent
+ .post(&url)
.set("Content-Type", "application/json")
.send_json(body)
.with_context(|| format!("Failed to POST to Ollama endpoint '{}'", url))?
@@ -157,13 +189,18 @@ impl AiProvider for OllamaProvider {
/// the connectivity problem.
pub fn check_ollama_connectivity(endpoint: &str) -> Result<()> {
let url = format!("{}/api/tags", endpoint.trim_end_matches('/'));
- ureq::get(&url).call().with_context(|| {
- format!(
- "Ollama server is not reachable at '{}'. \
+ ureq::AgentBuilder::new()
+ .timeout(Duration::from_millis(1_500))
+ .build()
+ .get(&url)
+ .call()
+ .with_context(|| {
+ format!(
+ "Ollama server is not reachable at '{}'. \
Ensure Ollama is running: `ollama serve`",
- endpoint
- )
- })?;
+ endpoint
+ )
+ })?;
Ok(())
}
diff --git a/crates/renderflow-core/src/ai/providers/openai.rs b/crates/renderflow-core/src/ai/providers/openai.rs
index 8c2a2b1..2cacf1a 100644
--- a/crates/renderflow-core/src/ai/providers/openai.rs
+++ b/crates/renderflow-core/src/ai/providers/openai.rs
@@ -13,6 +13,7 @@
//! variables. Keys are **never logged** at any log level.
use std::fmt;
+use std::time::Duration;
use anyhow::{Context, Result};
use serde_json::json;
@@ -163,12 +164,51 @@ impl OpenAiProvider {
"{}/v1/chat/completions",
self.endpoint.trim_end_matches('/')
);
- let body = json!({
+ let mut body = json!({
"model": request.model,
"messages": [{"role": "user", "content": request.prompt}],
});
+ let body_object = body.as_object_mut().expect("OpenAI request is an object");
+ if let Some(temperature) = request.params.temperature {
+ body_object.insert("temperature".to_string(), json!(temperature));
+ }
+ if let Some(max_tokens) = request.params.max_tokens {
+ body_object.insert("max_tokens".to_string(), json!(max_tokens));
+ }
+ if let Some(seed) = request.params.seed {
+ body_object.insert("seed".to_string(), json!(seed));
+ }
+ if let Some(top_p) = request.params.top_p {
+ body_object.insert("top_p".to_string(), json!(top_p));
+ }
+ if !request.params.stop.is_empty() {
+ body_object.insert("stop".to_string(), json!(request.params.stop));
+ }
+ if let Some(schema) = &request.output_schema {
+ body_object.insert(
+ "response_format".to_string(),
+ json!({
+ "type": "json_schema",
+ "json_schema": {
+ "name": "renderflow_skill_output",
+ "strict": true,
+ "schema": schema,
+ }
+ }),
+ );
+ } else if request.output_format == Some(crate::ai::OutputFormat::Json) {
+ body_object.insert(
+ "response_format".to_string(),
+ json!({"type": "json_object"}),
+ );
+ }
- let mut req = ureq::post(&url).set("Content-Type", "application/json");
+ let mut agent_builder = ureq::AgentBuilder::new();
+ if let Some(timeout_ms) = request.timeout_ms {
+ agent_builder = agent_builder.timeout(Duration::from_millis(timeout_ms));
+ }
+ let agent = agent_builder.build();
+ let mut req = agent.post(&url).set("Content-Type", "application/json");
if let Some(key) = self.resolve_api_key()? {
req = req.set("Authorization", &format!("Bearer {}", key));
}
diff --git a/crates/renderflow-core/src/ai/request.rs b/crates/renderflow-core/src/ai/request.rs
index d021f9f..6f4251c 100644
--- a/crates/renderflow-core/src/ai/request.rs
+++ b/crates/renderflow-core/src/ai/request.rs
@@ -3,6 +3,8 @@
use std::fmt;
+use serde_json::Value;
+
// ── OutputFormat ──────────────────────────────────────────────────────────────
/// The structured output format a transform expects from the AI backend.
@@ -50,6 +52,11 @@ pub struct GenerationParameters {
pub temperature: Option,
/// Maximum number of tokens to generate.
pub max_tokens: Option,
+ /// Optional random seed. Supporting providers use it as a repeatability
+ /// control; it never upgrades a probabilistic model to byte-deterministic.
+ pub seed: Option,
+ /// Optional nucleus-sampling probability.
+ pub top_p: Option,
/// Optional stop sequences that terminate generation early.
pub stop: Vec,
}
@@ -67,6 +74,12 @@ impl GenerationParameters {
if let Some(m) = self.max_tokens {
parts.push(format!("max_tokens:{}", m));
}
+ if let Some(seed) = self.seed {
+ parts.push(format!("seed:{}", seed));
+ }
+ if let Some(top_p) = self.top_p {
+ parts.push(format!("top_p:{:.6}", top_p));
+ }
if !self.stop.is_empty() {
let mut sorted = self.stop.clone();
sorted.sort();
@@ -88,8 +101,13 @@ pub struct AiRequest {
pub prompt: String,
/// Optional expected output format used for post-response validation.
pub output_format: Option,
+ /// Optional strict JSON Schema forwarded only by compatible adapters and
+ /// always revalidated by Renderflow after generation.
+ pub output_schema: Option,
/// Optional generation parameters forwarded to the backend.
pub params: GenerationParameters,
+ /// Optional wall-clock transport timeout in milliseconds.
+ pub timeout_ms: Option,
/// Prompt template version string, included in cache keys so that changing
/// the template invalidates existing cached responses.
pub prompt_version: Option,
@@ -102,7 +120,9 @@ impl AiRequest {
model: model.into(),
prompt: prompt.into(),
output_format: None,
+ output_schema: None,
params: GenerationParameters::default(),
+ timeout_ms: None,
prompt_version: None,
}
}
@@ -114,6 +134,13 @@ impl AiRequest {
self
}
+ /// Attach the strict structured-output JSON Schema.
+ #[must_use]
+ pub fn with_output_schema(mut self, schema: Value) -> Self {
+ self.output_schema = Some(schema);
+ self
+ }
+
/// Attach [`GenerationParameters`].
#[must_use]
pub fn with_params(mut self, params: GenerationParameters) -> Self {
@@ -121,6 +148,13 @@ impl AiRequest {
self
}
+ /// Attach a bounded provider transport timeout.
+ #[must_use]
+ pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
+ self.timeout_ms = Some(timeout_ms);
+ self
+ }
+
/// Attach a prompt template version string.
#[must_use]
pub fn with_prompt_version(mut self, version: impl Into) -> Self {
@@ -204,6 +238,7 @@ mod tests {
temperature: Some(0.7),
max_tokens: Some(256),
stop: vec!["END".to_string()],
+ ..Default::default()
};
let key = p.cache_key_fragment();
assert!(key.contains("temp:"));
@@ -232,6 +267,7 @@ mod tests {
assert_eq!(r.model, "mistral");
assert_eq!(r.prompt, "hello");
assert!(r.output_format.is_none());
+ assert!(r.output_schema.is_none());
assert!(r.prompt_version.is_none());
}
diff --git a/crates/renderflow-core/src/ai/runtime.rs b/crates/renderflow-core/src/ai/runtime.rs
new file mode 100644
index 0000000..096473c
--- /dev/null
+++ b/crates/renderflow-core/src/ai/runtime.rs
@@ -0,0 +1,780 @@
+//! Artifact-native execution of schema-bound AI skills.
+
+use std::time::{Instant, SystemTime, UNIX_EPOCH};
+
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use sha2::{Digest, Sha256};
+
+use super::catalog::{
+ AiAvailability, AiExecutionPreferenceV1, AiLocality, AiModelCatalog, AiResolutionReport,
+};
+use super::provider::AiProvider;
+use super::request::{AiRequest, OutputFormat};
+use super::retry::{execute_with_retry, RetryConfig};
+use super::skill::{
+ validate_json_instance, AiCandidateState, AiHygieneAction, AiProtectedReferenceRule,
+ AiSkillRegistry, AiSkillSpec,
+};
+use crate::artifact::{Artifact, ArtifactDescriptor, ArtifactStorageClass, ArtifactStore};
+use crate::graph::Format;
+
+pub const AI_EXECUTION_EVIDENCE_SCHEMA_V1: &str = "renderflow.ai-execution/v1";
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiInputArtifactEvidence {
+ pub artifact_id: String,
+ pub digest: String,
+ pub media_type: String,
+ pub approved_for_ai: bool,
+}
+
+#[derive(Debug, Clone)]
+pub struct AiSkillExecutionRequest {
+ pub skill_id: String,
+ pub skill_version: Option,
+ pub input: Value,
+ pub input_artifacts: Vec,
+ pub preference: AiExecutionPreferenceV1,
+ pub allow_remote: bool,
+ pub allow_unverified: bool,
+ pub source_approved: bool,
+ pub privacy_approved_for_remote: bool,
+ pub additional_protected_references: Vec,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AiHygieneStatus {
+ Passed,
+ Rewritten,
+ ReviewRequired,
+ Blocked,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiHygieneFindingEvidence {
+ pub code: String,
+ pub class: String,
+ pub action: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiHygieneEvidence {
+ pub policy_id: String,
+ pub status: AiHygieneStatus,
+ pub findings: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiDigestEvidence {
+ pub algorithm: String,
+ pub value: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiIdentityEvidence {
+ pub provider_id: String,
+ pub adapter: String,
+ pub runtime_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub runtime_revision: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub runtime_digest: Option,
+ pub model_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub model_revision: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub weights_digest: Option,
+ pub locality: AiLocality,
+ pub endpoint_identity: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiUsageEvidence {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub input_tokens: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub output_tokens: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub duration_ms: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cost_microunits: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiExecutionEvidence {
+ pub schema_version: String,
+ pub execution_id: String,
+ pub occurred_at_unix_ms: u128,
+ pub identity: AiIdentityEvidence,
+ pub skill_id: String,
+ pub skill_version: String,
+ pub skill_digest: AiDigestEvidence,
+ pub input_digest: AiDigestEvidence,
+ pub input_artifacts: Vec,
+ pub instruction_digest: AiDigestEvidence,
+ pub prompt_digest: AiDigestEvidence,
+ pub input_schema_digest: AiDigestEvidence,
+ pub output_schema_digest: AiDigestEvidence,
+ pub settings_digest: AiDigestEvidence,
+ pub hygiene_policy_digest: AiDigestEvidence,
+ pub cache_identity: AiDigestEvidence,
+ pub cache_decision: String,
+ pub determinism: String,
+ pub usage: AiUsageEvidence,
+ pub output_artifact_id: String,
+ pub output_digest: AiDigestEvidence,
+ pub output_state: AiCandidateState,
+ pub approval_required: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub approval_reference: Option,
+ pub validators: Vec,
+ pub validation_status: String,
+ pub pre_prompt_hygiene: AiHygieneEvidence,
+ pub post_output_hygiene: AiHygieneEvidence,
+ pub raw_inputs_retained: bool,
+ pub raw_prompts_retained: bool,
+}
+
+#[derive(Debug, Clone)]
+pub struct AiSkillExecutionOutcome {
+ pub artifact: Artifact,
+ pub evidence: AiExecutionEvidence,
+ pub resolution: AiResolutionReport,
+}
+
+pub struct AiSkillRuntime<'a> {
+ catalog: &'a AiModelCatalog,
+ skills: &'a AiSkillRegistry,
+ providers: Vec<&'a dyn AiProvider>,
+}
+
+impl<'a> AiSkillRuntime<'a> {
+ pub fn new(
+ catalog: &'a AiModelCatalog,
+ skills: &'a AiSkillRegistry,
+ providers: Vec<&'a dyn AiProvider>,
+ ) -> Self {
+ Self {
+ catalog,
+ skills,
+ providers,
+ }
+ }
+
+ pub fn resolve(&self, request: &AiSkillExecutionRequest) -> Result {
+ let skill = self.skill(request)?;
+ Ok(self.catalog.resolve(&skill.resolution_request(
+ request.preference,
+ request.allow_remote,
+ request.allow_unverified,
+ )))
+ }
+
+ pub fn execute(
+ &self,
+ request: &AiSkillExecutionRequest,
+ store: &ArtifactStore,
+ ) -> Result {
+ let skill = self.skill(request)?;
+ skill.validate()?;
+ validate_json_instance(&skill.input_schema, &request.input)
+ .context("AI skill input failed its declared schema")?;
+ let input_bytes = serde_json::to_vec(&request.input)?;
+ if input_bytes.len() > skill.budgets.max_input_bytes {
+ anyhow::bail!("AI skill input exceeds its declared byte budget");
+ }
+ if !request.source_approved
+ || request
+ .input_artifacts
+ .iter()
+ .any(|artifact| !artifact.approved_for_ai)
+ {
+ anyhow::bail!("AI skill source material is not approved for model exposure");
+ }
+
+ let resolution = self.resolve(request)?;
+ let selected = resolution
+ .selected
+ .as_ref()
+ .context("no provider/model satisfies the AI skill and active policy")?;
+ if selected.availability != AiAvailability::Available {
+ anyhow::bail!(
+ "selected model '{}:{}' is not execution-ready; inspect structured resolution evidence",
+ selected.provider_id,
+ selected.model_id
+ );
+ }
+ if selected.locality == AiLocality::Remote {
+ if !request.allow_remote || !skill.budgets.network || !skill.budgets.remote_execution {
+ anyhow::bail!(
+ "remote execution is not explicitly allowed by both request and skill"
+ );
+ }
+ if contains_pii(&input_bytes) && !request.privacy_approved_for_remote {
+ anyhow::bail!(
+ "remote execution of detected PII requires explicit privacy approval"
+ );
+ }
+ if !skill.hygiene.allow_private_remote_input
+ && skill.variables.iter().any(|variable| variable.sensitive)
+ {
+ anyhow::bail!("this skill forbids sensitive variables from remote execution");
+ }
+ }
+
+ let mut protected_references = skill.hygiene.protected_references.clone();
+ for rule in &request.additional_protected_references {
+ if rule.term.trim().is_empty() || !rule.term.is_ascii() {
+ anyhow::bail!(
+ "additional protected-reference terms must be non-empty ASCII strings"
+ );
+ }
+ if skill.hygiene.protected_reference_action == AiHygieneAction::Rewrite
+ && rule
+ .descriptive_replacement
+ .as_deref()
+ .is_none_or(|replacement| replacement.trim().is_empty())
+ {
+ anyhow::bail!(
+ "additional protected-reference rewrites require descriptive replacements"
+ );
+ }
+ }
+ protected_references.extend(request.additional_protected_references.clone());
+ let (rendered_prompt, pre_prompt_hygiene) =
+ render_and_sanitize_prompt(skill, &request.input, &protected_references)?;
+ ensure_not_blocked(&pre_prompt_hygiene, "pre-prompt")?;
+
+ let provider = self
+ .providers
+ .iter()
+ .find(|provider| provider.name() == selected.adapter)
+ .with_context(|| {
+ format!(
+ "resolved adapter '{}' is not configured in this runtime",
+ selected.adapter
+ )
+ })?;
+ let max_attempts = skill.budgets.max_retries.saturating_add(1);
+ let per_attempt_timeout_ms = skill.budgets.max_duration_ms / u64::from(max_attempts);
+ let mut ai_request = AiRequest::new(&selected.model_id, &rendered_prompt)
+ .with_output_format(OutputFormat::Json)
+ .with_params(skill.generation.provider_neutral_parameters())
+ .with_timeout_ms(per_attempt_timeout_ms)
+ .with_prompt_version(format!("{}@{}", skill.id, skill.version));
+ if skill.requires_json_schema {
+ ai_request = ai_request.with_output_schema(skill.output_schema.clone());
+ }
+ let started = Instant::now();
+ let retry_config = RetryConfig {
+ max_attempts,
+ initial_delay_ms: 0,
+ max_delay_ms: 0,
+ ..RetryConfig::default()
+ };
+ let response =
+ execute_with_retry(&retry_config, &skill.id, || provider.execute(&ai_request))
+ .with_context(|| {
+ format!(
+ "AI skill '{}@{}' failed through '{}:{}'",
+ skill.id, skill.version, selected.provider_id, selected.model_id
+ )
+ })?;
+ let observed_duration_ms = started.elapsed().as_millis() as u64;
+ if observed_duration_ms > skill.budgets.max_duration_ms {
+ anyhow::bail!("AI skill execution exceeded its declared time budget");
+ }
+ if response.provider != selected.adapter || response.model != selected.model_id {
+ anyhow::bail!("AI provider returned identity that does not match the resolved model");
+ }
+ if response
+ .output_tokens
+ .is_some_and(|tokens| tokens > skill.budgets.max_tokens)
+ {
+ anyhow::bail!("AI skill output exceeded its declared token budget");
+ }
+ if response.content.len() > skill.budgets.max_output_bytes {
+ anyhow::bail!("AI skill output exceeds its declared byte budget");
+ }
+ let mut output: Value = serde_json::from_str(&response.content)
+ .context("AI skill output is not valid structured JSON")?;
+ let post_output_hygiene = sanitize_json_output(&mut output, skill, &protected_references)?;
+ ensure_not_blocked(&post_output_hygiene, "post-output")?;
+ validate_json_instance(&skill.output_schema, &output)
+ .context("AI skill output failed its declared schema")?;
+
+ let output_bytes = serde_json::to_vec_pretty(&output)?;
+ let artifact = store.put_bytes(
+ &output_bytes,
+ ArtifactDescriptor::for_format(Format::Json, ArtifactStorageClass::Intermediate)
+ .with_metadata("renderflow.ai.skill_id", skill.id.clone())
+ .with_metadata("renderflow.ai.skill_version", skill.version.clone())
+ .with_metadata("renderflow.ai.provider_id", selected.provider_id.clone())
+ .with_metadata("renderflow.ai.model_id", selected.model_id.clone())
+ .with_metadata("renderflow.ai.output_state", "candidate")
+ .with_metadata("renderflow.ai.review_required", true),
+ )?;
+
+ let skill_digest = digest_json(skill)?;
+ let input_digest = digest_bytes(&input_bytes);
+ let instruction_digest = digest_bytes(
+ format!(
+ "{}\n{}",
+ skill.templates.system, skill.templates.instruction
+ )
+ .as_bytes(),
+ );
+ let prompt_digest = digest_bytes(rendered_prompt.as_bytes());
+ let input_schema_digest = digest_json(&skill.input_schema)?;
+ let output_schema_digest = digest_json(&skill.output_schema)?;
+ let settings_digest = digest_json(&skill.generation)?;
+ let hygiene_policy_digest = digest_json(&skill.hygiene)?;
+ let cache_identity = digest_json(&serde_json::json!({
+ "catalog_revision": self.catalog.revision,
+ "provider": selected.provider_id,
+ "runtime": selected.runtime_id,
+ "runtime_revision": selected.runtime_revision,
+ "runtime_digest": selected.runtime_digest,
+ "model": selected.model_id,
+ "model_revision": selected.model_revision,
+ "weights_digest": selected.weights_digest,
+ "skill": skill.id,
+ "skill_version": skill.version,
+ "skill_digest": skill_digest.value,
+ "input_digest": input_digest.value,
+ "input_schema_digest": input_schema_digest.value,
+ "output_schema_digest": output_schema_digest.value,
+ "settings_digest": settings_digest.value,
+ "hygiene_policy_digest": hygiene_policy_digest.value,
+ }))?;
+ let execution_id = format!("ai:sha256:{}", cache_identity.value);
+ let evidence = AiExecutionEvidence {
+ schema_version: AI_EXECUTION_EVIDENCE_SCHEMA_V1.to_string(),
+ execution_id,
+ occurred_at_unix_ms: SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis(),
+ identity: AiIdentityEvidence {
+ provider_id: selected.provider_id.clone(),
+ adapter: selected.adapter.clone(),
+ runtime_id: selected.runtime_id.clone(),
+ runtime_revision: selected.runtime_revision.clone(),
+ runtime_digest: selected.runtime_digest.clone(),
+ model_id: selected.model_id.clone(),
+ model_revision: selected.model_revision.clone(),
+ weights_digest: selected.weights_digest.clone(),
+ locality: selected.locality,
+ endpoint_identity: selected.runtime_id.clone(),
+ },
+ skill_id: skill.id.clone(),
+ skill_version: skill.version.clone(),
+ skill_digest,
+ input_digest,
+ input_artifacts: request.input_artifacts.clone(),
+ instruction_digest,
+ prompt_digest,
+ input_schema_digest,
+ output_schema_digest,
+ settings_digest,
+ hygiene_policy_digest,
+ cache_identity,
+ cache_decision: "not_requested".to_string(),
+ determinism: format!("{:?}", selected.determinism).to_lowercase(),
+ usage: AiUsageEvidence {
+ input_tokens: response.input_tokens,
+ output_tokens: response.output_tokens,
+ duration_ms: Some(response.duration_ms.unwrap_or(observed_duration_ms)),
+ cost_microunits: None,
+ },
+ output_artifact_id: artifact.id().to_string(),
+ output_digest: AiDigestEvidence {
+ algorithm: artifact.digest().algorithm().to_string(),
+ value: artifact.digest().value().to_string(),
+ },
+ output_state: AiCandidateState::Candidate,
+ approval_required: true,
+ approval_reference: None,
+ validators: skill.approval.validators.clone(),
+ validation_status: "valid_review_required".to_string(),
+ pre_prompt_hygiene,
+ post_output_hygiene,
+ raw_inputs_retained: false,
+ raw_prompts_retained: false,
+ };
+ Ok(AiSkillExecutionOutcome {
+ artifact,
+ evidence,
+ resolution,
+ })
+ }
+
+ fn skill(&self, request: &AiSkillExecutionRequest) -> Result<&AiSkillSpec> {
+ self.skills
+ .get(&request.skill_id, request.skill_version.as_deref())
+ .with_context(|| {
+ format!(
+ "AI skill '{}'{} is not registered",
+ request.skill_id,
+ request
+ .skill_version
+ .as_deref()
+ .map(|version| format!("@{version}"))
+ .unwrap_or_default()
+ )
+ })
+ }
+}
+
+fn render_and_sanitize_prompt(
+ skill: &AiSkillSpec,
+ input: &Value,
+ protected_references: &[AiProtectedReferenceRule],
+) -> Result<(String, AiHygieneEvidence)> {
+ let input = input
+ .as_object()
+ .context("AI skill input must be an object")?;
+ let mut prompt = format!(
+ "SYSTEM\n{}\n\nINSTRUCTION\n{}\n\nINPUT\n{}",
+ skill.templates.system, skill.templates.instruction, skill.templates.prompt
+ );
+ for variable in &skill.variables {
+ let value = input.get(&variable.name);
+ if variable.required && value.is_none() {
+ anyhow::bail!("AI skill variable '{}' is required", variable.name);
+ }
+ let rendered = match value {
+ Some(Value::String(value)) => value.clone(),
+ Some(value) => serde_json::to_string(value)?,
+ None => String::new(),
+ };
+ if rendered.len() > variable.max_bytes {
+ anyhow::bail!(
+ "AI skill variable '{}' exceeds its byte limit",
+ variable.name
+ );
+ }
+ prompt = prompt.replace(&format!("{{{{{}}}}}", variable.name), &rendered);
+ }
+ if prompt.contains("{{") || prompt.contains("}}") {
+ anyhow::bail!("AI skill prompt contains unresolved template variables");
+ }
+ if prompt.len() > skill.max_rendered_prompt_bytes {
+ anyhow::bail!("rendered AI skill prompt exceeds its byte budget");
+ }
+ sanitize_text(&mut prompt, skill, protected_references, "prompt")
+ .map(|evidence| (prompt, evidence))
+}
+
+fn sanitize_json_output(
+ output: &mut Value,
+ skill: &AiSkillSpec,
+ protected_references: &[AiProtectedReferenceRule],
+) -> Result {
+ let mut findings = Vec::new();
+ visit_strings(output, &mut |text| {
+ let evidence = sanitize_text(text, skill, protected_references, "output")?;
+ findings.extend(evidence.findings);
+ Ok(())
+ })?;
+ Ok(hygiene_evidence(skill, findings))
+}
+
+fn visit_strings(
+ value: &mut Value,
+ visitor: &mut impl FnMut(&mut String) -> Result<()>,
+) -> Result<()> {
+ match value {
+ Value::String(text) => visitor(text),
+ Value::Array(values) => {
+ for value in values {
+ visit_strings(value, visitor)?;
+ }
+ Ok(())
+ }
+ Value::Object(values) => {
+ for value in values.values_mut() {
+ visit_strings(value, visitor)?;
+ }
+ Ok(())
+ }
+ _ => Ok(()),
+ }
+}
+
+fn sanitize_text(
+ text: &mut String,
+ skill: &AiSkillSpec,
+ protected_references: &[AiProtectedReferenceRule],
+ stage: &str,
+) -> Result {
+ let mut findings = Vec::new();
+ if skill.hygiene.scan_secrets && contains_secret(text.as_bytes()) {
+ findings.push(AiHygieneFindingEvidence {
+ code: format!("ai.hygiene.{stage}.secret"),
+ class: "credential".to_string(),
+ action: "block".to_string(),
+ });
+ }
+ if contains_pii(text.as_bytes()) {
+ findings.push(AiHygieneFindingEvidence {
+ code: format!("ai.hygiene.{stage}.pii"),
+ class: "possible_contact_identifier".to_string(),
+ action: action_name(skill.hygiene.pii_action).to_string(),
+ });
+ }
+ for rule in protected_references {
+ if contains_case_insensitive(text, &rule.term) {
+ let action = skill.hygiene.protected_reference_action;
+ findings.push(AiHygieneFindingEvidence {
+ code: format!("ai.hygiene.{stage}.protected_reference"),
+ class: "configured_protected_reference".to_string(),
+ action: action_name(action).to_string(),
+ });
+ if action == AiHygieneAction::Rewrite {
+ let replacement = rule
+ .descriptive_replacement
+ .as_deref()
+ .context("protected-reference rewrite is missing a descriptive replacement")?;
+ *text = replace_case_insensitive(text, &rule.term, replacement);
+ }
+ }
+ }
+ Ok(hygiene_evidence(skill, findings))
+}
+
+fn hygiene_evidence(
+ skill: &AiSkillSpec,
+ findings: Vec,
+) -> AiHygieneEvidence {
+ let blocked = findings.iter().any(|finding| finding.action == "block");
+ let rewritten = findings.iter().any(|finding| finding.action == "rewrite");
+ let review = findings.iter().any(|finding| finding.action == "review")
+ || (skill.hygiene.post_output_review && !findings.is_empty());
+ AiHygieneEvidence {
+ policy_id: skill.hygiene.policy_id.clone(),
+ status: if blocked {
+ AiHygieneStatus::Blocked
+ } else if review {
+ AiHygieneStatus::ReviewRequired
+ } else if rewritten {
+ AiHygieneStatus::Rewritten
+ } else {
+ AiHygieneStatus::Passed
+ },
+ findings,
+ }
+}
+
+fn ensure_not_blocked(evidence: &AiHygieneEvidence, stage: &str) -> Result<()> {
+ if evidence.status == AiHygieneStatus::Blocked {
+ anyhow::bail!("AI {stage} hygiene blocked execution; raw findings were not retained");
+ }
+ Ok(())
+}
+
+fn action_name(action: AiHygieneAction) -> &'static str {
+ match action {
+ AiHygieneAction::Block => "block",
+ AiHygieneAction::Rewrite => "rewrite",
+ AiHygieneAction::Review => "review",
+ }
+}
+
+fn contains_secret(bytes: &[u8]) -> bool {
+ let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
+ text.contains("-----begin private key-----")
+ || text.contains("github_pat_")
+ || text.contains("authorization: bearer ")
+ || text.contains("api_key=")
+ || text.contains("password=")
+ || text.split_whitespace().any(|word| {
+ word.strip_prefix("sk-")
+ .is_some_and(|tail| tail.len() >= 20)
+ })
+}
+
+fn contains_pii(bytes: &[u8]) -> bool {
+ String::from_utf8_lossy(bytes)
+ .split_whitespace()
+ .any(|word| {
+ let trimmed = word.trim_matches(|character: char| {
+ !character.is_ascii_alphanumeric() && !matches!(character, '@' | '.' | '_' | '-')
+ });
+ let mut parts = trimmed.split('@');
+ parts.next().is_some_and(|local| !local.is_empty())
+ && parts
+ .next()
+ .is_some_and(|domain| domain.contains('.') && domain.len() >= 3)
+ && parts.next().is_none()
+ })
+}
+
+fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
+ !needle.is_empty()
+ && haystack
+ .as_bytes()
+ .windows(needle.len())
+ .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
+}
+
+fn replace_case_insensitive(haystack: &str, needle: &str, replacement: &str) -> String {
+ if needle.is_empty() {
+ return haystack.to_string();
+ }
+ let mut output = String::new();
+ let mut cursor = 0;
+ while let Some(relative) = haystack.as_bytes()[cursor..]
+ .windows(needle.len())
+ .position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
+ {
+ let start = cursor + relative;
+ output.push_str(&haystack[cursor..start]);
+ output.push_str(replacement);
+ cursor = start + needle.len();
+ }
+ output.push_str(&haystack[cursor..]);
+ output
+}
+
+fn digest_json(value: &impl Serialize) -> Result {
+ Ok(digest_bytes(&serde_json::to_vec(value)?))
+}
+
+fn digest_bytes(bytes: &[u8]) -> AiDigestEvidence {
+ let mut hasher = Sha256::new();
+ hasher.update(bytes);
+ AiDigestEvidence {
+ algorithm: "sha256".to_string(),
+ value: format!("{:x}", hasher.finalize()),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::ai::catalog::{AiAvailabilityProbe, AiProviderCatalogEntry, AiRuntimeDescriptor};
+ use crate::ai::{AiCapabilities, AiModel, AiResponse};
+
+ #[derive(Debug)]
+ struct FixtureProvider;
+
+ impl AiProvider for FixtureProvider {
+ fn name(&self) -> &str {
+ "fixture"
+ }
+
+ fn is_local(&self) -> bool {
+ true
+ }
+
+ fn capabilities(&self) -> AiCapabilities {
+ AiCapabilities::new()
+ }
+
+ fn models(&self) -> Vec {
+ vec![AiModel::new("fixture-model", true)]
+ }
+
+ fn execute(&self, request: &AiRequest) -> Result {
+ assert!(request.output_schema.is_some());
+ assert_eq!(request.params.seed, Some(42));
+ assert_eq!(request.timeout_ms, Some(30_000));
+ Ok(AiResponse::new(
+ r#"{"title":"Synthetic booklet","summary":"Mindful geometric pauses.","keywords":["mindfulness"],"review_required":true}"#,
+ "fixture-model",
+ "fixture",
+ ))
+ }
+ }
+
+ fn fixture_catalog() -> AiModelCatalog {
+ let mut catalog = AiModelCatalog::bundled().unwrap();
+ let mut model = catalog.providers[0].models[0].clone();
+ model.id = "fixture-model".to_string();
+ model.availability = AiAvailability::Available;
+ catalog.providers = vec![AiProviderCatalogEntry {
+ id: "provider.fixture".to_string(),
+ adapter: "fixture".to_string(),
+ display_name: "Fixture".to_string(),
+ locality: AiLocality::Local,
+ requires_network_permission: false,
+ runtime: AiRuntimeDescriptor {
+ id: "runtime.fixture".to_string(),
+ revision: Some("1".to_string()),
+ digest: Some("sha256:fixture".to_string()),
+ source: None,
+ license: Some("MIT".to_string()),
+ hardware_requirements: Vec::new(),
+ availability_probe: AiAvailabilityProbe {
+ kind: "fixture".to_string(),
+ bounded_timeout_ms: 1,
+ endpoint: None,
+ },
+ },
+ models: vec![model],
+ }];
+ catalog
+ }
+
+ #[test]
+ fn runtime_creates_validated_candidate_artifact_and_redacted_evidence() {
+ let catalog = fixture_catalog();
+ let skills = AiSkillRegistry::bundled().unwrap();
+ let provider = FixtureProvider;
+ let runtime = AiSkillRuntime::new(&catalog, &skills, vec![&provider]);
+ let directory = tempfile::tempdir().unwrap();
+ let store = ArtifactStore::new(directory.path()).unwrap();
+ let outcome = runtime
+ .execute(
+ &AiSkillExecutionRequest {
+ skill_id: "skill.metadata.extract".to_string(),
+ skill_version: None,
+ input: serde_json::json!({"source_text": "Synthetic mindful geometry."}),
+ input_artifacts: Vec::new(),
+ preference: AiExecutionPreferenceV1::LocalOnly,
+ allow_remote: false,
+ allow_unverified: false,
+ source_approved: true,
+ privacy_approved_for_remote: false,
+ additional_protected_references: Vec::new(),
+ },
+ &store,
+ )
+ .unwrap();
+ assert_eq!(outcome.evidence.output_state, AiCandidateState::Candidate);
+ assert!(outcome.evidence.approval_required);
+ assert!(!outcome.evidence.raw_prompts_retained);
+ assert!(store.verify(&outcome.artifact).is_ok());
+ }
+
+ #[test]
+ fn protected_reference_is_rewritten_without_leaking_term_into_evidence() {
+ let skills = AiSkillRegistry::bundled().unwrap();
+ let skill = skills.get("skill.prompt.from-sanitized-dna", None).unwrap();
+ let (prompt, evidence) = render_and_sanitize_prompt(
+ skill,
+ &serde_json::json!({
+ "dna": "Example Franchise mood",
+ "intent": "Original divider"
+ }),
+ &skill.hygiene.protected_references,
+ )
+ .unwrap();
+ assert!(!prompt.contains("Example Franchise"));
+ assert!(prompt.contains("weathered post-industrial"));
+ assert!(!serde_json::to_string(&evidence)
+ .unwrap()
+ .contains("Example Franchise"));
+ }
+}
diff --git a/crates/renderflow-core/src/ai/skill.rs b/crates/renderflow-core/src/ai/skill.rs
new file mode 100644
index 0000000..07dc3a0
--- /dev/null
+++ b/crates/renderflow-core/src/ai/skill.rs
@@ -0,0 +1,702 @@
+//! Reviewed, versioned AI skill specifications and strict JSON contracts.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::Path;
+
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use super::catalog::{AiModality, AiOperation, AiResolutionRequest};
+use super::request::GenerationParameters;
+
+pub const AI_SKILL_SCHEMA_V1: &str = "renderflow.ai-skill/v1";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AiCandidateState {
+ Candidate,
+ Approved,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AiHygieneAction {
+ Block,
+ Rewrite,
+ Review,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiSkillVariable {
+ pub name: String,
+ pub required: bool,
+ #[serde(default)]
+ pub sensitive: bool,
+ pub max_bytes: usize,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiSkillTemplates {
+ pub system: String,
+ pub instruction: String,
+ pub prompt: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiSkillGeneration {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub temperature: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub max_tokens: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub seed: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub top_p: Option,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub stop: Vec,
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub provider_extensions: BTreeMap,
+}
+
+impl AiSkillGeneration {
+ pub fn provider_neutral_parameters(&self) -> GenerationParameters {
+ GenerationParameters {
+ temperature: self.temperature,
+ max_tokens: self.max_tokens,
+ seed: self.seed,
+ top_p: self.top_p,
+ stop: self.stop.clone(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiSkillBudgets {
+ pub network: bool,
+ pub remote_execution: bool,
+ pub max_input_bytes: usize,
+ pub max_output_bytes: usize,
+ pub max_tokens: u32,
+ pub max_duration_ms: u64,
+ pub max_retries: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub max_cost_microunits: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiProtectedReferenceRule {
+ pub term: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub descriptive_replacement: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiSkillHygienePolicy {
+ pub policy_id: String,
+ pub scan_secrets: bool,
+ pub pii_action: AiHygieneAction,
+ pub protected_reference_action: AiHygieneAction,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub protected_references: Vec,
+ pub allow_private_remote_input: bool,
+ pub retain_raw_prompts: bool,
+ pub post_output_review: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiSkillApprovalPolicy {
+ pub initial_state: AiCandidateState,
+ pub human_review_required: bool,
+ pub validators: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiSkillProvenancePolicy {
+ pub cache_identity_fields: Vec,
+ pub evidence_fields: Vec,
+ pub redact_raw_inputs: bool,
+ pub redact_raw_prompts: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiSkillSpec {
+ pub schema_version: String,
+ pub id: String,
+ pub version: String,
+ pub purpose: String,
+ pub artifact_families: Vec,
+ pub operations: Vec,
+ pub input_modalities: Vec,
+ pub output_modalities: Vec,
+ pub requires_json_schema: bool,
+ pub input_schema: Value,
+ pub output_schema: Value,
+ pub templates: AiSkillTemplates,
+ pub variables: Vec,
+ pub max_rendered_prompt_bytes: usize,
+ pub generation: AiSkillGeneration,
+ pub budgets: AiSkillBudgets,
+ pub hygiene: AiSkillHygienePolicy,
+ pub approval: AiSkillApprovalPolicy,
+ pub provenance: AiSkillProvenancePolicy,
+ pub redistribution_notes: String,
+ pub license_notes: String,
+ pub fixture: Value,
+}
+
+impl AiSkillSpec {
+ pub fn from_json(contents: &str) -> Result {
+ let skill: Self = serde_json::from_str(contents).context("skill is not valid JSON")?;
+ skill.validate()?;
+ Ok(skill)
+ }
+
+ pub fn load(path: impl AsRef) -> Result {
+ let path = path.as_ref();
+ let contents = std::fs::read_to_string(path)
+ .with_context(|| format!("failed to read AI skill '{}'", path.display()))?;
+ Self::from_json(&contents).with_context(|| format!("invalid AI skill '{}'", path.display()))
+ }
+
+ pub fn validate(&self) -> Result<()> {
+ if self.schema_version != AI_SKILL_SCHEMA_V1 {
+ anyhow::bail!(
+ "unsupported AI skill schema '{}'; expected '{}'",
+ self.schema_version,
+ AI_SKILL_SCHEMA_V1
+ );
+ }
+ validate_skill_id(&self.id)?;
+ if self.version.trim().is_empty() || self.purpose.trim().is_empty() {
+ anyhow::bail!("AI skill version and purpose must not be empty");
+ }
+ if self.operations.is_empty()
+ || self.input_modalities.is_empty()
+ || self.output_modalities.is_empty()
+ {
+ anyhow::bail!("AI skill must declare operations and typed input/output modalities");
+ }
+ validate_json_schema_definition(&self.input_schema, "input_schema")?;
+ validate_json_schema_definition(&self.output_schema, "output_schema")?;
+ require_closed_object_schema(&self.input_schema, "input_schema")?;
+ require_closed_object_schema(&self.output_schema, "output_schema")?;
+ validate_json_instance(&self.input_schema, &self.fixture)
+ .context("synthetic fixture does not satisfy input_schema")?;
+ if self.max_rendered_prompt_bytes == 0
+ || self.budgets.max_input_bytes == 0
+ || self.budgets.max_output_bytes == 0
+ || self.budgets.max_duration_ms == 0
+ {
+ anyhow::bail!("AI skill byte and time budgets must be greater than zero");
+ }
+ if self.budgets.remote_execution && !self.budgets.network {
+ anyhow::bail!("AI skill cannot allow remote execution while network use is disabled");
+ }
+ if self.generation.max_tokens.unwrap_or(0) > self.budgets.max_tokens {
+ anyhow::bail!("generation max_tokens exceeds the skill token budget");
+ }
+ if self.budgets.max_retries > 10 {
+ anyhow::bail!("AI skill retry budget must not exceed 10 retries");
+ }
+ if self.budgets.max_duration_ms < u64::from(self.budgets.max_retries.saturating_add(1)) {
+ anyhow::bail!("AI skill time budget must allocate at least 1 ms per attempt");
+ }
+ if self
+ .generation
+ .temperature
+ .is_some_and(|temperature| !(0.0..=2.0).contains(&temperature))
+ {
+ anyhow::bail!("AI skill temperature must be between 0.0 and 2.0");
+ }
+ if self
+ .generation
+ .top_p
+ .is_some_and(|top_p| !(0.0..=1.0).contains(&top_p))
+ {
+ anyhow::bail!("AI skill top_p must be between 0.0 and 1.0");
+ }
+ if self.approval.initial_state == AiCandidateState::Approved
+ && self.approval.human_review_required
+ {
+ anyhow::bail!("a review-required AI skill must initially produce a candidate");
+ }
+ if self.hygiene.retain_raw_prompts || !self.provenance.redact_raw_prompts {
+ anyhow::bail!("v1 AI skills must redact raw prompts from durable evidence");
+ }
+
+ let variable_names: BTreeSet<&str> = self
+ .variables
+ .iter()
+ .map(|variable| variable.name.as_str())
+ .collect();
+ if variable_names.len() != self.variables.len() {
+ anyhow::bail!("AI skill variable names must be unique");
+ }
+ for variable in &self.variables {
+ validate_variable_name(&variable.name)?;
+ if variable.max_bytes == 0 {
+ anyhow::bail!(
+ "AI skill variable '{}' must have a positive byte limit",
+ variable.name
+ );
+ }
+ }
+ let referenced = referenced_variables(&format!(
+ "{}\n{}\n{}",
+ self.templates.system, self.templates.instruction, self.templates.prompt
+ ))?;
+ for referenced_name in &referenced {
+ if !variable_names.contains(referenced_name.as_str()) {
+ anyhow::bail!(
+ "AI skill template references undeclared variable '{{{{{referenced_name}}}}}'"
+ );
+ }
+ }
+ let input_properties = self.input_schema["properties"]
+ .as_object()
+ .context("input_schema.properties must be an object")?;
+ let required_properties: BTreeSet<&str> = self.input_schema["required"]
+ .as_array()
+ .context("input_schema.required must be an array")?
+ .iter()
+ .filter_map(Value::as_str)
+ .collect();
+ for variable in &self.variables {
+ if !input_properties.contains_key(&variable.name) {
+ anyhow::bail!(
+ "AI skill variable '{}' is missing from input_schema.properties",
+ variable.name
+ );
+ }
+ if variable.required != required_properties.contains(variable.name.as_str()) {
+ anyhow::bail!(
+ "AI skill variable '{}' required flag disagrees with input_schema.required",
+ variable.name
+ );
+ }
+ if variable.required && !referenced.contains(&variable.name) {
+ anyhow::bail!(
+ "required AI skill variable '{}' is not used by its templates",
+ variable.name
+ );
+ }
+ }
+ for rule in &self.hygiene.protected_references {
+ if rule.term.trim().is_empty() {
+ anyhow::bail!("protected-reference terms must not be empty");
+ }
+ if !rule.term.is_ascii() {
+ anyhow::bail!("v1 protected-reference terms must be ASCII for stable matching");
+ }
+ if self.hygiene.protected_reference_action == AiHygieneAction::Rewrite
+ && rule
+ .descriptive_replacement
+ .as_deref()
+ .is_none_or(|replacement| replacement.trim().is_empty())
+ {
+ anyhow::bail!(
+ "rewrite policy requires a descriptive replacement for every protected reference"
+ );
+ }
+ }
+ Ok(())
+ }
+
+ pub fn resolution_request(
+ &self,
+ preference: super::catalog::AiExecutionPreferenceV1,
+ allow_remote: bool,
+ allow_unverified: bool,
+ ) -> AiResolutionRequest {
+ AiResolutionRequest {
+ operations: self.operations.clone(),
+ input_modalities: self.input_modalities.clone(),
+ output_modalities: self.output_modalities.clone(),
+ requires_json_schema: self.requires_json_schema,
+ preference,
+ allow_remote,
+ remote_policy_allows: self.budgets.remote_execution && self.budgets.network,
+ allow_unverified,
+ }
+ }
+}
+
+fn require_closed_object_schema(schema: &Value, path: &str) -> Result<()> {
+ if schema["type"] != "object" {
+ anyhow::bail!("{path} must declare type 'object'");
+ }
+ if schema["additionalProperties"] != false {
+ anyhow::bail!("{path} must set additionalProperties to false");
+ }
+ if !schema["properties"].is_object() {
+ anyhow::bail!("{path}.properties must be an object");
+ }
+ if !schema["required"].is_array() {
+ anyhow::bail!("{path}.required must be an array");
+ }
+ Ok(())
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct AiSkillRegistry {
+ skills: Vec,
+}
+
+impl AiSkillRegistry {
+ pub fn bundled() -> Result {
+ let sources = [
+ include_str!("../../data/ai/skills/metadata-extraction-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"),
+ ];
+ let mut registry = Self::default();
+ for source in sources {
+ registry.insert(AiSkillSpec::from_json(source)?)?;
+ }
+ Ok(registry)
+ }
+
+ pub fn insert(&mut self, skill: AiSkillSpec) -> Result<()> {
+ skill.validate()?;
+ if self
+ .skills
+ .iter()
+ .any(|existing| existing.id == skill.id && existing.version == skill.version)
+ {
+ anyhow::bail!("duplicate AI skill '{}@{}'", skill.id, skill.version);
+ }
+ self.skills.push(skill);
+ self.skills
+ .sort_by(|left, right| (&left.id, &left.version).cmp(&(&right.id, &right.version)));
+ Ok(())
+ }
+
+ pub fn get(&self, id: &str, version: Option<&str>) -> Option<&AiSkillSpec> {
+ self.skills
+ .iter()
+ .rev()
+ .find(|skill| skill.id == id && version.is_none_or(|value| skill.version == value))
+ }
+
+ pub fn iter(&self) -> impl Iterator- {
+ self.skills.iter()
+ }
+
+ pub fn validate_all(&self) -> Vec {
+ self.skills
+ .iter()
+ .map(|skill| match skill.validate() {
+ Ok(()) => AiSkillValidationResult {
+ id: skill.id.clone(),
+ version: skill.version.clone(),
+ valid: true,
+ error: None,
+ },
+ Err(error) => AiSkillValidationResult {
+ id: skill.id.clone(),
+ version: skill.version.clone(),
+ valid: false,
+ error: Some(error.to_string()),
+ },
+ })
+ .collect()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AiSkillValidationResult {
+ pub id: String,
+ pub version: String,
+ pub valid: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+}
+
+fn validate_skill_id(value: &str) -> Result<()> {
+ if !value.starts_with("skill.")
+ || !value.chars().all(|character| {
+ character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
+ })
+ {
+ anyhow::bail!("AI skill id '{value}' must be a stable 'skill.*' identifier");
+ }
+ Ok(())
+}
+
+fn validate_variable_name(value: &str) -> Result<()> {
+ if value.is_empty()
+ || !value
+ .chars()
+ .all(|character| character.is_ascii_alphanumeric() || character == '_')
+ {
+ anyhow::bail!("AI skill variable '{value}' must be alphanumeric or underscore");
+ }
+ Ok(())
+}
+
+fn referenced_variables(template: &str) -> Result> {
+ let mut names = BTreeSet::new();
+ let mut remaining = template;
+ while let Some(start) = remaining.find("{{") {
+ let after_start = &remaining[start + 2..];
+ let Some(end) = after_start.find("}}") else {
+ anyhow::bail!("AI skill template contains an unclosed variable expression");
+ };
+ let name = after_start[..end].trim();
+ validate_variable_name(name)?;
+ names.insert(name.to_string());
+ remaining = &after_start[end + 2..];
+ }
+ if remaining.contains("}}") {
+ anyhow::bail!("AI skill template contains an unmatched closing variable expression");
+ }
+ Ok(names)
+}
+
+/// Validate the conservative JSON Schema subset supported by the v1 skill runtime.
+///
+/// Rejecting unknown validation keywords keeps contracts strict: a skill cannot
+/// appear validated while relying on a keyword the runtime silently ignores.
+pub fn validate_json_schema_definition(schema: &Value, path: &str) -> Result<()> {
+ let object = schema
+ .as_object()
+ .with_context(|| format!("{path} must be a JSON Schema object"))?;
+ let supported = [
+ "$schema",
+ "$id",
+ "title",
+ "description",
+ "type",
+ "const",
+ "enum",
+ "required",
+ "properties",
+ "additionalProperties",
+ "items",
+ "minLength",
+ "maxLength",
+ "minimum",
+ "maximum",
+ "minItems",
+ "maxItems",
+ ];
+ for key in object.keys() {
+ if !supported.contains(&key.as_str()) {
+ anyhow::bail!("{path} uses unsupported JSON Schema keyword '{key}'");
+ }
+ }
+ if let Some(schema_type) = object.get("type") {
+ let schema_type = schema_type
+ .as_str()
+ .with_context(|| format!("{path}.type must be a string"))?;
+ if ![
+ "object", "array", "string", "number", "integer", "boolean", "null",
+ ]
+ .contains(&schema_type)
+ {
+ anyhow::bail!("{path}.type '{schema_type}' is not supported");
+ }
+ }
+ if let Some(properties) = object.get("properties") {
+ for (name, property) in properties
+ .as_object()
+ .with_context(|| format!("{path}.properties must be an object"))?
+ {
+ validate_json_schema_definition(property, &format!("{path}.properties.{name}"))?;
+ }
+ }
+ if let Some(items) = object.get("items") {
+ validate_json_schema_definition(items, &format!("{path}.items"))?;
+ }
+ if let Some(required) = object.get("required") {
+ let required = required
+ .as_array()
+ .with_context(|| format!("{path}.required must be an array"))?;
+ if required.iter().any(|item| !item.is_string()) {
+ anyhow::bail!("{path}.required entries must be strings");
+ }
+ }
+ if let Some(additional) = object.get("additionalProperties") {
+ if !additional.is_boolean() {
+ anyhow::bail!("{path}.additionalProperties must be a boolean");
+ }
+ }
+ Ok(())
+}
+
+/// Validate a JSON value against the runtime's conservative schema subset.
+pub fn validate_json_instance(schema: &Value, instance: &Value) -> Result<()> {
+ validate_instance_at(schema, instance, "$")
+}
+
+fn validate_instance_at(schema: &Value, instance: &Value, path: &str) -> Result<()> {
+ let object = schema
+ .as_object()
+ .context("validated schema must be an object")?;
+ if let Some(expected) = object.get("const") {
+ if instance != expected {
+ anyhow::bail!("{path} does not match the schema const value");
+ }
+ }
+ if let Some(allowed) = object.get("enum") {
+ let allowed = allowed.as_array().context("schema enum must be an array")?;
+ if !allowed.contains(instance) {
+ anyhow::bail!("{path} is not one of the allowed enum values");
+ }
+ }
+ if let Some(expected_type) = object.get("type").and_then(Value::as_str) {
+ let valid_type = match expected_type {
+ "object" => instance.is_object(),
+ "array" => instance.is_array(),
+ "string" => instance.is_string(),
+ "number" => instance.is_number(),
+ "integer" => instance.as_i64().is_some() || instance.as_u64().is_some(),
+ "boolean" => instance.is_boolean(),
+ "null" => instance.is_null(),
+ _ => false,
+ };
+ if !valid_type {
+ anyhow::bail!("{path} must be of type '{expected_type}'");
+ }
+ }
+ if let Some(value) = instance.as_object() {
+ let properties = object.get("properties").and_then(Value::as_object);
+ if let Some(required) = object.get("required").and_then(Value::as_array) {
+ for name in required.iter().filter_map(Value::as_str) {
+ if !value.contains_key(name) {
+ anyhow::bail!("{path}.{name} is required");
+ }
+ }
+ }
+ if object.get("additionalProperties").and_then(Value::as_bool) == Some(false) {
+ for name in value.keys() {
+ if !properties.is_some_and(|properties| properties.contains_key(name)) {
+ anyhow::bail!("{path}.{name} is not allowed by the schema");
+ }
+ }
+ }
+ if let Some(properties) = properties {
+ for (name, child_schema) in properties {
+ if let Some(child) = value.get(name) {
+ validate_instance_at(child_schema, child, &format!("{path}.{name}"))?;
+ }
+ }
+ }
+ }
+ if let Some(value) = instance.as_array() {
+ if let Some(minimum) = object.get("minItems").and_then(Value::as_u64) {
+ if value.len() < minimum as usize {
+ anyhow::bail!("{path} contains fewer than {minimum} items");
+ }
+ }
+ if let Some(maximum) = object.get("maxItems").and_then(Value::as_u64) {
+ if value.len() > maximum as usize {
+ anyhow::bail!("{path} contains more than {maximum} items");
+ }
+ }
+ if let Some(item_schema) = object.get("items") {
+ for (index, child) in value.iter().enumerate() {
+ validate_instance_at(item_schema, child, &format!("{path}[{index}]"))?;
+ }
+ }
+ }
+ if let Some(value) = instance.as_str() {
+ if let Some(minimum) = object.get("minLength").and_then(Value::as_u64) {
+ if value.chars().count() < minimum as usize {
+ anyhow::bail!("{path} is shorter than {minimum} characters");
+ }
+ }
+ if let Some(maximum) = object.get("maxLength").and_then(Value::as_u64) {
+ if value.chars().count() > maximum as usize {
+ anyhow::bail!("{path} is longer than {maximum} characters");
+ }
+ }
+ }
+ if let Some(value) = instance.as_f64() {
+ if let Some(minimum) = object.get("minimum").and_then(Value::as_f64) {
+ if value < minimum {
+ anyhow::bail!("{path} is less than {minimum}");
+ }
+ }
+ if let Some(maximum) = object.get("maximum").and_then(Value::as_f64) {
+ if value > maximum {
+ anyhow::bail!("{path} is greater than {maximum}");
+ }
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use sha2::{Digest, Sha256};
+
+ #[test]
+ fn bundled_skills_are_valid_and_candidate_first() {
+ let registry = AiSkillRegistry::bundled().unwrap();
+ assert_eq!(registry.iter().count(), 4);
+ assert!(registry.iter().all(|skill| {
+ skill.approval.initial_state == AiCandidateState::Candidate
+ && skill.approval.human_review_required
+ }));
+ }
+
+ #[test]
+ fn visual_skill_fixtures_pin_the_bundled_synthetic_image() {
+ let digest = format!(
+ "sha256:{:x}",
+ Sha256::digest(include_bytes!(
+ "../../data/ai/fixtures/synthetic-layout.svg"
+ ))
+ );
+ let registry = AiSkillRegistry::bundled().unwrap();
+ for id in [
+ "skill.visual-dna.describe",
+ "skill.accessibility.describe-candidate",
+ ] {
+ assert_eq!(
+ registry.get(id, None).unwrap().fixture["image_digest"],
+ digest
+ );
+ }
+ }
+
+ #[test]
+ fn strict_contract_rejects_unknown_fields() {
+ let schema = serde_json::json!({
+ "type": "object",
+ "required": ["title"],
+ "properties": {"title": {"type": "string"}},
+ "additionalProperties": false
+ });
+ let error = validate_json_instance(
+ &schema,
+ &serde_json::json!({"title": "Safe", "surprise": true}),
+ )
+ .unwrap_err();
+ assert!(error.to_string().contains("surprise"));
+ }
+
+ #[test]
+ fn unsupported_schema_keywords_are_rejected() {
+ let error = validate_json_schema_definition(
+ &serde_json::json!({"type": "string", "pattern": "unsafe"}),
+ "schema",
+ )
+ .unwrap_err();
+ assert!(error.to_string().contains("unsupported"));
+ }
+}
diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs
index 30626c9..14e1a15 100644
--- a/crates/renderflow-core/src/app.rs
+++ b/crates/renderflow-core/src/app.rs
@@ -3,8 +3,8 @@ use clap::Parser;
use tracing::info;
use crate::cli::{
- AiCommands, Cli, Commands, EbookCommands, GraphCommands, LuluCommands, PluginCommands,
- PublicationCommands, SpecCommands, ToolCommands, VideoCommands,
+ AiCommands, AiSkillCommands, Cli, Commands, EbookCommands, GraphCommands, LuluCommands,
+ PluginCommands, PublicationCommands, SpecCommands, ToolCommands, VideoCommands,
};
use crate::video::HandBrakeLimits;
use crate::{commands, transforms};
@@ -101,6 +101,37 @@ pub fn run_cli(cli: Cli) -> Result<()> {
}
}
Some(Commands::Ai { subcommand }) => match subcommand {
+ AiCommands::Matrix { format, catalog } => {
+ commands::ai::run_matrix(&format, catalog.as_deref())?
+ }
+ AiCommands::Resolve {
+ skill,
+ skill_version,
+ execution_preference,
+ allow_remote,
+ allow_unverified,
+ format,
+ catalog,
+ } => commands::ai::run_resolve(
+ &skill,
+ skill_version.as_deref(),
+ &execution_preference,
+ allow_remote,
+ allow_unverified,
+ &format,
+ catalog.as_deref(),
+ )?,
+ AiCommands::Skills { subcommand } => match subcommand {
+ AiSkillCommands::List { format } => commands::ai::run_skills_list(&format)?,
+ AiSkillCommands::Inspect {
+ id,
+ version,
+ format,
+ } => commands::ai::run_skills_inspect(&id, version.as_deref(), &format)?,
+ AiSkillCommands::Validate { path, format } => {
+ commands::ai::run_skills_validate(path.as_deref(), &format)?
+ }
+ },
AiCommands::Providers => commands::ai::run_providers()?,
AiCommands::Models => commands::ai::run_models()?,
AiCommands::Doctor { ollama_endpoint } => commands::ai::run_doctor(&ollama_endpoint)?,
diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs
index de2f563..0b04135 100644
--- a/crates/renderflow-core/src/cli.rs
+++ b/crates/renderflow-core/src/cli.rs
@@ -218,7 +218,7 @@ pub enum Commands {
subcommand: PluginCommands,
},
- /// Manage and inspect AI providers and the AI transform cache
+ /// Resolve model-aware AI skills and inspect providers, policy, and cache state
///
/// These commands help you discover configured AI providers, inspect
/// available models, run connectivity diagnostics, and manage the AI
@@ -227,8 +227,10 @@ pub enum Commands {
subcommand_required = true,
arg_required_else_help = true,
after_help = "Examples:\n \
- renderflow ai providers List available AI providers\n \
- renderflow ai models List available models per provider\n \
+ renderflow ai matrix Inspect model-specific compatibility\n \
+ renderflow ai resolve --skill skill.metadata.extract\n \
+ renderflow ai skills validate Validate bundled skill contracts\n \
+ renderflow ai providers List legacy provider-wide capabilities\n \
renderflow ai doctor Run AI provider diagnostics\n \
renderflow ai cache Show AI cache statistics"
)]
@@ -555,6 +557,53 @@ pub enum ToolCommands {
/// Subcommands for `renderflow ai`.
#[derive(Subcommand)]
pub enum AiCommands {
+ /// Inspect the versioned provider/model compatibility matrix
+ #[command(
+ after_help = "Examples:\n renderflow ai matrix\n renderflow ai matrix --format json"
+ )]
+ Matrix {
+ /// Output format: text (default), json, or yaml
+ #[arg(long, default_value = "text", value_name = "FORMAT")]
+ format: String,
+ /// Optional path to a versioned catalog JSON file
+ #[arg(long, value_name = "FILE")]
+ catalog: Option,
+ },
+
+ /// Resolve a reviewed AI skill to a compatible provider/model
+ #[command(
+ after_help = "Examples:\n renderflow ai resolve --skill skill.metadata.extract\n renderflow ai resolve --skill skill.metadata.extract --allow-unverified --format json"
+ )]
+ Resolve {
+ /// Stable skill ID
+ #[arg(long, value_name = "ID")]
+ skill: String,
+ /// Optional exact skill version
+ #[arg(long, value_name = "VERSION")]
+ skill_version: Option,
+ /// Execution preference
+ #[arg(long, default_value = "local-preferred", value_name = "PREFERENCE")]
+ execution_preference: String,
+ /// Explicitly allow remote candidates when the skill policy also permits them
+ #[arg(long)]
+ allow_remote: bool,
+ /// Permit planning against catalog entries whose live availability is unverified
+ #[arg(long)]
+ allow_unverified: bool,
+ /// Output format: text (default), json, or yaml
+ #[arg(long, default_value = "text", value_name = "FORMAT")]
+ format: String,
+ /// Optional path to a versioned catalog JSON file
+ #[arg(long, value_name = "FILE")]
+ catalog: Option,
+ },
+
+ /// Inspect and validate reviewed, versioned AI skills
+ Skills {
+ #[command(subcommand)]
+ subcommand: AiSkillCommands,
+ },
+
/// List available AI providers and their capabilities
///
/// Prints a table of all known providers (Ollama, OpenAI) with their
@@ -596,6 +645,37 @@ pub enum AiCommands {
},
}
+/// Subcommands for versioned Renderflow AI skills.
+#[derive(Subcommand)]
+pub enum AiSkillCommands {
+ /// List bundled AI skills
+ List {
+ /// Output format: text (default), json, or yaml
+ #[arg(long, default_value = "text", value_name = "FORMAT")]
+ format: String,
+ },
+ /// Inspect one bundled AI skill
+ Inspect {
+ /// Stable skill ID
+ id: String,
+ /// Optional exact skill version
+ #[arg(long, value_name = "VERSION")]
+ version: Option,
+ /// Output format: text (default), json, or yaml
+ #[arg(long, default_value = "text", value_name = "FORMAT")]
+ format: String,
+ },
+ /// Validate all bundled skills or one external skill file
+ Validate {
+ /// Optional path to a skill JSON file
+ #[arg(long, value_name = "FILE")]
+ path: Option,
+ /// Output format: text (default), json, or yaml
+ #[arg(long, default_value = "text", value_name = "FORMAT")]
+ format: String,
+ },
+}
+
/// Subcommands for `renderflow graph`.
#[derive(Subcommand)]
pub enum GraphCommands {
diff --git a/crates/renderflow-core/src/commands/ai.rs b/crates/renderflow-core/src/commands/ai.rs
index 674c367..8a3de00 100644
--- a/crates/renderflow-core/src/commands/ai.rs
+++ b/crates/renderflow-core/src/commands/ai.rs
@@ -1,19 +1,232 @@
//! Handler for `renderflow ai` subcommands.
//!
//! Implements:
+//! * `renderflow ai matrix` – inspect model-granular compatibility
+//! * `renderflow ai resolve` – resolve a versioned skill against policy
+//! * `renderflow ai skills` – inspect and validate reviewed skills
//! * `renderflow ai providers` – list available providers
//! * `renderflow ai models` – list available models per provider
//! * `renderflow ai doctor` – connectivity diagnostics
//! * `renderflow ai cache` – cache statistics
-use anyhow::Result;
+use std::str::FromStr;
+
+use anyhow::{Context, Result};
+use serde::Serialize;
use crate::ai::{
provider::AiProvider,
providers::{OllamaProvider, OpenAiProvider},
+ AiCandidateStatus, AiExecutionPreferenceV1, AiModelCatalog, AiSkillRegistry, AiSkillSpec,
+ AiSkillValidationResult,
};
use crate::cache::{load_ai_cache, AiCache};
+fn load_catalog(path: Option<&str>) -> Result {
+ match path {
+ Some(path) => AiModelCatalog::load(path),
+ None => AiModelCatalog::bundled(),
+ }
+}
+
+fn serialize_output(value: &impl Serialize, format: &str) -> Result {
+ match format {
+ "json" => Ok(format!("{}\n", serde_json::to_string_pretty(value)?)),
+ "yaml" => Ok(serde_yaml_ng::to_string(value)?),
+ _ => anyhow::bail!("unknown output format '{format}'; expected text, json, or yaml"),
+ }
+}
+
+// ── matrix and resolution ────────────────────────────────────────────────────
+
+/// Run `renderflow ai matrix`.
+pub fn run_matrix(format: &str, path: Option<&str>) -> Result<()> {
+ let catalog = load_catalog(path)?;
+ if format != "text" {
+ print!("{}", serialize_output(&catalog, format)?);
+ return Ok(());
+ }
+ println!(
+ "AI model compatibility matrix {} ({})",
+ catalog.schema_version, catalog.revision
+ );
+ println!();
+ println!(
+ " {:<30} {:<20} {:<9} {:<12} Operations",
+ "Provider", "Model", "Locality", "Availability"
+ );
+ println!(
+ " {:-<30} {:-<20} {:-<9} {:-<12} {:-<36}",
+ "", "", "", "", ""
+ );
+ for provider in &catalog.providers {
+ for model in &provider.models {
+ let locality = provider.locality.to_string();
+ let availability = format!("{:?}", model.availability).to_lowercase();
+ let operations = model
+ .operations
+ .iter()
+ .map(ToString::to_string)
+ .collect::>()
+ .join(",");
+ println!(
+ " {:<30} {:<20} {:<9} {:<12} {}",
+ provider.id, model.id, locality, availability, operations
+ );
+ }
+ }
+ println!();
+ println!("Availability is evidence, not an installation claim; use --format json for limits, licenses, and model-specific modalities.");
+ Ok(())
+}
+
+/// Run `renderflow ai resolve`.
+#[allow(clippy::too_many_arguments)]
+pub fn run_resolve(
+ skill_id: &str,
+ skill_version: Option<&str>,
+ preference: &str,
+ allow_remote: bool,
+ allow_unverified: bool,
+ format: &str,
+ catalog_path: Option<&str>,
+) -> Result<()> {
+ let catalog = load_catalog(catalog_path)?;
+ let registry = AiSkillRegistry::bundled()?;
+ let skill = registry
+ .get(skill_id, skill_version)
+ .with_context(|| format!("AI skill '{skill_id}' was not found"))?;
+ let preference = AiExecutionPreferenceV1::from_str(preference)?;
+ let report =
+ catalog.resolve(&skill.resolution_request(preference, allow_remote, allow_unverified));
+ if format != "text" {
+ print!("{}", serialize_output(&report, format)?);
+ return Ok(());
+ }
+ println!("AI resolution for {}@{}", skill.id, skill.version);
+ println!(" preference: {}", preference);
+ match &report.selected {
+ Some(selected) => {
+ println!(
+ " selected: {}:{} ({}, {:?})",
+ selected.provider_id, selected.model_id, selected.locality, selected.availability
+ );
+ println!(" execution ready: {}", selected.execution_ready);
+ }
+ None => println!(" selected: none"),
+ }
+ println!();
+ for candidate in &report.candidates {
+ let marker = match candidate.status {
+ AiCandidateStatus::Selected => "selected",
+ AiCandidateStatus::Compatible => "compatible",
+ AiCandidateStatus::Rejected => "rejected",
+ };
+ println!(
+ " [{}] {}:{}",
+ marker, candidate.provider_id, candidate.model_id
+ );
+ for reason in &candidate.reasons {
+ println!(" - {}: {}", reason.code, reason.message);
+ }
+ }
+ Ok(())
+}
+
+// ── skills ───────────────────────────────────────────────────────────────────
+
+/// Run `renderflow ai skills list`.
+pub fn run_skills_list(format: &str) -> Result<()> {
+ let registry = AiSkillRegistry::bundled()?;
+ if format != "text" {
+ let skills = registry.iter().collect::>();
+ print!("{}", serialize_output(&skills, format)?);
+ return Ok(());
+ }
+ println!("Bundled Renderflow AI skills:");
+ for skill in registry.iter() {
+ println!(" {}@{} — {}", skill.id, skill.version, skill.purpose);
+ }
+ Ok(())
+}
+
+/// Run `renderflow ai skills inspect`.
+pub fn run_skills_inspect(id: &str, version: Option<&str>, format: &str) -> Result<()> {
+ let registry = AiSkillRegistry::bundled()?;
+ let skill = registry
+ .get(id, version)
+ .with_context(|| format!("AI skill '{id}' was not found"))?;
+ if format != "text" {
+ print!("{}", serialize_output(skill, format)?);
+ return Ok(());
+ }
+ println!("{}@{}", skill.id, skill.version);
+ println!(" purpose: {}", skill.purpose);
+ println!(
+ " inputs: {}",
+ skill
+ .input_modalities
+ .iter()
+ .map(ToString::to_string)
+ .collect::>()
+ .join(", ")
+ );
+ println!(
+ " outputs: {}",
+ skill
+ .output_modalities
+ .iter()
+ .map(ToString::to_string)
+ .collect::>()
+ .join(", ")
+ );
+ println!(" hygiene policy: {}", skill.hygiene.policy_id);
+ println!(" initial state: {:?}", skill.approval.initial_state);
+ println!(
+ " human review required: {}",
+ skill.approval.human_review_required
+ );
+ Ok(())
+}
+
+/// Run `renderflow ai skills validate`.
+pub fn run_skills_validate(path: Option<&str>, format: &str) -> Result<()> {
+ let results = if let Some(path) = path {
+ let skill = AiSkillSpec::load(path)?;
+ vec![AiSkillValidationResult {
+ id: skill.id,
+ version: skill.version,
+ valid: true,
+ error: None,
+ }]
+ } else {
+ AiSkillRegistry::bundled()?.validate_all()
+ };
+ if format != "text" {
+ print!("{}", serialize_output(&results, format)?);
+ return Ok(());
+ }
+ for result in &results {
+ if result.valid {
+ println!("✓ {}@{}", result.id, result.version);
+ } else {
+ println!(
+ "✗ {}@{}: {}",
+ result.id,
+ result.version,
+ result
+ .error
+ .as_deref()
+ .unwrap_or("unknown validation error")
+ );
+ }
+ }
+ if results.iter().any(|result| !result.valid) {
+ anyhow::bail!("one or more AI skills are invalid");
+ }
+ Ok(())
+}
+
// ── providers ─────────────────────────────────────────────────────────────────
/// Run `renderflow ai providers`.
diff --git a/docs/ai-guide/model-catalog-and-skills.md b/docs/ai-guide/model-catalog-and-skills.md
new file mode 100644
index 0000000..8aeb679
--- /dev/null
+++ b/docs/ai-guide/model-catalog-and-skills.md
@@ -0,0 +1,150 @@
+# Model catalog and AI skills
+
+Renderflow separates stable artifact intent from replaceable model adapters:
+
+```text
+profile or artifact transform
+ -> versioned AI skill and JSON contracts
+ -> deterministic policy/capability resolver
+ -> model-specific catalog entry
+ -> configured local or explicitly approved remote adapter
+ -> validated candidate artifact and redacted execution evidence
+ -> human review and publication hygiene
+```
+
+The contracts are:
+
+- `renderflow.ai-model-catalog/v1` — providers, runtimes, individual models,
+ modalities, operations, limits, availability, licenses, determinism, and
+ advisory evidence;
+- `renderflow.ai-skill/v1` — reviewed instructions, bounded variables, schemas,
+ budgets, hygiene, validation, provenance, and approval policy;
+- `renderflow.ai-resolution/v1` — deterministic selection plus an explanation
+ for every rejected, unavailable, or lower-ranked candidate;
+- `renderflow.ai-execution/v1` — redacted identities, digests, usage,
+ validators, hygiene outcomes, candidate state, and approval evidence.
+
+The canonical JSON Schemas live in `schemas/`. Built-in catalog and skill assets
+live under `crates/renderflow-core/data/ai/` and are parsed through the same SDK
+types exposed to callers.
+
+## Local-first resolution
+
+`local-preferred` is the default. A local model must still match every required
+operation and input/output modality. `local-only` rejects every remote model and
+never silently falls back. Remote execution requires all of the following:
+
+1. `--allow-remote` or the equivalent SDK request permission;
+2. a skill whose network and remote-execution budgets permit it;
+3. an available configured provider adapter;
+4. explicit privacy approval when detected PII could leave the machine;
+5. approval for every input artifact exposed to the model.
+
+The bundled catalog begins with Ollama and an OpenAI-compatible adapter, but the
+contract can represent llama.cpp servers, vLLM, local OpenAI-compatible servers,
+image graphs, and speech runtimes without changing profile contracts. Renderflow
+does not bundle model weights.
+
+`unverified` means the catalog entry is structurally usable for planning but is
+not execution-ready. Live discovery should replace it with bounded evidence for
+the exact runtime, model revision, digests, source, and licenses. Unknown data
+must remain unknown; never invent a digest, revision, commercial-use grant, or
+provider-terms version.
+
+## Adding a provider or model
+
+To add an adapter, implement `AiProvider`, give the adapter a stable name, and
+add a provider entry with locality, network requirements, runtime identity, and
+a bounded availability probe. Add each model separately. Do not copy a
+provider-wide capability set onto models that do not actually support it.
+
+Each model entry must include:
+
+- stable model and family IDs;
+- typed input/output modalities and operations;
+- structured JSON and JSON Schema behavior;
+- limits, controls, multi-input, streaming, batch, and tool-use behavior;
+- an honest determinism class;
+- availability plus runtime/model/weight identity evidence;
+- model, weight, code, and provider licensing evidence where known;
+- commercial constraints and human-review requirements;
+- timestamped cost, quality, and latency hints when supplied;
+- maturity, conformance, and required provenance fields.
+
+Validate custom catalogs with `renderflow ai matrix --catalog FILE --format
+json`. Catalog parsing rejects unknown fields and duplicate IDs.
+
+## Adding a skill
+
+A skill is an inspectable execution recipe, not an autonomous agent and not an
+opaque provider prompt. Start from one of the bundled synthetic skills and:
+
+1. choose a stable `skill.*` ID and semantic version;
+2. declare artifact families, operations, and typed modalities;
+3. define strict input and output JSON Schemas;
+4. declare every `{{variable}}`, sensitivity, and byte bound;
+5. use provider-neutral generation settings; namespace optional adapter
+ extensions instead of leaking them into profile contracts;
+6. set network, locality, byte, token, time, retry, and cost budgets;
+7. configure secret, PII, protected-reference, and post-output hygiene;
+8. keep generated output in `candidate` state and name its validators/review;
+9. declare cache/evidence identity and redact raw inputs/prompts by default;
+10. add a redistribution-safe synthetic fixture.
+
+The v1 runtime supports a deliberately conservative JSON Schema subset and
+rejects unsupported schema keywords rather than pretending they were enforced.
+Use `renderflow ai skills validate --path FILE` before registering an external
+skill.
+
+## Execution, validation, and evidence
+
+The SDK `AiSkillRuntime` validates input, resolves the exact model, applies
+pre-prompt hygiene, executes through `AiProvider`, parses and sanitizes the
+structured response, validates the output schema, and stores it in the artifact
+store as an intermediate candidate. It never marks generated content as
+publication-approved.
+
+Cache and resume identity commits to the catalog revision, provider, runtime,
+model and weight identities, skill version/content, input, schemas, settings,
+and hygiene policy. Reusing that fingerprint means the configuration is
+compatible; it does not claim byte-level reproducibility for a probabilistic
+model. Execution evidence stores digests instead of raw private prompts or
+source payloads, and includes usage/cost fields only when the adapter can report
+them.
+
+Protected-reference rewrites replace configured imitation labels with reviewed,
+descriptive characteristics before model exposure and again after generation.
+Evidence records the finding class and action without publishing the blocked
+term. Automated hygiene, model metadata, and provider terms are not legal
+clearance.
+
+## Initial proving skills and downstream use
+
+The built-in fixtures require no paid API or bundled weights:
+
+- `skill.metadata.extract`;
+- `skill.visual-dna.describe`;
+- `skill.prompt.from-sanitized-dna`;
+- `skill.accessibility.describe-candidate`.
+
+The visual skills reference the original geometric
+`data/ai/fixtures/synthetic-layout.svg` asset by SHA-256 digest. Validation does
+not download or execute a model.
+
+Visual DNA and accessibility skills may resolve to a compatible local
+multimodal model when one is installed and discovered. Otherwise, resolution
+returns structured unavailability evidence and deterministic/non-AI publication
+paths remain usable.
+
+Artifact DNA (#387) can consume the visual-description and sanitized-prompt
+contracts. The coloring-book profile (#348) can use the same layer for optional
+line-art planning while retaining a deterministic non-AI path. Neither consumer
+should copy provider prompts or treat an AI candidate as authoritative.
+
+## Rollback and reproduction
+
+Keep the source artifact, skill version, catalog revision, schemas, settings,
+hygiene policy, model/runtime identity, and candidate evidence together. Roll
+back by selecting an earlier reviewed candidate or disabling the optional AI
+stage. Re-running the same fingerprint reproduces the configuration and audit
+trail, but probabilistic byte output may differ and must be validated again.
diff --git a/docs/ai-guide/overview.md b/docs/ai-guide/overview.md
index 8efb555..7215fc4 100644
--- a/docs/ai-guide/overview.md
+++ b/docs/ai-guide/overview.md
@@ -1,6 +1,7 @@
# AI Guide Overview
-Renderflow treats AI as just another transform backend.
+Renderflow treats AI as an optional, governed artifact provider. Profiles and
+transforms request capabilities; they do not need to name a hosted vendor.
## Supported scenarios
@@ -20,3 +21,10 @@ Providers implement `AiProvider` and advertise:
- execution method
`OllamaProvider` is local-first; `OpenAiProvider` targets OpenAI-compatible APIs.
+
+Provider-wide capability claims are retained for compatibility, but planning
+uses the model compatibility catalog. Image support on one model therefore does
+not imply image support on every model behind the same endpoint.
+
+See [Model catalog and AI skills](model-catalog-and-skills.md) for the resolver,
+schema contracts, hygiene gates, candidate artifacts, and provenance model.
diff --git a/docs/cli-reference/ai.md b/docs/cli-reference/ai.md
index a3e6744..bd8049f 100644
--- a/docs/cli-reference/ai.md
+++ b/docs/cli-reference/ai.md
@@ -1,9 +1,50 @@
# `renderflow ai`
-Inspect built-in AI providers and cache state.
+Inspect model-specific capabilities, resolve reviewed skills, diagnose providers,
+and inspect cache state. Inspection commands do not execute a model.
## Subcommands
+### `ai matrix`
+
+```bash
+renderflow ai matrix [--catalog FILE] [--format text|json|yaml]
+```
+
+Displays the versioned compatibility catalog at model granularity, including
+typed modalities, operations, structured-output support, availability,
+determinism, licenses, runtime evidence, limits, and advisory cost/quality hints.
+Bundled entries are `unverified`: a declaration never claims that weights are
+installed or that a hosted endpoint is authorized.
+
+### `ai resolve`
+
+```bash
+renderflow ai resolve \
+ --skill skill.metadata.extract \
+ --execution-preference local-preferred \
+ --allow-unverified \
+ --format json
+```
+
+Returns the selected provider/model plus explicit rejection or lower-ranking
+reasons for every other catalog candidate. `--allow-remote` is an explicit
+request-level permission; it cannot override a skill that forbids network or
+remote execution. `local-only` never falls back to a hosted service.
+
+### `ai skills`
+
+```bash
+renderflow ai skills list
+renderflow ai skills inspect skill.visual-dna.describe --format json
+renderflow ai skills validate
+renderflow ai skills validate --path custom-skill.json --format json
+```
+
+Skills are versioned Renderflow recipes with strict input/output schemas,
+reviewed templates, bounded variables, budgets, hygiene, provenance, validators,
+and candidate/approval policy. They are not provider-specific prompt strings.
+
### `ai providers`
Prints provider name, locality, and declared capabilities.
@@ -36,6 +77,9 @@ Reads the cache file and prints entry counts by model. The default path is `.ren
```bash
renderflow ai providers
renderflow ai models
+renderflow ai matrix --format json
+renderflow ai resolve --skill skill.metadata.extract --allow-unverified --format json
+renderflow ai skills validate
renderflow ai doctor --ollama-endpoint http://localhost:11434
renderflow ai cache --path .renderflow-ai-cache.json
```
diff --git a/docs/user-guide/ai.md b/docs/user-guide/ai.md
index a35fff6..6bf2162 100644
--- a/docs/user-guide/ai.md
+++ b/docs/user-guide/ai.md
@@ -9,10 +9,10 @@ Renderflow supports AI-backed transforms through `AiTransform` and the `renderfl
| `ollama` | `http://localhost:11434` | local-first provider, `POST /api/generate` |
| `openai` | `https://api.openai.com` | OpenAI-compatible chat completions, `POST /v1/chat/completions` |
-Built-in model lists shown by `renderflow ai models` include:
-
-- Ollama: `mistral`, `llava`, `llama3`, `gemma`, `phi`
-- OpenAI: `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo`, `gpt-3.5-turbo`
+The legacy model lists shown by `renderflow ai models` are compatibility hints.
+Use `renderflow ai matrix` for the versioned, model-specific capability contract
+and `renderflow ai resolve` for a policy-aware selection. A catalog entry does
+not imply that local weights are installed or a remote endpoint is approved.
## YAML example
@@ -52,8 +52,15 @@ Renderflow substitutes `{input}` into the prompt template before sending the req
- `renderflow ai providers`
- `renderflow ai models`
+- `renderflow ai matrix --format json`
+- `renderflow ai resolve --skill skill.metadata.extract --format json`
+- `renderflow ai skills validate`
- `renderflow ai doctor --ollama-endpoint ...`
- `renderflow ai cache --path .renderflow-ai-cache.json`
+See [Model catalog and AI skills](../ai-guide/model-catalog-and-skills.md) for
+local-only guarantees, remote opt-in, strict schemas, hygiene, provenance, and
+candidate approval.
+
!!! warning
AI transforms are optional and only run when configured. In fail-fast mode a backend outage aborts the build; in watch mode the transform is skipped and the original content continues through the pipeline.
diff --git a/mkdocs.yml b/mkdocs.yml
index f5efb22..a786350 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -148,6 +148,7 @@ nav:
- AI Guide:
- Overview: ai-guide/overview.md
- Configuration: ai-guide/configuration.md
+ - Model catalog and skills: ai-guide/model-catalog-and-skills.md
- Examples:
- Hello World: examples/hello-world.md
- Multi Output: examples/multi-output.md
diff --git a/schemas/renderflow-ai-execution-v1.schema.json b/schemas/renderflow-ai-execution-v1.schema.json
new file mode 100644
index 0000000..e390d3c
--- /dev/null
+++ b/schemas/renderflow-ai-execution-v1.schema.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-ai-execution-v1.schema.json",
+ "title": "Renderflow AI execution evidence v1",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["schema_version", "execution_id", "occurred_at_unix_ms", "identity", "skill_id", "skill_version", "skill_digest", "input_digest", "input_artifacts", "instruction_digest", "prompt_digest", "input_schema_digest", "output_schema_digest", "settings_digest", "hygiene_policy_digest", "cache_identity", "cache_decision", "determinism", "usage", "output_artifact_id", "output_digest", "output_state", "approval_required", "validators", "validation_status", "pre_prompt_hygiene", "post_output_hygiene", "raw_inputs_retained", "raw_prompts_retained"],
+ "properties": {
+ "schema_version": {"const": "renderflow.ai-execution/v1"}, "execution_id": {"type": "string"}, "occurred_at_unix_ms": {"type": "integer"},
+ "identity": {"type": "object"}, "skill_id": {"type": "string"}, "skill_version": {"type": "string"},
+ "skill_digest": {"$ref": "#/$defs/digest"}, "input_digest": {"$ref": "#/$defs/digest"}, "input_artifacts": {"type": "array", "items": {"type": "object"}},
+ "instruction_digest": {"$ref": "#/$defs/digest"}, "prompt_digest": {"$ref": "#/$defs/digest"}, "input_schema_digest": {"$ref": "#/$defs/digest"}, "output_schema_digest": {"$ref": "#/$defs/digest"}, "settings_digest": {"$ref": "#/$defs/digest"}, "hygiene_policy_digest": {"$ref": "#/$defs/digest"}, "cache_identity": {"$ref": "#/$defs/digest"},
+ "cache_decision": {"type": "string"}, "determinism": {"type": "string"}, "usage": {"type": "object"}, "output_artifact_id": {"type": "string"}, "output_digest": {"$ref": "#/$defs/digest"},
+ "output_state": {"enum": ["candidate", "approved"]}, "approval_required": {"type": "boolean"}, "approval_reference": {"type": "string"}, "validators": {"type": "array", "items": {"type": "string"}}, "validation_status": {"type": "string"},
+ "pre_prompt_hygiene": {"type": "object"}, "post_output_hygiene": {"type": "object"}, "raw_inputs_retained": {"const": false}, "raw_prompts_retained": {"const": false}
+ },
+ "$defs": {"digest": {"type": "object", "additionalProperties": false, "required": ["algorithm", "value"], "properties": {"algorithm": {"const": "sha256"}, "value": {"type": "string", "pattern": "^[a-f0-9]{64}$"}}}}
+}
diff --git a/schemas/renderflow-ai-model-catalog-v1.schema.json b/schemas/renderflow-ai-model-catalog-v1.schema.json
new file mode 100644
index 0000000..111f094
--- /dev/null
+++ b/schemas/renderflow-ai-model-catalog-v1.schema.json
@@ -0,0 +1,72 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-ai-model-catalog-v1.schema.json",
+ "title": "Renderflow AI provider/model compatibility catalog v1",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["schema_version", "revision", "providers"],
+ "properties": {
+ "schema_version": {"const": "renderflow.ai-model-catalog/v1"},
+ "revision": {"type": "string", "minLength": 1},
+ "providers": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/provider"}}
+ },
+ "$defs": {
+ "locality": {"enum": ["local", "remote", "hybrid"]},
+ "availability": {"enum": ["available", "unavailable", "unverified"]},
+ "modality": {"enum": ["text", "image", "audio", "video", "document", "structured_json", "artifact_dna", "embeddings", "mask", "metadata"]},
+ "operation": {"enum": ["generation", "editing", "extraction", "classification", "transcription", "embeddings", "multimodal_reasoning", "schema_constrained_output"]},
+ "probe": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["kind", "bounded_timeout_ms"],
+ "properties": {"kind": {"type": "string", "minLength": 1}, "bounded_timeout_ms": {"type": "integer", "minimum": 1}, "endpoint": {"type": "string"}}
+ },
+ "runtime": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "availability_probe"],
+ "properties": {
+ "id": {"type": "string", "minLength": 1}, "revision": {"type": "string"}, "digest": {"type": "string"}, "source": {"type": "string"}, "license": {"type": "string"},
+ "hardware_requirements": {"type": "array", "items": {"type": "string"}}, "availability_probe": {"$ref": "#/$defs/probe"}
+ }
+ },
+ "limits": {
+ "type": "object", "additionalProperties": false,
+ "properties": {"context_tokens": {"type": "integer", "minimum": 1}, "output_tokens": {"type": "integer", "minimum": 1}, "max_input_bytes": {"type": "integer", "minimum": 1}, "max_images": {"type": "integer", "minimum": 1}, "max_audio_seconds": {"type": "integer", "minimum": 1}, "max_video_seconds": {"type": "integer", "minimum": 1}}
+ },
+ "features": {
+ "type": "object", "additionalProperties": false,
+ "required": ["native_json", "json_schema", "seed", "sampler_controls", "streaming", "batch", "tool_use", "multi_input"],
+ "properties": {"native_json": {"type": "boolean"}, "json_schema": {"type": "boolean"}, "seed": {"type": "boolean"}, "sampler_controls": {"type": "boolean"}, "streaming": {"type": "boolean"}, "batch": {"type": "boolean"}, "tool_use": {"type": "boolean"}, "multi_input": {"type": "boolean"}}
+ },
+ "license": {
+ "type": "object", "additionalProperties": false,
+ "required": ["model", "commercial_use", "human_review_required"],
+ "properties": {"model": {"type": "string"}, "weights": {"type": "string"}, "code": {"type": "string"}, "provider_terms": {"type": "string"}, "commercial_use": {"type": "string"}, "human_review_required": {"type": "boolean"}}
+ },
+ "advisory": {
+ "type": "object", "additionalProperties": false,
+ "properties": {"cost_tier": {"type": "integer", "minimum": 0, "maximum": 255}, "quality_tier": {"type": "integer", "minimum": 0, "maximum": 255}, "latency_tier": {"type": "integer", "minimum": 0, "maximum": 255}, "observed_at": {"type": "string"}}
+ },
+ "model": {
+ "type": "object", "additionalProperties": false,
+ "required": ["id", "family", "input_modalities", "output_modalities", "operations", "determinism", "license", "maturity", "conformance", "availability", "required_provenance_fields"],
+ "properties": {
+ "id": {"type": "string", "minLength": 1}, "family": {"type": "string", "minLength": 1}, "revision": {"type": "string"}, "quantization": {"type": "string"}, "weights_digest": {"type": "string"}, "configuration_digest": {"type": "string"},
+ "input_modalities": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/modality"}},
+ "output_modalities": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/modality"}},
+ "operations": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/operation"}},
+ "limits": {"$ref": "#/$defs/limits"}, "features": {"$ref": "#/$defs/features"},
+ "determinism": {"enum": ["byte_deterministic", "configuration_repeatable", "probabilistic", "unknown"]}, "license": {"$ref": "#/$defs/license"},
+ "commercial_constraints": {"type": "array", "items": {"type": "string"}}, "advisory": {"$ref": "#/$defs/advisory"},
+ "maturity": {"type": "string"}, "conformance": {"type": "string"}, "availability": {"$ref": "#/$defs/availability"}, "availability_reason": {"type": "string"},
+ "required_provenance_fields": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string"}}
+ }
+ },
+ "provider": {
+ "type": "object", "additionalProperties": false,
+ "required": ["id", "adapter", "display_name", "locality", "requires_network_permission", "runtime", "models"],
+ "properties": {"id": {"type": "string", "minLength": 1}, "adapter": {"type": "string", "minLength": 1}, "display_name": {"type": "string", "minLength": 1}, "locality": {"$ref": "#/$defs/locality"}, "requires_network_permission": {"type": "boolean"}, "runtime": {"$ref": "#/$defs/runtime"}, "models": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/model"}}}
+ }
+ }
+}
diff --git a/schemas/renderflow-ai-skill-v1.schema.json b/schemas/renderflow-ai-skill-v1.schema.json
new file mode 100644
index 0000000..7a4e566
--- /dev/null
+++ b/schemas/renderflow-ai-skill-v1.schema.json
@@ -0,0 +1,27 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-ai-skill-v1.schema.json",
+ "title": "Renderflow reviewed AI skill specification v1",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["schema_version", "id", "version", "purpose", "artifact_families", "operations", "input_modalities", "output_modalities", "requires_json_schema", "input_schema", "output_schema", "templates", "variables", "max_rendered_prompt_bytes", "generation", "budgets", "hygiene", "approval", "provenance", "redistribution_notes", "license_notes", "fixture"],
+ "properties": {
+ "schema_version": {"const": "renderflow.ai-skill/v1"},
+ "id": {"type": "string", "pattern": "^skill\\.[A-Za-z0-9._-]+$"},
+ "version": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1},
+ "artifact_families": {"type": "array", "minItems": 1, "items": {"type": "string"}},
+ "operations": {"type": "array", "minItems": 1, "items": {"enum": ["generation", "editing", "extraction", "classification", "transcription", "embeddings", "multimodal_reasoning", "schema_constrained_output"]}},
+ "input_modalities": {"type": "array", "minItems": 1, "items": {"enum": ["text", "image", "audio", "video", "document", "structured_json", "artifact_dna", "embeddings", "mask", "metadata"]}},
+ "output_modalities": {"type": "array", "minItems": 1, "items": {"enum": ["text", "image", "audio", "video", "document", "structured_json", "artifact_dna", "embeddings", "mask", "metadata"]}},
+ "requires_json_schema": {"type": "boolean"}, "input_schema": {"type": "object"}, "output_schema": {"type": "object"},
+ "templates": {"type": "object", "additionalProperties": false, "required": ["system", "instruction", "prompt"], "properties": {"system": {"type": "string"}, "instruction": {"type": "string"}, "prompt": {"type": "string"}}},
+ "variables": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["name", "required", "max_bytes"], "properties": {"name": {"type": "string", "pattern": "^[A-Za-z0-9_]+$"}, "required": {"type": "boolean"}, "sensitive": {"type": "boolean"}, "max_bytes": {"type": "integer", "minimum": 1}}}},
+ "max_rendered_prompt_bytes": {"type": "integer", "minimum": 1},
+ "generation": {"type": "object", "additionalProperties": false, "properties": {"temperature": {"type": "number", "minimum": 0}, "max_tokens": {"type": "integer", "minimum": 1}, "seed": {"type": "integer", "minimum": 0}, "top_p": {"type": "number", "minimum": 0, "maximum": 1}, "stop": {"type": "array", "items": {"type": "string"}}, "provider_extensions": {"type": "object"}}},
+ "budgets": {"type": "object", "additionalProperties": false, "required": ["network", "remote_execution", "max_input_bytes", "max_output_bytes", "max_tokens", "max_duration_ms", "max_retries"], "properties": {"network": {"type": "boolean"}, "remote_execution": {"type": "boolean"}, "max_input_bytes": {"type": "integer", "minimum": 1}, "max_output_bytes": {"type": "integer", "minimum": 1}, "max_tokens": {"type": "integer", "minimum": 1}, "max_duration_ms": {"type": "integer", "minimum": 1}, "max_retries": {"type": "integer", "minimum": 0}, "max_cost_microunits": {"type": "integer", "minimum": 0}}},
+ "hygiene": {"type": "object", "additionalProperties": false, "required": ["policy_id", "scan_secrets", "pii_action", "protected_reference_action", "allow_private_remote_input", "retain_raw_prompts", "post_output_review"], "properties": {"policy_id": {"type": "string"}, "scan_secrets": {"type": "boolean"}, "pii_action": {"enum": ["block", "rewrite", "review"]}, "protected_reference_action": {"enum": ["block", "rewrite", "review"]}, "protected_references": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["term"], "properties": {"term": {"type": "string", "minLength": 1}, "descriptive_replacement": {"type": "string"}}}}, "allow_private_remote_input": {"type": "boolean"}, "retain_raw_prompts": {"const": false}, "post_output_review": {"type": "boolean"}}},
+ "approval": {"type": "object", "additionalProperties": false, "required": ["initial_state", "human_review_required", "validators"], "properties": {"initial_state": {"enum": ["candidate", "approved"]}, "human_review_required": {"type": "boolean"}, "validators": {"type": "array", "minItems": 1, "items": {"type": "string"}}}},
+ "provenance": {"type": "object", "additionalProperties": false, "required": ["cache_identity_fields", "evidence_fields", "redact_raw_inputs", "redact_raw_prompts"], "properties": {"cache_identity_fields": {"type": "array", "minItems": 1, "items": {"type": "string"}}, "evidence_fields": {"type": "array", "minItems": 1, "items": {"type": "string"}}, "redact_raw_inputs": {"type": "boolean"}, "redact_raw_prompts": {"const": true}}},
+ "redistribution_notes": {"type": "string"}, "license_notes": {"type": "string"}, "fixture": {}
+ }
+}