Skip to main content

caboodle/
skill_profile.rs

1//! Pure pinned skill selection. This does not query, import, or install anything.
2//!
3//! A future graph adapter must establish the profile's governed type, explicit
4//! dataset and transaction before constructing these inputs. A complete response
5//! is query coverage, not proof that a whole graph has been inventoried. Pendant
6//! verification and render ownership remain the native tools' responsibility.
7
8pub mod query;
9
10use anyhow::{bail, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13
14/// The two supported materialization consumers, never arbitrary directory names.
15#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
16#[serde(rename_all = "lowercase")]
17pub enum Harness {
18    Claude,
19    Codex,
20}
21
22/// One exact native pendant identity selected by a profile.
23#[derive(Clone, Debug, Deserialize, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct Pin {
26    pub skill: String,
27    pub share_id: String,
28    pub version: String,
29}
30
31/// A profile selects immutable releases; it never asks for "latest".
32#[derive(Clone, Debug, Deserialize, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct SkillProfile {
35    pub id: String,
36    pub public_only: bool,
37    pub harnesses: Vec<Harness>,
38    pub pins: Vec<Pin>,
39}
40
41/// Metadata for a native pendant. File contents do not belong in this model.
42#[derive(Clone, Debug, Deserialize, Serialize)]
43#[serde(deny_unknown_fields)]
44pub struct Release {
45    pub pin: Pin,
46    pub name: String,
47    pub render_spec: String,
48    pub public: bool,
49    pub harnesses: Vec<Harness>,
50}
51
52/// A bounded, transaction-pinned result supplied by the graph adapter.
53#[derive(Clone, Debug, Deserialize, Serialize)]
54#[serde(deny_unknown_fields)]
55pub struct CatalogueSnapshot {
56    pub transaction: u64,
57    pub complete: bool,
58    pub releases: Vec<Release>,
59}
60
61/// A deterministic selection, not evidence of installation or trust admission.
62#[derive(Clone, Debug, Serialize)]
63pub struct Selection {
64    pub profile: String,
65    pub transaction: u64,
66    pub harnesses: Vec<Harness>,
67    pub releases: Vec<Release>,
68}
69
70fn text(value: &str) -> bool {
71    !value.is_empty()
72        && value.len() <= 2_048
73        && !value.chars().any(|c| c.is_control() || c.is_whitespace())
74}
75
76fn valid_pin(pin: &Pin) -> bool {
77    text(&pin.skill)
78        && text(&pin.version)
79        && pin.share_id.strip_prefix("sha256:").is_some_and(|digest| {
80            digest.len() == 64
81                && digest
82                    .bytes()
83                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
84        })
85}
86
87fn harnesses(input: &[Harness]) -> Result<BTreeSet<Harness>> {
88    let set: BTreeSet<_> = input.iter().copied().collect();
89    if set.is_empty() || set.len() != input.len() {
90        bail!("empty or duplicate harness selection");
91    }
92    Ok(set)
93}
94
95/// Resolve exact pins, refusing partial or ambiguous metadata before any I/O.
96///
97/// Empty profiles are deliberately unsupported until explicit retirement is
98/// implemented. This prevents an absent catalogue from becoming a delete-all
99/// instruction. Identifiers remain opaque here: the graph adapter must parse
100/// them as RDF terms, and no identifier is interpreted as a filesystem path.
101pub fn select(profile: &SkillProfile, snapshot: &CatalogueSnapshot) -> Result<Selection> {
102    if !text(&profile.id) || !snapshot.complete || snapshot.transaction == 0 {
103        bail!("profile or transaction-pinned catalogue is unavailable");
104    }
105    if profile.pins.is_empty() || profile.pins.len() > 128 || snapshot.releases.len() > 1_024 {
106        bail!("skill selection exceeds its bounded contract");
107    }
108    let requested = harnesses(&profile.harnesses)?;
109    let mut catalogue = BTreeMap::new();
110    for release in &snapshot.releases {
111        if !valid_pin(&release.pin)
112            || !text(&release.render_spec)
113            || release.name.is_empty()
114            || release.name.len() > 64
115            || !release
116                .name
117                .bytes()
118                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
119        {
120            bail!("invalid skill release metadata");
121        }
122        harnesses(&release.harnesses)?;
123        let key = (&release.pin.skill, &release.pin.share_id);
124        if catalogue.insert(key, release).is_some() {
125            bail!("ambiguous skill release metadata");
126        }
127    }
128    let mut selected = BTreeMap::new();
129    let mut names = BTreeSet::new();
130    for pin in &profile.pins {
131        if !valid_pin(pin) {
132            bail!("invalid profile pin");
133        }
134        let release = catalogue
135            .get(&(&pin.skill, &pin.share_id))
136            .ok_or_else(|| anyhow::anyhow!("pinned skill release is unavailable"))?;
137        if pin.version != release.pin.version
138            || (profile.public_only && !release.public)
139            || !requested.is_subset(&harnesses(&release.harnesses)?)
140        {
141            bail!("pinned release does not satisfy the profile");
142        }
143        if selected
144            .insert(pin.skill.clone(), (**release).clone())
145            .is_some()
146            || !names.insert(release.name.clone())
147        {
148            bail!("duplicate skill identity or destination name");
149        }
150    }
151    Ok(Selection {
152        profile: profile.id.clone(),
153        transaction: snapshot.transaction,
154        harnesses: requested.into_iter().collect(),
155        releases: selected.into_values().collect(),
156    })
157}
158
159#[cfg(test)]
160mod tests;