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
207/// Return the latest report for each `(taster domain, taster key, target_uri)`.
208pub fn latest_taste_reports_by_taster(tastes: &[Taste]) -> Vec<&Taste> {
209    let mut latest: HashMap<(&str, &str, &str), &Taste> = HashMap::new();
210    for taste in tastes {
211        let key = (
212            taste.capsule.core.domain.as_str(),
213            taste.capsule.core.key.as_str(),
214            taste.capsule.core.target_uri.as_str(),
215        );
216        match latest.get(&key) {
217            Some(existing)
218                if existing.capsule.core.tasted_at_epoch_ms
219                    >= taste.capsule.core.tasted_at_epoch_ms => {}
220            _ => {
221                latest.insert(key, taste);
222            }
223        }
224    }
225    latest.into_values().collect()
226}
227
228impl Taste {
229    pub fn uri(&self) -> &str {
230        &self.capsule.uri
231    }
232
233    pub fn target_uri(&self) -> &str {
234        &self.capsule.core.target_uri
235    }
236
237    pub fn author_domain(&self) -> &str {
238        &self.capsule.core.domain
239    }
240
241    pub fn timestamp_ms(&self) -> u64 {
242        self.capsule.core.tasted_at_epoch_ms
243    }
244
245    pub fn embedded_core_key(&self) -> Option<&str> {
246        let key = self.capsule.core.key.as_str();
247        (!key.is_empty()).then_some(key)
248    }
249
250    pub fn verify_core_signature(&self, author_key: &str) -> Result<()> {
251        crate::verify_json_signature(&self.capsule.core, &self.capsule.core_signature, author_key)
252    }
253
254    pub fn verify_capsule_signature(&self, host_key: &str) -> Result<()> {
255        crate::verify_json_signature(&self.capsule, &self.capsule_signature, host_key)
256    }
257
258    pub fn verify_signatures(&self, host_key: &str, author_key: &str) -> Result<()> {
259        self.verify_core_signature(author_key)?;
260        self.verify_capsule_signature(host_key)
261    }
262
263    /// Verify both signatures using the same key (self-hosted case where host == author).
264    pub fn verify_self_hosted_signatures(&self, key: &str) -> Result<()> {
265        self.verify_signatures(key, key)
266    }
267
268    pub fn computed_uri_hash(&self) -> Result<String> {
269        crate::crypto::hash::compute_signed_core_hash(
270            &self.capsule.core,
271            &self.capsule.core_signature,
272        )
273    }
274
275    pub fn verify_uri_hash(&self, expected_hash: &str) -> Result<()> {
276        let actual_hash = self.computed_uri_hash()?;
277        super::verify_expected_uri_hash(&actual_hash, expected_hash)
278    }
279}
280
281// ---------------------------------------------------------------------------
282// Lightweight local verdict record
283// ---------------------------------------------------------------------------
284
285/// A lightweight local taste verdict record for client-side caching.
286///
287/// Unlike the full [`Taste`] manifest (which is cryptographically signed and
288/// content-addressed), this struct is for clients to persist their own taste
289/// results locally — e.g. after an LLM safety review or manual inspection.
290///
291/// ```
292/// use substrate::TasteVerdictRecord;
293/// use substrate::TasteVerdict;
294///
295/// let record = TasteVerdictRecord::new(TasteVerdict::Safe, Some("Reviewed, looks good"));
296/// let json = serde_json::to_string_pretty(&record).unwrap();
297/// let parsed: TasteVerdictRecord = serde_json::from_str(&json).unwrap();
298/// assert_eq!(parsed.verdict, TasteVerdict::Safe);
299/// ```
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301pub struct TasteVerdictRecord {
302    pub verdict: TasteVerdict,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub notes: Option<String>,
305    pub tasted_at_epoch_ms: u64,
306}
307
308impl TasteVerdictRecord {
309    /// Create a new verdict record stamped with the current time.
310    pub fn new(verdict: TasteVerdict, notes: Option<&str>) -> Self {
311        let now_ms = std::time::SystemTime::now()
312            .duration_since(std::time::UNIX_EPOCH)
313            .unwrap_or_default()
314            .as_millis() as u64;
315        Self {
316            verdict,
317            notes: notes.map(|s| s.to_string()),
318            tasted_at_epoch_ms: now_ms,
319        }
320    }
321
322    /// Create a verdict record with an explicit timestamp.
323    pub fn with_timestamp(verdict: TasteVerdict, notes: Option<&str>, epoch_ms: u64) -> Self {
324        Self {
325            verdict,
326            notes: notes.map(|s| s.to_string()),
327            tasted_at_epoch_ms: epoch_ms,
328        }
329    }
330
331    /// Whether this verdict allows the spore to be used (i.e. not toxic).
332    pub fn allows_use(&self) -> bool {
333        self.verdict.allows_use()
334    }
335
336    /// Gate action for a given operation based on this verdict.
337    pub fn gate_action_for(&self, operation: GateOperation) -> GateAction {
338        TasteVerdict::gate_action_for(operation, Some(self.verdict))
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    #![allow(clippy::expect_used, clippy::unwrap_used)]
345
346    use super::*;
347
348    fn taste(domain: &str, key: &str, target: &str, verdict: TasteVerdict, ts: u64) -> Taste {
349        Taste {
350            schema: TASTE_SCHEMA.to_string(),
351            capsule: TasteCapsule {
352                uri: format!("cmn://{domain}/taste/b3.fake{ts}"),
353                core: TasteCore {
354                    domain: domain.to_string(),
355                    key: key.to_string(),
356                    target_uri: target.to_string(),
357                    verdict,
358                    notes: vec![],
359                    tasted_at_epoch_ms: ts,
360                },
361                core_signature: "ed25519.fake".to_string(),
362            },
363            capsule_signature: "ed25519.fake".to_string(),
364        }
365    }
366
367    #[test]
368    fn test_latest_taste_reports_by_taster_uses_newest_per_target() {
369        let reports = vec![
370            taste(
371                "alice.dev",
372                "ed25519.a",
373                "cmn://target.dev/b3.x",
374                TasteVerdict::Safe,
375                10,
376            ),
377            taste(
378                "alice.dev",
379                "ed25519.a",
380                "cmn://target.dev/b3.x",
381                TasteVerdict::Toxic,
382                20,
383            ),
384            taste(
385                "bob.dev",
386                "ed25519.b",
387                "cmn://target.dev/b3.x",
388                TasteVerdict::Fresh,
389                5,
390            ),
391        ];
392
393        let latest = latest_taste_reports_by_taster(&reports);
394        assert_eq!(latest.len(), 2);
395        assert!(latest.iter().any(|t| {
396            t.author_domain() == "alice.dev" && t.capsule.core.verdict == TasteVerdict::Toxic
397        }));
398        assert!(latest.iter().any(|t| {
399            t.author_domain() == "bob.dev" && t.capsule.core.verdict == TasteVerdict::Fresh
400        }));
401    }
402
403    #[test]
404    fn test_base_gate_action() {
405        assert_eq!(TasteVerdict::base_gate_action(None), GateAction::Block);
406        assert_eq!(
407            TasteVerdict::base_gate_action(Some(TasteVerdict::Toxic)),
408            GateAction::Block
409        );
410        assert_eq!(
411            TasteVerdict::base_gate_action(Some(TasteVerdict::Rotten)),
412            GateAction::Warn
413        );
414        assert_eq!(
415            TasteVerdict::base_gate_action(Some(TasteVerdict::Safe)),
416            GateAction::Proceed
417        );
418    }
419
420    #[test]
421    fn test_gate_action_for_operation() {
422        assert_eq!(
423            TasteVerdict::gate_action_for(GateOperation::Spawn, Some(TasteVerdict::Rotten)),
424            GateAction::Warn
425        );
426        assert_eq!(
427            TasteVerdict::gate_action_for(GateOperation::Taste, Some(TasteVerdict::Toxic)),
428            GateAction::Proceed
429        );
430        assert_eq!(
431            TasteVerdict::gate_action_for(GateOperation::Sense, None),
432            GateAction::Proceed
433        );
434    }
435
436    #[test]
437    fn test_gate_action_sandbox_skips_untasted() {
438        assert_eq!(
439            TasteVerdict::gate_action_for_env(GateOperation::Spawn, None, true),
440            GateAction::Proceed
441        );
442    }
443
444    #[test]
445    fn test_gate_action_sandbox_skips_rotten() {
446        assert_eq!(
447            TasteVerdict::gate_action_for_env(
448                GateOperation::Bond,
449                Some(TasteVerdict::Rotten),
450                true
451            ),
452            GateAction::Proceed
453        );
454    }
455
456    #[test]
457    fn test_gate_action_sandbox_still_blocks_toxic() {
458        assert_eq!(
459            TasteVerdict::gate_action_for_env(
460                GateOperation::Spawn,
461                Some(TasteVerdict::Toxic),
462                true
463            ),
464            GateAction::Block
465        );
466    }
467}