Skip to main content

substrate/model/
taste.rs

1use std::collections::HashMap;
2use std::fmt::{Display, Formatter};
3use std::str::FromStr;
4
5use anyhow::{anyhow, Result};
6use serde::{Deserialize, Serialize};
7
8pub const TASTE_SCHEMA: &str = "https://cmn.dev/schemas/v1/taste.json";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum GateAction {
13    Block,
14    Warn,
15    Proceed,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum GateOperation {
21    Spawn,
22    Grow,
23    Absorb,
24    Bond,
25    Replicate,
26    Taste,
27    Sense,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum TasteVerdict {
32    Sweet,
33    Fresh,
34    Safe,
35    Rotten,
36    Toxic,
37}
38
39impl TasteVerdict {
40    pub const ALL: [Self; 5] = [
41        Self::Sweet,
42        Self::Fresh,
43        Self::Safe,
44        Self::Rotten,
45        Self::Toxic,
46    ];
47
48    pub fn as_str(self) -> &'static str {
49        match self {
50            Self::Sweet => "sweet",
51            Self::Fresh => "fresh",
52            Self::Safe => "safe",
53            Self::Rotten => "rotten",
54            Self::Toxic => "toxic",
55        }
56    }
57
58    pub fn allows_use(self) -> bool {
59        !matches!(self, Self::Toxic)
60    }
61
62    pub fn base_gate_action(verdict: Option<Self>) -> GateAction {
63        match verdict {
64            None | Some(Self::Toxic) => GateAction::Block,
65            Some(Self::Rotten) => GateAction::Warn,
66            Some(Self::Safe | Self::Fresh | Self::Sweet) => GateAction::Proceed,
67        }
68    }
69
70    pub fn gate_action_for(operation: GateOperation, verdict: Option<Self>) -> GateAction {
71        Self::gate_action_for_env(operation, verdict, false)
72    }
73
74    /// Gate action with optional sandbox override.
75    /// In sandbox mode, untasted and rotten spores proceed (toxic still blocks).
76    pub fn gate_action_for_env(
77        operation: GateOperation,
78        verdict: Option<Self>,
79        sandboxed: bool,
80    ) -> GateAction {
81        match operation {
82            GateOperation::Taste | GateOperation::Sense => GateAction::Proceed,
83            GateOperation::Spawn
84            | GateOperation::Grow
85            | GateOperation::Absorb
86            | GateOperation::Bond
87            | GateOperation::Replicate => {
88                if sandboxed && verdict != Some(Self::Toxic) {
89                    GateAction::Proceed
90                } else {
91                    Self::base_gate_action(verdict)
92                }
93            }
94        }
95    }
96}
97
98impl Display for TasteVerdict {
99    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
100        f.write_str(self.as_str())
101    }
102}
103
104impl FromStr for TasteVerdict {
105    type Err = anyhow::Error;
106
107    fn from_str(value: &str) -> anyhow::Result<Self> {
108        match value {
109            "sweet" => Ok(Self::Sweet),
110            "fresh" => Ok(Self::Fresh),
111            "safe" => Ok(Self::Safe),
112            "rotten" => Ok(Self::Rotten),
113            "toxic" => Ok(Self::Toxic),
114            _ => Err(anyhow!(
115                "Invalid verdict '{}'. Must be one of: sweet, fresh, safe, rotten, toxic",
116                value
117            )),
118        }
119    }
120}
121
122impl Serialize for TasteVerdict {
123    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
124    where
125        S: serde::Serializer,
126    {
127        serializer.serialize_str(self.as_str())
128    }
129}
130
131impl<'de> Deserialize<'de> for TasteVerdict {
132    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
133    where
134        D: serde::Deserializer<'de>,
135    {
136        let value = String::deserialize(deserializer)?;
137        Self::from_str(&value).map_err(serde::de::Error::custom)
138    }
139}
140
141/// Full Taste manifest (content-addressed)
142#[derive(Serialize, Deserialize, Debug, Clone)]
143pub struct Taste {
144    #[serde(rename = "$schema")]
145    pub schema: String,
146    pub capsule: TasteCapsule,
147    pub capsule_signature: String,
148}
149
150/// Taste capsule containing uri, core, and core_signature
151#[derive(Serialize, Deserialize, Debug, Clone)]
152pub struct TasteCapsule {
153    pub uri: String,
154    pub core: TasteCore,
155    pub core_signature: String,
156}
157
158/// Core taste data (part of hash)
159#[derive(Serialize, Deserialize, Debug, Clone)]
160pub struct TasteCore {
161    pub domain: String,
162    pub key: String,
163    pub target_uri: String,
164    pub verdict: TasteVerdict,
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub notes: Vec<String>,
167    pub tasted_at_epoch_ms: u64,
168}
169
170/// Aggregated verdict counts across multiple taste reports.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct VerdictSummary {
173    pub counts: HashMap<TasteVerdict, u64>,
174    pub total: u64,
175}
176
177impl VerdictSummary {
178    /// Aggregate verdict counts from a slice of `Taste` manifests.
179    pub fn from_tastes(tastes: &[Taste]) -> Self {
180        let mut counts = HashMap::with_capacity(TasteVerdict::ALL.len());
181        for v in TasteVerdict::ALL {
182            counts.insert(v, 0);
183        }
184        for taste in tastes {
185            *counts.entry(taste.capsule.core.verdict).or_insert(0) += 1;
186        }
187        Self {
188            counts,
189            total: tastes.len() as u64,
190        }
191    }
192
193    /// Produce a JSON-friendly map of verdict name → count (all verdicts present, defaulting to 0).
194    pub fn to_json_map(&self) -> serde_json::Map<String, serde_json::Value> {
195        let mut map = serde_json::Map::new();
196        for v in TasteVerdict::ALL {
197            let count = self.counts.get(&v).copied().unwrap_or(0);
198            map.insert(
199                v.as_str().to_string(),
200                serde_json::Value::Number(count.into()),
201            );
202        }
203        map
204    }
205}
206
207impl Taste {
208    pub fn uri(&self) -> &str {
209        &self.capsule.uri
210    }
211
212    pub fn target_uri(&self) -> &str {
213        &self.capsule.core.target_uri
214    }
215
216    pub fn author_domain(&self) -> &str {
217        &self.capsule.core.domain
218    }
219
220    pub fn timestamp_ms(&self) -> u64 {
221        self.capsule.core.tasted_at_epoch_ms
222    }
223
224    pub fn embedded_core_key(&self) -> Option<&str> {
225        let key = self.capsule.core.key.as_str();
226        (!key.is_empty()).then_some(key)
227    }
228
229    pub fn verify_core_signature(&self, author_key: &str) -> Result<()> {
230        crate::verify_json_signature(&self.capsule.core, &self.capsule.core_signature, author_key)
231    }
232
233    pub fn verify_capsule_signature(&self, host_key: &str) -> Result<()> {
234        crate::verify_json_signature(&self.capsule, &self.capsule_signature, host_key)
235    }
236
237    pub fn verify_signatures(&self, host_key: &str, author_key: &str) -> Result<()> {
238        self.verify_core_signature(author_key)?;
239        self.verify_capsule_signature(host_key)
240    }
241
242    /// Verify both signatures using the same key (self-hosted case where host == author).
243    pub fn verify_self_hosted_signatures(&self, key: &str) -> Result<()> {
244        self.verify_signatures(key, key)
245    }
246
247    pub fn computed_uri_hash(&self) -> Result<String> {
248        crate::crypto::hash::compute_signed_core_hash(
249            &self.capsule.core,
250            &self.capsule.core_signature,
251        )
252    }
253
254    pub fn verify_uri_hash(&self, expected_hash: &str) -> Result<()> {
255        let actual_hash = self.computed_uri_hash()?;
256        super::verify_expected_uri_hash(&actual_hash, expected_hash)
257    }
258}
259
260// ---------------------------------------------------------------------------
261// Lightweight local verdict record
262// ---------------------------------------------------------------------------
263
264/// A lightweight local taste verdict record for client-side caching.
265///
266/// Unlike the full [`Taste`] manifest (which is cryptographically signed and
267/// content-addressed), this struct is for clients to persist their own taste
268/// results locally — e.g. after an LLM safety review or manual inspection.
269///
270/// ```
271/// use substrate::TasteVerdictRecord;
272/// use substrate::TasteVerdict;
273///
274/// let record = TasteVerdictRecord::new(TasteVerdict::Safe, Some("Reviewed, looks good"));
275/// let json = serde_json::to_string_pretty(&record).unwrap();
276/// let parsed: TasteVerdictRecord = serde_json::from_str(&json).unwrap();
277/// assert_eq!(parsed.verdict, TasteVerdict::Safe);
278/// ```
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280pub struct TasteVerdictRecord {
281    pub verdict: TasteVerdict,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub notes: Option<String>,
284    pub tasted_at_epoch_ms: u64,
285}
286
287impl TasteVerdictRecord {
288    /// Create a new verdict record stamped with the current time.
289    pub fn new(verdict: TasteVerdict, notes: Option<&str>) -> Self {
290        let now_ms = std::time::SystemTime::now()
291            .duration_since(std::time::UNIX_EPOCH)
292            .unwrap_or_default()
293            .as_millis() as u64;
294        Self {
295            verdict,
296            notes: notes.map(|s| s.to_string()),
297            tasted_at_epoch_ms: now_ms,
298        }
299    }
300
301    /// Create a verdict record with an explicit timestamp.
302    pub fn with_timestamp(verdict: TasteVerdict, notes: Option<&str>, epoch_ms: u64) -> Self {
303        Self {
304            verdict,
305            notes: notes.map(|s| s.to_string()),
306            tasted_at_epoch_ms: epoch_ms,
307        }
308    }
309
310    /// Whether this verdict allows the spore to be used (i.e. not toxic).
311    pub fn allows_use(&self) -> bool {
312        self.verdict.allows_use()
313    }
314
315    /// Gate action for a given operation based on this verdict.
316    pub fn gate_action_for(&self, operation: GateOperation) -> GateAction {
317        TasteVerdict::gate_action_for(operation, Some(self.verdict))
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    #![allow(clippy::expect_used, clippy::unwrap_used)]
324
325    use super::*;
326
327    #[test]
328    fn test_base_gate_action() {
329        assert_eq!(TasteVerdict::base_gate_action(None), GateAction::Block);
330        assert_eq!(
331            TasteVerdict::base_gate_action(Some(TasteVerdict::Toxic)),
332            GateAction::Block
333        );
334        assert_eq!(
335            TasteVerdict::base_gate_action(Some(TasteVerdict::Rotten)),
336            GateAction::Warn
337        );
338        assert_eq!(
339            TasteVerdict::base_gate_action(Some(TasteVerdict::Safe)),
340            GateAction::Proceed
341        );
342    }
343
344    #[test]
345    fn test_gate_action_for_operation() {
346        assert_eq!(
347            TasteVerdict::gate_action_for(GateOperation::Spawn, Some(TasteVerdict::Rotten)),
348            GateAction::Warn
349        );
350        assert_eq!(
351            TasteVerdict::gate_action_for(GateOperation::Taste, Some(TasteVerdict::Toxic)),
352            GateAction::Proceed
353        );
354        assert_eq!(
355            TasteVerdict::gate_action_for(GateOperation::Sense, None),
356            GateAction::Proceed
357        );
358    }
359
360    #[test]
361    fn test_gate_action_sandbox_skips_untasted() {
362        assert_eq!(
363            TasteVerdict::gate_action_for_env(GateOperation::Spawn, None, true),
364            GateAction::Proceed
365        );
366    }
367
368    #[test]
369    fn test_gate_action_sandbox_skips_rotten() {
370        assert_eq!(
371            TasteVerdict::gate_action_for_env(
372                GateOperation::Bond,
373                Some(TasteVerdict::Rotten),
374                true
375            ),
376            GateAction::Proceed
377        );
378    }
379
380    #[test]
381    fn test_gate_action_sandbox_still_blocks_toxic() {
382        assert_eq!(
383            TasteVerdict::gate_action_for_env(
384                GateOperation::Spawn,
385                Some(TasteVerdict::Toxic),
386                true
387            ),
388            GateAction::Block
389        );
390    }
391}