Skip to main content

substrate/schemas/
mod.rs

1//! JSON Schema validation for CMN documents
2//!
3//! Uses embedded schemas for fast, offline validation.
4//! See docs/09_SCHEMA.md for specification.
5
6use anyhow::{anyhow, Result};
7use serde_json::Value;
8
9use crate::model::{CMN_SCHEMA, MYCELIUM_SCHEMA, SPORE_CORE_SCHEMA, SPORE_SCHEMA, TASTE_SCHEMA};
10
11// Embedded schemas - compiled into the binary
12pub const SPORE_SCHEMA_JSON: &str = include_str!("spore.json");
13pub const MYCELIUM_SCHEMA_JSON: &str = include_str!("mycelium.json");
14pub const CMN_SCHEMA_JSON: &str = include_str!("cmn.json");
15pub const SPORE_CORE_SCHEMA_JSON: &str = include_str!("spore-core.json");
16pub const TASTE_SCHEMA_JSON: &str = include_str!("taste.json");
17
18/// CMN document type
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SchemaType {
21    Spore,
22    SporeCore,
23    Mycelium,
24    Cmn,
25    Taste,
26}
27
28#[derive(Clone, Copy)]
29struct SchemaDescriptor {
30    schema_type: SchemaType,
31    schema_json: &'static str,
32}
33
34/// Validation error details
35#[derive(Debug)]
36pub struct ValidationError {
37    pub message: String,
38    pub path: String,
39}
40
41impl std::fmt::Display for ValidationError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        write!(f, "{} at {}", self.message, self.path)
44    }
45}
46
47fn extract_schema_url(doc: &Value) -> Result<&str> {
48    doc.get("$schema")
49        .and_then(Value::as_str)
50        .ok_or_else(|| anyhow!("Missing $schema field"))
51}
52
53fn describe_schema(schema_url: &str) -> Option<SchemaDescriptor> {
54    match schema_url {
55        s if s == SPORE_SCHEMA || s.ends_with("/spore.json") => Some(SchemaDescriptor {
56            schema_type: SchemaType::Spore,
57            schema_json: SPORE_SCHEMA_JSON,
58        }),
59        s if s == SPORE_CORE_SCHEMA || s.ends_with("/spore-core.json") => Some(SchemaDescriptor {
60            schema_type: SchemaType::SporeCore,
61            schema_json: SPORE_CORE_SCHEMA_JSON,
62        }),
63        s if s == MYCELIUM_SCHEMA || s.ends_with("/mycelium.json") => Some(SchemaDescriptor {
64            schema_type: SchemaType::Mycelium,
65            schema_json: MYCELIUM_SCHEMA_JSON,
66        }),
67        s if s == CMN_SCHEMA || s.ends_with("/cmn.json") => Some(SchemaDescriptor {
68            schema_type: SchemaType::Cmn,
69            schema_json: CMN_SCHEMA_JSON,
70        }),
71        s if s == TASTE_SCHEMA || s.ends_with("/taste.json") => Some(SchemaDescriptor {
72            schema_type: SchemaType::Taste,
73            schema_json: TASTE_SCHEMA_JSON,
74        }),
75        _ => None,
76    }
77}
78
79/// Get embedded schema by URL
80///
81/// Returns the embedded schema JSON string for a given schema URL.
82/// Uses suffix matching to handle different schema URL formats.
83///
84/// # Examples
85/// ```
86/// use substrate::schemas::get_schema;
87///
88/// let schema = get_schema("https://cmn.dev/schemas/v1/spore.json");
89/// assert!(schema.is_some());
90/// ```
91pub fn get_schema(schema_url: &str) -> Option<&'static str> {
92    describe_schema(schema_url).map(|descriptor| descriptor.schema_json)
93}
94
95/// Detect document type from $schema field
96///
97/// # Examples
98/// ```
99/// use substrate::schemas::detect_schema_type;
100/// use serde_json::json;
101///
102/// let doc = json!({
103///     "$schema": "https://cmn.dev/schemas/v1/spore.json",
104///     "capsule": {},
105///     "capsule_signature": ""
106/// });
107/// let schema_type = detect_schema_type(&doc).unwrap();
108/// assert!(matches!(schema_type, substrate::schemas::SchemaType::Spore));
109/// ```
110pub fn detect_schema_type(doc: &Value) -> Result<SchemaType> {
111    let schema_url = extract_schema_url(doc)?;
112    describe_schema(schema_url)
113        .map(|descriptor| descriptor.schema_type)
114        .ok_or_else(|| anyhow!("Unknown schema: {}", schema_url))
115}
116
117/// Validate a CMN document against its schema
118///
119/// Automatically detects the document type from `$schema` and validates
120/// against the embedded schema.
121///
122/// # Examples
123/// ```
124/// use substrate::schemas::validate;
125/// use serde_json::json;
126///
127/// let doc = json!({
128///     "$schema": "https://cmn.dev/schemas/v1/spore.json",
129///     "capsule": {
130///         "uri": "cmn://example.com/b3.3yMR7vZQ9hL",
131///         "core": {
132///             "id": "test",
133///             "name": "test",
134///             "domain": "example.com",
135///             "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
136///             "synopsis": "A test",
137///             "intent": ["Testing"],
138///             "license": "MIT",
139///             "mutations": [],
140///             "bonds": [],
141///             "size_bytes": 0,
142///             "tree": { "algorithm": "blob_tree_blake3_nfc", "exclude_names": [], "follow_rules": [] },
143///             "updated_at_epoch_ms": 1700000000000_u64
144///         },
145///         "core_signature": "ed25519.5XmkQ9vZP8nL",
146///         "dist": [{"type":"archive"}]
147///     },
148///     "capsule_signature": "ed25519.3yMR7vZQ9hL"
149/// });
150///
151/// assert!(validate(&doc).is_ok());
152/// ```
153pub fn validate(doc: &Value) -> Result<SchemaType> {
154    // 1. Extract schema URL and schema metadata
155    let schema_url = extract_schema_url(doc)?;
156    let descriptor =
157        describe_schema(schema_url).ok_or_else(|| anyhow!("Unknown schema: {}", schema_url))?;
158
159    // 2. Get schema
160    let schema: Value = serde_json::from_str(descriptor.schema_json)
161        .map_err(|e| anyhow!("Failed to parse schema: {}", e))?;
162
163    // 3. Compile schema
164    let compiled = jsonschema::validator_for(&schema)
165        .map_err(|e| anyhow!("Failed to compile schema: {}", e))?;
166
167    // 4. Validate
168    if let Err(e) = compiled.validate(doc) {
169        let errors: Vec<String> = compiled
170            .iter_errors(doc)
171            .map(|e| format!("{} at {}", e, e.instance_path()))
172            .collect();
173        if errors.is_empty() {
174            return Err(anyhow!("Validation failed: {}", e));
175        }
176        return Err(anyhow!("Validation failed: {}", errors.join("; ")));
177    }
178
179    Ok(descriptor.schema_type)
180}
181
182/// Validate a document and return detailed errors
183///
184/// Unlike `validate()`, this returns a list of all validation errors
185/// instead of failing on the first one.
186pub fn validate_detailed(doc: &Value) -> Result<(SchemaType, Vec<ValidationError>)> {
187    // 1. Extract schema URL and schema metadata
188    let schema_url = extract_schema_url(doc)?;
189    let descriptor =
190        describe_schema(schema_url).ok_or_else(|| anyhow!("Unknown schema: {}", schema_url))?;
191
192    // 2. Get schema
193    let schema: Value = serde_json::from_str(descriptor.schema_json)
194        .map_err(|e| anyhow!("Failed to parse schema: {}", e))?;
195
196    // 3. Compile schema
197    let compiled = jsonschema::validator_for(&schema)
198        .map_err(|e| anyhow!("Failed to compile schema: {}", e))?;
199
200    // 4. Validate and collect errors
201    let errors: Vec<ValidationError> = compiled
202        .iter_errors(doc)
203        .map(|e| ValidationError {
204            message: e.to_string(),
205            path: e.instance_path().to_string(),
206        })
207        .collect();
208
209    Ok((descriptor.schema_type, errors))
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use serde_json::{json, Value};
216
217    #[test]
218    fn test_get_schema_spore() {
219        let schema = get_schema(SPORE_SCHEMA);
220        assert!(schema.is_some());
221        assert!(schema.map(|s| s.contains("spore_core")).unwrap_or(false));
222    }
223
224    #[test]
225    fn test_get_schema_mycelium() {
226        let schema = get_schema(MYCELIUM_SCHEMA);
227        assert!(schema.is_some());
228        assert!(schema.map(|s| s.contains("mycelium_core")).unwrap_or(false));
229    }
230
231    #[test]
232    fn test_get_schema_cmn() {
233        let schema = get_schema(CMN_SCHEMA);
234        assert!(schema.is_some());
235        assert!(schema.map(|s| s.contains("endpoints")).unwrap_or(false));
236    }
237
238    #[test]
239    fn test_get_schema_unknown() {
240        let schema = get_schema("https://example.com/unknown.json");
241        assert!(schema.is_none());
242    }
243
244    #[test]
245    fn test_detect_schema_type_spore() {
246        let doc = json!({
247            "$schema": SPORE_SCHEMA
248        });
249        assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::Spore));
250    }
251
252    #[test]
253    fn test_detect_schema_type_mycelium() {
254        let doc = json!({
255            "$schema": MYCELIUM_SCHEMA
256        });
257        assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::Mycelium));
258    }
259
260    #[test]
261    fn test_detect_schema_type_cmn() {
262        let doc = json!({
263            "$schema": CMN_SCHEMA
264        });
265        assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::Cmn));
266    }
267
268    #[test]
269    fn test_validate_valid_spore() {
270        let doc = json!({
271            "$schema": SPORE_SCHEMA,
272            "capsule": {
273                "uri": "cmn://example.com/b3.3yMR7vZQ9hL",
274                "core": {
275                    "id": "test-spore",
276                    "name": "test-spore",
277                    "domain": "example.com",
278                    "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
279                    "synopsis": "A test spore",
280                    "intent": ["Testing"],
281                    "license": "MIT",
282                    "mutations": [],
283                    "bonds": [],
284                    "size_bytes": 0,
285                    "tree": { "algorithm": "blob_tree_blake3_nfc", "exclude_names": [], "follow_rules": [] },
286                    "updated_at_epoch_ms": 1700000000000_u64
287                },
288                "core_signature": "ed25519.5XmkQ9vZP8nL",
289                "dist": [{"type":"archive"}]
290            },
291            "capsule_signature": "ed25519.3yMR7vZQ9hL"
292        });
293
294        let result = validate(&doc);
295        assert!(result.is_ok(), "Validation failed: {:?}", result.err());
296        assert_eq!(result.ok(), Some(SchemaType::Spore));
297    }
298
299    #[test]
300    fn test_validate_valid_mycelium() {
301        let doc = json!({
302            "$schema": MYCELIUM_SCHEMA,
303            "capsule": {
304                "uri": "cmn://example.com/mycelium/b3.3yMR7vZQ9hL",
305                "core": {
306                    "name": "Test User",
307                    "domain": "example.com",
308                    "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
309                    "synopsis": "A test user",
310                    "updated_at_epoch_ms": 1234567890000_u64,
311                    "spores": [],
312                    "nutrients": [],
313                    "tastes": []
314                },
315                "core_signature": "ed25519.5XmkQ9vZP8nL"
316            },
317            "capsule_signature": "ed25519.3yMR7vZQ9hL"
318        });
319
320        let result = validate(&doc);
321        assert!(result.is_ok(), "Validation failed: {:?}", result.err());
322        assert_eq!(result.ok(), Some(SchemaType::Mycelium));
323    }
324
325    fn valid_cmn_doc() -> Value {
326        json!({
327            "$schema": CMN_SCHEMA,
328            "capsules": [{
329                "uri": "cmn://example.com",
330                "serial": 1,
331                "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
332                "history": [],
333                "endpoints": [
334                    {
335                        "type": "mycelium",
336                        "url": "https://example.com/cmn/mycelium/{hash}.json",
337                        "hash": "b3.3yMR7vZQ9hL"
338                    },
339                    {
340                        "type": "spore",
341                        "url": "https://example.com/cmn/spore/{hash}.json"
342                    },
343                    {
344                        "type": "archive",
345                        "url": "https://example.com/cmn/archive/{hash}.tar.zst",
346                        "format": "tar+zstd"
347                    }
348                ]
349            }],
350            "capsule_signature": "ed25519.3yMR7vZQ9hL"
351        })
352    }
353
354    #[test]
355    fn test_validate_valid_cmn() {
356        let doc = valid_cmn_doc();
357
358        let result = validate(&doc);
359        assert!(result.is_ok(), "Validation failed: {:?}", result.err());
360        assert_eq!(result.ok(), Some(SchemaType::Cmn));
361    }
362
363    #[test]
364    fn test_validate_valid_cmn_history_entry() {
365        let mut doc = valid_cmn_doc();
366        doc["capsules"][0]["serial"] = json!(2);
367        doc["capsules"][0]["history"] = json!([{
368            "key": "ed25519.CJfRUQxyonG6B5mnztsNUqxknbFT89DJdrdrzV9F96mU",
369            "status": "retired",
370            "retired_at_epoch_ms": 1710000000000_u64,
371            "replaced_by": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
372            "rotation_signature": "ed25519.3yMR7vZQ9hL"
373        }]);
374
375        let result = validate(&doc);
376        assert!(result.is_ok(), "Validation failed: {:?}", result.err());
377    }
378
379    #[test]
380    fn test_validate_valid_cmn_taste_only() {
381        let doc = json!({
382            "$schema": CMN_SCHEMA,
383            "capsules": [{
384                "uri": "cmn://taster.example.com",
385                "serial": 1,
386                "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
387                "history": [],
388                "endpoints": [{
389                    "type": "taste",
390                    "url": "https://taster.example.com/cmn/taste/{hash}.json"
391                }]
392            }],
393            "capsule_signature": "ed25519.3yMR7vZQ9hL"
394        });
395
396        let result = validate(&doc);
397        assert!(result.is_ok(), "Validation failed: {:?}", result.err());
398    }
399
400    #[test]
401    fn test_validate_valid_cmn_no_endpoints() {
402        let doc = json!({
403            "$schema": CMN_SCHEMA,
404            "capsules": [{
405                "uri": "cmn://minimal.example.com",
406                "serial": 1,
407                "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
408                "history": [],
409                "endpoints": []
410            }],
411            "capsule_signature": "ed25519.3yMR7vZQ9hL"
412        });
413
414        let result = validate(&doc);
415        assert!(result.is_ok(), "Validation failed: {:?}", result.err());
416    }
417
418    #[test]
419    fn test_validate_cmn_missing_key() {
420        let mut doc = valid_cmn_doc();
421        let Some(capsule) = doc["capsules"][0].as_object_mut() else {
422            assert!(
423                doc["capsules"][0].is_object(),
424                "valid_cmn_doc should contain an object capsule"
425            );
426            return;
427        };
428        capsule.remove("key");
429
430        let result = validate(&doc);
431        assert!(result.is_err(), "Expected validation to fail without key");
432    }
433
434    #[test]
435    fn test_validate_cmn_missing_serial() {
436        let mut doc = valid_cmn_doc();
437        let Some(capsule) = doc["capsules"][0].as_object_mut() else {
438            assert!(
439                doc["capsules"][0].is_object(),
440                "valid_cmn_doc should contain an object capsule"
441            );
442            return;
443        };
444        capsule.remove("serial");
445
446        let result = validate(&doc);
447        assert!(
448            result.is_err(),
449            "Expected validation to fail without serial"
450        );
451    }
452
453    #[test]
454    fn test_validate_cmn_rejects_removed_protocol_versions() {
455        let mut doc = valid_cmn_doc();
456        let Some(doc_object) = doc.as_object_mut() else {
457            assert!(doc.is_object(), "valid_cmn_doc should be an object");
458            return;
459        };
460        doc_object.insert("protocol_versions".to_string(), json!(["v1"]));
461
462        let result = validate(&doc);
463        assert!(
464            result.is_err(),
465            "Expected validation to reject top-level protocol_versions"
466        );
467    }
468
469    #[test]
470    fn test_validate_cmn_rejects_removed_endpoint_protocol_version() {
471        let mut doc = valid_cmn_doc();
472        doc["capsules"][0]["endpoints"][0]["protocol_version"] = json!("v1");
473
474        let result = validate(&doc);
475        assert!(
476            result.is_err(),
477            "Expected validation to reject endpoint protocol_version"
478        );
479    }
480
481    #[test]
482    fn test_validate_cmn_rejects_previous_keys() {
483        let mut doc = valid_cmn_doc();
484        doc["capsules"][0]["previous_keys"] = json!([]);
485
486        let result = validate(&doc);
487        assert!(
488            result.is_err(),
489            "Expected validation to reject previous_keys"
490        );
491    }
492
493    #[test]
494    fn test_validate_cmn_retired_history_requires_rotation_signature() {
495        let mut doc = valid_cmn_doc();
496        doc["capsules"][0]["history"] = json!([{
497            "key": "ed25519.CJfRUQxyonG6B5mnztsNUqxknbFT89DJdrdrzV9F96mU",
498            "status": "retired",
499            "retired_at_epoch_ms": 1710000000000_u64,
500            "replaced_by": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4"
501        }]);
502
503        let result = validate(&doc);
504        assert!(
505            result.is_err(),
506            "Expected retired history to require rotation_signature"
507        );
508    }
509
510    #[test]
511    fn test_validate_missing_schema() {
512        let doc = json!({
513            "capsule": {}
514        });
515
516        let result = validate(&doc);
517        assert!(result.is_err());
518        assert!(result
519            .err()
520            .map(|e| e.to_string().contains("Missing $schema"))
521            .unwrap_or(false));
522    }
523
524    #[test]
525    fn test_validate_invalid_spore_missing_required() {
526        let doc = json!({
527            "$schema": SPORE_SCHEMA,
528            "capsule": {
529                "uri": "cmn://example.com/b3.3yMR7vZQ9hL",
530                "core": {
531                    "name": "test"
532                    // missing domain, synopsis, intent, license
533                },
534                "core_signature": "ed25519.5XmkQ9vZP8nL",
535                "dist": [{"type":"archive","filename":"test.tar.zst"}]
536            },
537            "capsule_signature": "ed25519.3yMR7vZQ9hL"
538        });
539
540        let result = validate(&doc);
541        assert!(result.is_err());
542    }
543
544    #[test]
545    fn test_validate_detailed_returns_all_errors() {
546        let doc = json!({
547            "$schema": SPORE_SCHEMA,
548            "capsule": {
549                "uri": "invalid-uri",  // Invalid format
550                "core": {
551                    "name": ""  // Empty name (minLength: 1)
552                    // missing required fields
553                },
554                "core_signature": "invalid",  // Invalid format
555                "dist": [{"type":"archive","filename":"test.tar.zst"}]
556            },
557            "capsule_signature": "invalid"  // Invalid format
558        });
559
560        let result = validate_detailed(&doc);
561        assert!(result.is_ok());
562        let (_, errors) = result.ok().unwrap_or((SchemaType::Spore, vec![]));
563        assert!(!errors.is_empty(), "Expected validation errors");
564    }
565
566    #[test]
567    fn test_get_schema_spore_core() {
568        let schema = get_schema(SPORE_CORE_SCHEMA);
569        assert!(schema.is_some());
570        assert!(schema.map(|s| s.contains("bonds")).unwrap_or(false));
571    }
572
573    #[test]
574    fn test_detect_schema_type_spore_core() {
575        let doc = json!({
576            "$schema": SPORE_CORE_SCHEMA
577        });
578        assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::SporeCore));
579    }
580
581    #[test]
582    fn test_validate_valid_spore_core() {
583        // This is the format of spore.core.json
584        let doc = json!({
585            "$schema": SPORE_CORE_SCHEMA,
586            "id": "my-tool",
587            "name": "my-tool",
588            "domain": "example.com",
589            "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
590            "synopsis": "A useful tool",
591            "intent": ["v1.0.0"],
592            "license": "MIT",
593            "mutations": [],
594            "bonds": [],
595            "tree": { "algorithm": "blob_tree_blake3_nfc", "exclude_names": [], "follow_rules": [] }
596        });
597
598        let result = validate(&doc);
599        assert!(result.is_ok(), "Validation failed: {:?}", result.err());
600        assert_eq!(result.ok(), Some(SchemaType::SporeCore));
601    }
602
603    #[test]
604    fn test_validate_spore_core_with_optional_fields() {
605        let doc = json!({
606            "$schema": SPORE_CORE_SCHEMA,
607            "id": "my-tool",
608            "name": "my-tool",
609            "domain": "example.com",
610            "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
611            "synopsis": "A useful tool",
612            "intent": ["v1.0.0"],
613            "license": "MIT",
614            "mutations": [],
615            "bonds": [
616                { "uri": "cmn://other.com/b3.3yMR7vZQ9hL", "relation": "depends_on" }
617            ],
618            "tree": {
619                "algorithm": "blob_tree_blake3_nfc",
620                "exclude_names": [".git"],
621                "follow_rules": [".gitignore"]
622            }
623        });
624
625        let result = validate(&doc);
626        assert!(result.is_ok(), "Validation failed: {:?}", result.err());
627        assert_eq!(result.ok(), Some(SchemaType::SporeCore));
628    }
629
630    #[test]
631    fn test_get_schema_taste() {
632        let schema = get_schema(TASTE_SCHEMA);
633        assert!(schema.is_some());
634        assert!(schema.map(|s| s.contains("taste_core")).unwrap_or(false));
635    }
636
637    #[test]
638    fn test_detect_schema_type_taste() {
639        let doc = json!({
640            "$schema": TASTE_SCHEMA
641        });
642        assert_eq!(detect_schema_type(&doc).ok(), Some(SchemaType::Taste));
643    }
644
645    #[test]
646    fn test_validate_valid_taste() {
647        let doc = json!({
648            "$schema": TASTE_SCHEMA,
649            "capsule": {
650                "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
651                "core": {
652                    "target_uri": "cmn://example.com/b3.3yMR7vZQ9hL",
653                    "domain": "reviewer.com",
654                    "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
655                    "verdict": "safe",
656                    "tasted_at_epoch_ms": 1234567890000_u64
657                },
658                "core_signature": "ed25519.5XmkQ9vZP8nL"
659            },
660            "capsule_signature": "ed25519.3yMR7vZQ9hL"
661        });
662
663        let result = validate(&doc);
664        assert!(result.is_ok(), "Validation failed: {:?}", result.err());
665        assert_eq!(result.ok(), Some(SchemaType::Taste));
666    }
667
668    #[test]
669    fn test_validate_taste_invalid_verdict() {
670        let doc = json!({
671            "$schema": TASTE_SCHEMA,
672            "capsule": {
673                "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
674                "core": {
675                    "target_uri": "cmn://example.com/b3.3yMR7vZQ9hL",
676                    "domain": "reviewer.com",
677                    "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
678                    "verdict": "unknown_taste",
679                    "tasted_at_epoch_ms": 1234567890000_u64
680                },
681                "core_signature": "ed25519.5XmkQ9vZP8nL"
682            },
683            "capsule_signature": "ed25519.3yMR7vZQ9hL"
684        });
685
686        let result = validate(&doc);
687        assert!(
688            result.is_err(),
689            "Expected validation to fail with invalid taste"
690        );
691    }
692
693    #[test]
694    fn test_validate_taste_mycelium_target_uri() {
695        let doc = json!({
696            "$schema": TASTE_SCHEMA,
697            "capsule": {
698                "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
699                "core": {
700                    "target_uri": "cmn://example.com/mycelium/b3.3yMR7vZQ9hL",
701                    "domain": "reviewer.com",
702                    "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
703                    "verdict": "safe",
704                    "tasted_at_epoch_ms": 1234567890000_u64
705                },
706                "core_signature": "ed25519.5XmkQ9vZP8nL"
707            },
708            "capsule_signature": "ed25519.3yMR7vZQ9hL"
709        });
710
711        let result = validate(&doc);
712        assert!(result.is_ok(), "Validation failed: {:?}", result.err());
713    }
714
715    #[test]
716    fn test_validate_taste_rejects_taste_target_uri() {
717        let doc = json!({
718            "$schema": TASTE_SCHEMA,
719            "capsule": {
720                "uri": "cmn://reviewer.com/taste/b3.7tRkW2xPqL9nH",
721                "core": {
722                    "target_uri": "cmn://someone.dev/taste/b3.3yMR7vZQ9hL",
723                    "domain": "reviewer.com",
724                    "key": "ed25519.5XmkQ9vZP8nL3xJdFtR7wNcA6sY2bKgU1eH9pXb4",
725                    "verdict": "safe",
726                    "tasted_at_epoch_ms": 1234567890000_u64
727                },
728                "core_signature": "ed25519.5XmkQ9vZP8nL"
729            },
730            "capsule_signature": "ed25519.3yMR7vZQ9hL"
731        });
732
733        let result = validate(&doc);
734        assert!(result.is_err(), "Expected taste target_uri to be rejected");
735    }
736
737    #[test]
738    fn test_validate_invalid_spore_core_missing_required() {
739        let doc = json!({
740            "$schema": SPORE_CORE_SCHEMA,
741            "name": "my-tool"
742            // missing domain, synopsis, intent, license
743        });
744
745        let result = validate(&doc);
746        assert!(result.is_err());
747    }
748}