Skip to main content

runx_runtime/registry/local/
build.rs

1// rust-style-allow: large-file because local registry ingestion keeps skill
2// parsing, binding metadata, and registry-version projection together for the
3// current TS-sunset parity slice.
4use runx_contracts::maturity::MaturityTier;
5use runx_contracts::{JsonObject, JsonValue, sha256_hex};
6use runx_parser::{
7    SkillRunnerManifest, ValidatedSkill, parse_runner_manifest_yaml, parse_skill_markdown,
8    validate_runner_manifest, validate_skill,
9};
10use serde::Deserialize;
11
12use super::super::package_files::{normalize_registry_package_files, registry_package_digest};
13use super::super::scopes::required_scopes_from_skill_and_runner;
14use super::super::types::{
15    RegistryAttestation, RegistryPackageFile, RegistryPublisher, RegistrySkillVersion,
16    RegistrySourceMetadata, TrustTier,
17};
18use super::{IngestSkillOptions, LocalRegistryError, build_skill_id};
19use crate::registry::local::trust::{
20    build_publisher_attestations, build_source_attestations, merge_registry_attestations,
21    normalize_attestations,
22};
23use crate::registry::local::util::{
24    missing_field, now_iso8601, required_string, validate_publisher, validate_source_metadata,
25};
26
27pub fn build_registry_skill_version(
28    markdown: &str,
29    options: &IngestSkillOptions,
30) -> Result<RegistrySkillVersion, LocalRegistryError> {
31    let raw = parse_skill_markdown(markdown)?;
32    let skill = validate_skill(raw)?;
33    let digest = sha256_hex(markdown.as_bytes());
34    let binding = build_binding_artifact(&skill, options.profile_document.as_deref())?;
35    let package_files = normalize_package_files(options.package_files.clone())?;
36    let package_digest = registry_package_digest(&package_files);
37    let catalog = registry_catalog(binding.manifest.as_ref());
38    let defaults = registry_version_defaults(
39        &digest,
40        binding.digest.as_deref(),
41        package_digest.as_deref(),
42        options,
43    );
44    let manifest = binding.manifest.as_ref();
45    let skill_id = build_skill_id(&defaults.owner, &skill.name)?;
46    Ok(RegistrySkillVersion {
47        skill_id,
48        owner: defaults.owner,
49        name: skill.name.clone(),
50        description: skill.description.clone(),
51        category: skill.runx_category.clone(),
52        source_category: skill.category.clone(),
53        version: defaults.version,
54        digest,
55        signed_manifest: None,
56        markdown: markdown.to_owned(),
57        profile_document: options.profile_document.clone(),
58        profile_digest: binding.digest,
59        package_files,
60        package_digest,
61        runner_names: binding.runner_names,
62        source_type: skill.source.source_type.as_str().to_owned(),
63        trust_tier: defaults.trust_tier,
64        // Alpha is the floor at creation; maturity is recomputed from harness
65        // signals at the publish and harness-seal events, never on read.
66        maturity: MaturityTier::Alpha,
67        catalog_kind: Some(catalog.kind.as_str().to_owned()),
68        catalog_audience: Some(catalog.audience.as_str().to_owned()),
69        catalog_visibility: Some(catalog.visibility.as_str().to_owned()),
70        source_metadata: defaults.source_metadata,
71        attestations: defaults.attestations,
72        required_scopes: registry_required_scopes(&skill, manifest)?,
73        runtime: registry_runtime(&skill, manifest),
74        auth: skill.auth.clone(),
75        risk: registry_risk(&skill),
76        runx: skill.runx.clone(),
77        tags: registry_tags(&skill, manifest),
78        publisher: defaults.publisher,
79        created_at: defaults.created_at,
80        updated_at: now_iso8601(),
81    })
82}
83
84struct RegistryVersionDefaults {
85    owner: String,
86    created_at: String,
87    publisher: RegistryPublisher,
88    trust_tier: TrustTier,
89    version: String,
90    source_metadata: Option<RegistrySourceMetadata>,
91    attestations: Vec<RegistryAttestation>,
92}
93
94fn registry_version_defaults(
95    digest: &str,
96    profile_digest: Option<&str>,
97    package_digest: Option<&str>,
98    options: &IngestSkillOptions,
99) -> RegistryVersionDefaults {
100    let owner = options.owner.clone().unwrap_or_else(|| "local".to_owned());
101    let created_at = options.created_at.clone().unwrap_or_else(now_iso8601);
102    let publisher = options
103        .publisher
104        .clone()
105        .unwrap_or_else(|| default_registry_publisher(&owner));
106    let trust_tier = options
107        .trust_tier
108        .clone()
109        .unwrap_or_else(|| derive_registry_trust_tier(&owner, None));
110    let version = options.version.clone().unwrap_or_else(|| {
111        let seed = default_registry_version_seed(digest, profile_digest, package_digest);
112        format!("sha-{}", seed.chars().take(12).collect::<String>())
113    });
114    let source_metadata = options.source_metadata.clone();
115    let attestations = merge_registry_attestations(vec![
116        build_publisher_attestations(&publisher, &trust_tier, &created_at),
117        build_source_attestations(source_metadata.as_ref(), &created_at),
118        options.attestations.clone(),
119    ]);
120    RegistryVersionDefaults {
121        owner,
122        created_at,
123        publisher,
124        trust_tier,
125        version,
126        source_metadata,
127        attestations,
128    }
129}
130
131pub(super) fn registry_catalog(
132    manifest: Option<&SkillRunnerManifest>,
133) -> runx_parser::CatalogMetadata {
134    manifest
135        .and_then(|manifest| manifest.catalog.clone())
136        .unwrap_or(runx_parser::CatalogMetadata {
137            kind: runx_parser::CatalogKind::Skill,
138            audience: runx_parser::CatalogAudience::Public,
139            visibility: runx_parser::CatalogVisibility::Public,
140            role: runx_parser::CatalogRole::Context,
141            canonical_skill: None,
142            provider: None,
143            runtime_path: None,
144            part_of: Vec::new(),
145        })
146}
147
148pub(super) fn registry_required_scopes(
149    skill: &ValidatedSkill,
150    manifest: Option<&SkillRunnerManifest>,
151) -> Result<Vec<String>, LocalRegistryError> {
152    required_scopes_from_skill_and_runner(skill, manifest).map_err(|error| {
153        LocalRegistryError::InvalidSkillManifest {
154            field: error.field,
155            message: error.message,
156        }
157    })
158}
159
160pub(super) fn registry_runtime(
161    skill: &ValidatedSkill,
162    manifest: Option<&SkillRunnerManifest>,
163) -> Option<JsonValue> {
164    skill
165        .runtime
166        .clone()
167        .or_else(|| record_field(skill.runx.as_ref(), "runtime"))
168        .or_else(|| extract_runner_runtime(manifest))
169}
170
171pub(super) fn registry_risk(skill: &ValidatedSkill) -> Option<JsonValue> {
172    skill
173        .risk
174        .clone()
175        .or_else(|| record_field(skill.runx.as_ref(), "risk"))
176}
177
178pub(super) fn registry_tags(
179    skill: &ValidatedSkill,
180    manifest: Option<&SkillRunnerManifest>,
181) -> Vec<String> {
182    unique(
183        extract_tags(skill)
184            .into_iter()
185            .chain(skill.runx_category.clone())
186            .chain(extract_runner_tags(manifest))
187            .collect(),
188    )
189}
190
191// rust-style-allow: long-function - normalization validates the package digest,
192// manifest, and registry row in one pass over the submitted version payload.
193pub fn normalize_registry_skill_version(
194    payload: RegistrySkillVersionPayload,
195) -> Result<RegistrySkillVersion, LocalRegistryError> {
196    let governance = normalize_registry_version_governance(&payload)?;
197    let package_files = normalize_package_files(payload.package_files.unwrap_or_default())?;
198    let computed_package_digest = registry_package_digest(&package_files);
199    if let (Some(declared), Some(computed)) = (&payload.package_digest, &computed_package_digest) {
200        if declared != computed {
201            return Err(LocalRegistryError::InvalidVersionPayload {
202                field: "registry_version.package_digest".to_owned(),
203                message: format!(
204                    "declared digest {declared} does not match package files {computed}"
205                ),
206            });
207        }
208    }
209    if payload.package_digest.is_some() && computed_package_digest.is_none() {
210        return Err(LocalRegistryError::InvalidVersionPayload {
211            field: "registry_version.package_digest".to_owned(),
212            message: "declared without package_files".to_owned(),
213        });
214    }
215    let markdown = required_string(payload.markdown, "registry_version.markdown")?;
216    let derived_categories = derive_categories_from_markdown(&markdown);
217    let category = payload.category.or(derived_categories.runx_category);
218    let source_category = payload.source_category.or(derived_categories.category);
219
220    Ok(RegistrySkillVersion {
221        skill_id: required_string(payload.skill_id, "registry_version.skill_id")?,
222        owner: governance.owner,
223        name: required_string(payload.name, "registry_version.name")?,
224        description: payload.description,
225        category,
226        source_category,
227        version: required_string(payload.version, "registry_version.version")?,
228        digest: required_string(payload.digest, "registry_version.digest")?,
229        signed_manifest: payload.signed_manifest,
230        markdown,
231        profile_document: payload.profile_document,
232        profile_digest: payload.profile_digest,
233        package_files,
234        package_digest: payload.package_digest.or(computed_package_digest),
235        runner_names: payload.runner_names.unwrap_or_default(),
236        source_type: required_string(payload.source_type, "registry_version.source_type")?,
237        trust_tier: governance.trust_tier,
238        // Preserved through re-ingest; defaults to the Alpha floor when absent.
239        maturity: payload.maturity.unwrap_or_default(),
240        catalog_kind: Some(governance.catalog.kind.as_str().to_owned()),
241        catalog_audience: Some(governance.catalog.audience.as_str().to_owned()),
242        catalog_visibility: Some(governance.catalog.visibility.as_str().to_owned()),
243        source_metadata: governance.source_metadata,
244        attestations: governance.attestations,
245        required_scopes: payload.required_scopes.unwrap_or_default(),
246        runtime: payload.runtime,
247        auth: payload.auth,
248        risk: payload.risk,
249        runx: payload.runx,
250        tags: payload.tags.unwrap_or_default(),
251        publisher: governance.publisher,
252        updated_at: governance.updated_at,
253        created_at: governance.created_at,
254    })
255}
256
257struct DerivedCategories {
258    category: Option<String>,
259    runx_category: Option<String>,
260}
261
262fn derive_categories_from_markdown(markdown: &str) -> DerivedCategories {
263    let Some(skill) = parse_skill_markdown(markdown)
264        .ok()
265        .and_then(|raw| validate_skill(raw).ok())
266    else {
267        return DerivedCategories {
268            category: None,
269            runx_category: None,
270        };
271    };
272    DerivedCategories {
273        category: skill.category,
274        runx_category: skill.runx_category,
275    }
276}
277
278struct NormalizedRegistryVersionGovernance {
279    owner: String,
280    created_at: String,
281    publisher: RegistryPublisher,
282    trust_tier: TrustTier,
283    source_metadata: Option<RegistrySourceMetadata>,
284    attestations: Vec<RegistryAttestation>,
285    catalog: runx_parser::CatalogMetadata,
286    updated_at: String,
287}
288
289fn normalize_registry_version_governance(
290    payload: &RegistrySkillVersionPayload,
291) -> Result<NormalizedRegistryVersionGovernance, LocalRegistryError> {
292    let owner = required_string(payload.owner.clone(), "registry_version.owner")?;
293    let created_at = required_string(payload.created_at.clone(), "registry_version.created_at")?;
294    let publisher = validate_publisher(
295        payload
296            .publisher
297            .clone()
298            .ok_or_else(|| missing_field("registry_version.publisher"))?,
299        "registry_version.publisher",
300    )?;
301    let trust_tier = payload.trust_tier.clone().unwrap_or(TrustTier::Community);
302    let source_metadata = normalize_source_metadata(payload.source_metadata.clone())?;
303    let attestations = normalize_attestations(
304        payload.attestations.clone().unwrap_or_default(),
305        source_metadata.as_ref(),
306        &publisher,
307        &trust_tier,
308        &created_at,
309    );
310    let catalog = normalize_registry_catalog(
311        payload.catalog_kind.as_deref(),
312        payload.catalog_audience.as_deref(),
313        payload.catalog_visibility.as_deref(),
314    );
315    let updated_at = payload
316        .updated_at
317        .as_ref()
318        .filter(|value| !value.is_empty())
319        .cloned()
320        .unwrap_or_else(|| created_at.clone());
321    Ok(NormalizedRegistryVersionGovernance {
322        owner,
323        created_at,
324        publisher,
325        trust_tier,
326        source_metadata,
327        attestations,
328        catalog,
329        updated_at,
330    })
331}
332
333pub(super) fn normalize_source_metadata(
334    source_metadata: Option<RegistrySourceMetadata>,
335) -> Result<Option<RegistrySourceMetadata>, LocalRegistryError> {
336    source_metadata.map(validate_source_metadata).transpose()
337}
338
339pub(super) fn normalize_registry_catalog(
340    kind: Option<&str>,
341    audience: Option<&str>,
342    visibility: Option<&str>,
343) -> runx_parser::CatalogMetadata {
344    runx_parser::CatalogMetadata {
345        kind: match kind {
346            Some("graph") => runx_parser::CatalogKind::Graph,
347            _ => runx_parser::CatalogKind::Skill,
348        },
349        audience: match audience {
350            Some("builder") => runx_parser::CatalogAudience::Builder,
351            Some("operator") => runx_parser::CatalogAudience::Operator,
352            Some("system") => runx_parser::CatalogAudience::System,
353            _ => runx_parser::CatalogAudience::Public,
354        },
355        visibility: match visibility {
356            Some("internal") => runx_parser::CatalogVisibility::Internal,
357            _ => runx_parser::CatalogVisibility::Public,
358        },
359        role: runx_parser::CatalogRole::Context,
360        canonical_skill: None,
361        provider: None,
362        runtime_path: None,
363        part_of: Vec::new(),
364    }
365}
366
367#[derive(Clone, Debug, Deserialize)]
368pub struct RegistrySkillVersionPayload {
369    skill_id: Option<String>,
370    owner: Option<String>,
371    name: Option<String>,
372    description: Option<String>,
373    category: Option<String>,
374    source_category: Option<String>,
375    version: Option<String>,
376    digest: Option<String>,
377    signed_manifest: Option<super::super::types::RegistrySignedManifest>,
378    markdown: Option<String>,
379    profile_document: Option<String>,
380    profile_digest: Option<String>,
381    package_files: Option<Vec<RegistryPackageFile>>,
382    package_digest: Option<String>,
383    runner_names: Option<Vec<String>>,
384    source_type: Option<String>,
385    trust_tier: Option<TrustTier>,
386    maturity: Option<MaturityTier>,
387    catalog_kind: Option<String>,
388    catalog_audience: Option<String>,
389    catalog_visibility: Option<String>,
390    source_metadata: Option<RegistrySourceMetadata>,
391    attestations: Option<Vec<RegistryAttestation>>,
392    required_scopes: Option<Vec<String>>,
393    runtime: Option<JsonValue>,
394    auth: Option<JsonValue>,
395    risk: Option<JsonValue>,
396    runx: Option<JsonObject>,
397    tags: Option<Vec<String>>,
398    publisher: Option<RegistryPublisher>,
399    created_at: Option<String>,
400    updated_at: Option<String>,
401}
402
403struct BindingArtifact {
404    digest: Option<String>,
405    runner_names: Vec<String>,
406    manifest: Option<SkillRunnerManifest>,
407}
408
409fn build_binding_artifact(
410    skill: &ValidatedSkill,
411    profile_document: Option<&str>,
412) -> Result<BindingArtifact, LocalRegistryError> {
413    let Some(profile_document) = profile_document else {
414        return Ok(BindingArtifact {
415            digest: None,
416            runner_names: Vec::new(),
417            manifest: None,
418        });
419    };
420    let manifest = validate_runner_manifest(parse_runner_manifest_yaml(profile_document)?)?;
421    if let Some(manifest_skill) = &manifest.skill {
422        if manifest_skill != &skill.name {
423            return Err(LocalRegistryError::InvalidVersionPayload {
424                field: "profile_document.skill".to_owned(),
425                message: format!(
426                    "runner manifest skill '{manifest_skill}' does not match skill '{}'",
427                    skill.name
428                ),
429            });
430        }
431    }
432    Ok(BindingArtifact {
433        digest: Some(sha256_hex(profile_document.as_bytes())),
434        runner_names: manifest.runners.keys().cloned().collect(),
435        manifest: Some(manifest),
436    })
437}
438
439pub(super) fn default_registry_version_seed(
440    markdown_digest: &str,
441    profile_digest: Option<&str>,
442    package_digest: Option<&str>,
443) -> String {
444    match (profile_digest, package_digest) {
445        (None, None) => markdown_digest.to_owned(),
446        _ => sha256_hex(
447            format!(
448                "{{\"markdown_digest\":\"{markdown_digest}\",\"package_digest\":\"{}\",\"profile_digest\":\"{}\"}}",
449                package_digest.unwrap_or(""),
450                profile_digest.unwrap_or("")
451            )
452            .as_bytes(),
453        ),
454    }
455}
456
457fn normalize_package_files(
458    files: Vec<RegistryPackageFile>,
459) -> Result<Vec<RegistryPackageFile>, LocalRegistryError> {
460    normalize_registry_package_files(files).map_err(|message| {
461        LocalRegistryError::InvalidVersionPayload {
462            field: "registry_version.package_files".to_owned(),
463            message,
464        }
465    })
466}
467
468pub(super) fn default_registry_publisher(owner: &str) -> RegistryPublisher {
469    RegistryPublisher {
470        kind: if owner == "runx" {
471            "organization".to_owned()
472        } else {
473            "publisher".to_owned()
474        },
475        id: owner.to_owned(),
476        handle: Some(owner.to_owned()),
477        display_name: None,
478    }
479}
480
481pub(super) fn derive_registry_trust_tier(
482    _owner: &str,
483    trust_tier: Option<&TrustTier>,
484) -> TrustTier {
485    trust_tier.cloned().unwrap_or(TrustTier::Community)
486}
487
488pub(super) fn extract_runner_runtime(manifest: Option<&SkillRunnerManifest>) -> Option<JsonValue> {
489    let manifest = manifest?;
490    let runners = manifest
491        .runners
492        .values()
493        .filter(|runner| runner.runtime.is_some())
494        .map(|runner| JsonValue::String(runner.name.clone()))
495        .collect::<Vec<_>>();
496    if runners.is_empty() {
497        None
498    } else {
499        Some(JsonValue::Object(
500            [("runners".to_owned(), JsonValue::Array(runners))].into(),
501        ))
502    }
503}
504
505pub(super) fn extract_runner_tags(manifest: Option<&SkillRunnerManifest>) -> Vec<String> {
506    let Some(manifest) = manifest else {
507        return Vec::new();
508    };
509    unique(
510        manifest
511            .runners
512            .values()
513            .flat_map(|runner| record_array_field_from_object(runner.runx.as_ref(), "tags"))
514            .collect(),
515    )
516}
517
518pub(super) fn extract_tags(skill: &ValidatedSkill) -> Vec<String> {
519    unique(record_array_field_from_object(skill.runx.as_ref(), "tags"))
520}
521
522pub(super) fn record_array_field_from_object(
523    value: Option<&JsonObject>,
524    field: &str,
525) -> Vec<String> {
526    let Some(record) = value else {
527        return Vec::new();
528    };
529    let Some(JsonValue::Array(values)) = record.get(field) else {
530        return Vec::new();
531    };
532    values
533        .iter()
534        .filter_map(|value| match value {
535            JsonValue::String(value) if !value.is_empty() => Some(value.clone()),
536            _ => None,
537        })
538        .collect()
539}
540
541pub(super) fn record_field(value: Option<&JsonObject>, field: &str) -> Option<JsonValue> {
542    value.and_then(|record| record.get(field).cloned())
543}
544
545pub(super) fn unique(values: Vec<String>) -> Vec<String> {
546    let mut unique_values = Vec::new();
547    for value in values {
548        if !unique_values.contains(&value) {
549            unique_values.push(value);
550        }
551    }
552    unique_values
553}