Skip to main content

nap_core/
schema.rs

1//! JSON Schema generation for NAP core types.
2//!
3//! These schemas describe the NAP data model (manifest, commit, etc.)
4//! in standard JSON Schema draft-2020-12 format.  They are served by the
5//! HTTP resolver at `/schema/{name}` and consumed by the MCP server's
6//! `nap_get_schema` tool so that agents can introspect the data model
7//! without guessing field names or types.
8
9use serde_json::Value;
10
11/// Returns the JSON Schema for the full Manifest struct.
12pub fn manifest_schema() -> Value {
13    serde_json::json!({
14        "$schema": "https://json-schema.org/draft/2020-12/schema",
15        "$id": "nap://schema/manifest.json",
16        "title": "NAP Manifest",
17        "description": concat!(
18            "The durable representation of a narrative resource. ",
19            "Human-editable, machine-readable, agent-queryable. ",
20            "Characters, locations, scenes, props, and worlds ",
21            "all share this common structure."
22        ),
23        "type": "object",
24        "required": ["id", "name", "entity_type"],
25        "properties": {
26            "id": {
27                "type": "string",
28                "pattern": "^nap://[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$",
29                "description": concat!(
30                    "Canonical NAP URI. ",
31                    "e.g., nap://starwars/character/lukeskywalker"
32                )
33            },
34            "name": {
35                "type": "string",
36                "description": "Human-readable display name. e.g., 'Luke Skywalker'"
37            },
38            "entity_type": {
39                "type": "string",
40                "minLength": 1,
41                "description": "The kind of narrative entity this manifest describes (any non-empty string)"
42            },
43            "version": {
44                "type": "integer",
45                "minimum": 0,
46                "description": "Monotonic version counter. Incremented on each commit."
47            },
48            "principals": {
49                "type": "object",
50                "description": "Access control owners/maintainers/publishers (optional).",
51                "properties": {
52                    "owners": {
53                        "type": "array",
54                        "items": { "type": "string" },
55                        "description": "Full control — can modify, transfer, delete."
56                    },
57                    "maintainers": {
58                        "type": "array",
59                        "items": { "type": "string" },
60                        "description": "Can modify content but not transfer ownership."
61                    },
62                    "publishers": {
63                        "type": "array",
64                        "items": { "type": "string" },
65                        "description": "Can publish/distribute but not modify source."
66                    }
67                }
68            },
69            "properties": {
70                "type": "object",
71                "additionalProperties": true,
72                "description": concat!(
73                    "Entity-specific key-value properties. ",
74                    "Character: species, homeworld, affiliation, lightsaber_color. ",
75                    "Scene: setting, participants, mood, time_of_day, outcome. ",
76                    "Location: climate, type, controlled_by, population. ",
77                    "Prop: owner, material, weight, color."
78                )
79            },
80            "representations": {
81                "type": "object",
82                "additionalProperties": {
83                    "$ref": "#/definitions/Representation"
84                },
85                "description": concat!(
86                    "Content-addressed representations of this entity. ",
87                    "Keys are labels like 'reference_image', 'voice_model', 'mesh'."
88                )
89            },
90            "references": {
91                "type": "object",
92                "additionalProperties": true,
93                "description": concat!(
94                    "Cross-references to other NAP resources. ",
95                    "Common keys: appears_in (array of scene URIs), ",
96                    "relationships (array of {target, type} objects), ",
97                    "owner (character URI)."
98                )
99            },
100            "provenance": {
101                "$ref": "#/definitions/Provenance",
102                "description": concat!(
103                    "AI generation provenance metadata — which model, ",
104                    "prompt, seed, and parameters were used."
105                )
106            },
107            "head": {
108                "type": "string",
109                "pattern": "^[a-f0-9]{40}$|^[a-f0-9]{64}$",
110                "description": concat!(
111                    "Pointer to the latest VCS commit hash (40-char Git SHA-1 ",
112                    "or 64-char BLAKE3). ",
113                    "History lives in the VCS, not the manifest."
114                )
115            },
116            "metadata": {
117                "type": "object",
118                "additionalProperties": true,
119                "description": "Arbitrary extension metadata. Future-proof escape hatch."
120            }
121        },
122        "definitions": {
123            "Representation": {
124                "type": "object",
125                "required": ["hash", "format"],
126                "properties": {
127                    "hash": {
128                        "type": "string",
129                        "pattern": "^blake3:[a-f0-9]{64}$",
130                        "description": concat!(
131                            "BLAKE3 content hash of the asset. ",
132                            "Format: blake3:<64-hex-chars>"
133                        )
134                    },
135                    "format": {
136                        "type": "string",
137                        "description": concat!(
138                            "File format. ",
139                            "Examples: png, glb, onnx, wav, mp4, splat"
140                        )
141                    },
142                    "uri": {
143                        "type": "string",
144                        "description": concat!(
145                            "Optional storage URI. ",
146                            "e.g., gs://assets/starwars/luke/ref.png, ",
147                            "s3://bucket/path/to/file.glb"
148                        )
149                    },
150                    "tier": {
151                        "type": "string",
152                        "enum": ["draft", "production", "distribution"],
153                        "description": concat!(
154                            "Quality tier of the representation. ",
155                            "draft = WIP, production = final, distribution = optimized"
156                        )
157                    }
158                }
159            },
160            "Provenance": {
161                "type": "object",
162                "properties": {
163                    "model": {
164                        "type": "string",
165                        "description": concat!(
166                            "AI model used to generate this entity. ",
167                            "e.g., 'midjourney-v6', 'gpt-4o', 'stable-diffusion-3'"
168                        )
169                    },
170                    "prompt_hash": {
171                        "type": "string",
172                        "pattern": "^blake3:[a-f0-9]{64}$",
173                        "description": "BLAKE3 content hash of the generation prompt."
174                    },
175                    "seed": {
176                        "type": "string",
177                        "description": "Generation seed for reproducibility."
178                    },
179                    "parameters": {
180                        "type": "object",
181                        "additionalProperties": {
182                            "type": "string"
183                        },
184                        "description": concat!(
185                            "Additional generation parameters. ",
186                            "e.g., stylize, chaos, temperature, cfg_scale"
187                        )
188                    },
189                    "derived_from": {
190                        "type": "string",
191                        "pattern": "^nap://",
192                        "description": concat!(
193                            "Parent entity URI this was derived from. ",
194                            "e.g., nap://starwars/character/lukeskywalker/v1"
195                        )
196                    },
197                    "created_at": {
198                        "type": "string",
199                        "format": "date-time",
200                        "description": "ISO 8601 creation timestamp."
201                    }
202                }
203            }
204        }
205    })
206}
207
208/// Returns the JSON Schema for a NAP Commit.
209pub fn commit_schema() -> Value {
210    serde_json::json!({
211        "$schema": "https://json-schema.org/draft/2020-12/schema",
212        "$id": "nap://schema/commit.json",
213        "title": "NAP Commit",
214        "description": concat!(
215            "A NAP commit records a point-in-time snapshot of a manifest ",
216            "plus patch metadata describing what changed."
217        ),
218        "type": "object",
219        "required": ["id", "timestamp", "author", "message", "manifest_hash"],
220        "properties": {
221            "id": {
222                "type": "string",
223                "pattern": "^[a-f0-9]{64}$",
224                "description": "BLAKE3 content-addressed commit identifier."
225            },
226            "parent": {
227                "type": "string",
228                "pattern": "^[a-f0-9]{64}$",
229                "description": "Parent commit hash. null for the initial commit."
230            },
231            "timestamp": {
232                "type": "string",
233                "format": "date-time",
234                "description": "ISO 8601 timestamp of when the commit was created."
235            },
236            "author": {
237                "type": "string",
238                "description": concat!(
239                    "Author identifier ",
240                    "(DID key, email, or key fingerprint)."
241                )
242            },
243            "signature": {
244                "type": "string",
245                "description": concat!(
246                    "Ed25519 signature over the commit hash ",
247                    "(optional in v0)."
248                )
249            },
250            "message": {
251                "type": "string",
252                "description": "Human-readable commit message describing the changes."
253            },
254            "manifest_hash": {
255                "type": "string",
256                "pattern": "^blake3:[a-f0-9]{64}$",
257                "description": concat!(
258                    "BLAKE3 content hash of the resulting manifest ",
259                    "after this commit."
260                )
261            },
262            "changes": {
263                "type": "array",
264                "items": {
265                    "$ref": "#/definitions/Change"
266                },
267                "description": "Patch metadata describing what changed in this commit."
268            }
269        },
270        "definitions": {
271            "Change": {
272                "type": "object",
273                "required": ["path", "operation"],
274                "properties": {
275                    "path": {
276                        "type": "string",
277                        "description": concat!(
278                            "Dot-notation path to the changed field. ",
279                            "e.g., 'properties.homeworld', ",
280                            "'representations.reference_image.hash'"
281                        )
282                    },
283                    "operation": {
284                        "type": "string",
285                        "enum": ["set", "delete", "append", "remove"],
286                        "description": "The kind of change operation."
287                    },
288                    "old_value": {
289                        "type": "string",
290                        "description": "Previous value hash (for verification)."
291                    },
292                    "new_value": {
293                        "type": "string",
294                        "description": "New value hash or literal."
295                    }
296                }
297            }
298        }
299    })
300}
301
302/// Validate a manifest against the manifest schema.
303///
304/// Returns `Ok(())` if valid, or `Err` with a list of human-readable
305/// validation error messages (path + description for each violation).
306pub fn validate_manifest(manifest: &crate::manifest::Manifest) -> Result<(), Vec<String>> {
307    let schema = manifest_schema();
308    let instance = serde_json::to_value(manifest)
309        .map_err(|e| vec![format!("manifest serialization error: {e}")])?;
310    let validator = jsonschema::validator_for(&schema)
311        .map_err(|e| vec![format!("schema compilation error: {e}")])?;
312    let errors: Vec<String> = validator
313        .iter_errors(&instance)
314        .map(|e| format!("{}: {}", e.instance_path, e))
315        .collect();
316    if errors.is_empty() {
317        Ok(())
318    } else {
319        Err(errors)
320    }
321}
322
323/// Validate a commit against the commit schema.
324///
325/// Returns `Ok(())` if valid, or `Err` with a list of human-readable
326/// validation error messages.
327pub fn validate_commit(commit: &crate::commit::Commit) -> Result<(), Vec<String>> {
328    let schema = commit_schema();
329    let instance = serde_json::to_value(commit)
330        .map_err(|e| vec![format!("commit serialization error: {e}")])?;
331    let validator = jsonschema::validator_for(&schema)
332        .map_err(|e| vec![format!("schema compilation error: {e}")])?;
333    let errors: Vec<String> = validator
334        .iter_errors(&instance)
335        .map(|e| format!("{}: {}", e.instance_path, e))
336        .collect();
337    if errors.is_empty() {
338        Ok(())
339    } else {
340        Err(errors)
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_validate_valid_manifest() {
350        use crate::manifest::Manifest;
351        let m = Manifest::new(
352            "starwars",
353            crate::types::EntityType::new("character"),
354            "luke",
355            "Luke Skywalker",
356        );
357        assert!(validate_manifest(&m).is_ok());
358    }
359
360    #[test]
361    fn test_validate_manifest_with_custom_entity_type() {
362        use crate::manifest::Manifest;
363        let m = Manifest::new(
364            "lab",
365            crate::types::EntityType::new("scientific_paper"),
366            "fusion-2024",
367            "Cold Fusion Results",
368        );
369        assert!(validate_manifest(&m).is_ok());
370    }
371
372    #[test]
373    fn test_validate_manifest_rejects_empty_entity_type() {
374        // Build a manifest-like JSON value with an empty entity_type.
375        let json = serde_json::json!({
376            "id": "nap://starwars/character/luke",
377            "name": "Luke",
378            "entity_type": "",
379            "version": 1,
380        });
381        // Validate the raw JSON value directly against the schema
382        let schema = manifest_schema();
383        let validator = jsonschema::validator_for(&schema).unwrap();
384        let errors: Vec<String> = validator
385            .iter_errors(&json)
386            .map(|e| format!("{}: {}", e.instance_path, e))
387            .collect();
388        assert!(
389            !errors.is_empty(),
390            "expected validation errors for empty entity_type"
391        );
392    }
393
394    #[test]
395    fn test_manifest_schema_is_valid_json() {
396        let schema = manifest_schema();
397        // Verify it's an object with required top-level keys
398        assert!(schema.is_object());
399        assert!(schema.get("title").is_some());
400        assert!(schema.get("properties").is_some());
401        assert!(schema.get("definitions").is_some());
402
403        // Verify entity_type is a string type (no enum constraint)
404        let props = schema.get("properties").unwrap();
405        let et = props.get("entity_type").unwrap();
406        assert_eq!(et.get("type").unwrap().as_str(), Some("string"));
407        assert!(et.get("enum").is_none());
408    }
409
410    #[test]
411    fn test_commit_schema_is_valid_json() {
412        let schema = commit_schema();
413        assert!(schema.is_object());
414        assert!(schema.get("title").is_some());
415        assert!(schema.get("properties").is_some());
416        assert!(schema.get("definitions").is_some());
417    }
418
419    #[test]
420    fn test_schemas_serialize_to_valid_json() {
421        let m = manifest_schema();
422        let json = serde_json::to_string(&m).unwrap();
423        // Parse it back — if it fails, it's not valid JSON
424        let parsed: Value = serde_json::from_str(&json).unwrap();
425        assert_eq!(
426            parsed.get("title").unwrap().as_str().unwrap(),
427            "NAP Manifest"
428        );
429
430        let c = commit_schema();
431        let json = serde_json::to_string(&c).unwrap();
432        let parsed: Value = serde_json::from_str(&json).unwrap();
433        assert_eq!(parsed.get("title").unwrap().as_str().unwrap(), "NAP Commit");
434    }
435}