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-z0-9_-]+/[a-z]+/[a-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                "enum": ["character", "location", "scene", "prop", "world"],
41                "description": "The kind of narrative entity this manifest describes"
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::Character,
354            "luke",
355            "Luke Skywalker",
356        );
357        assert!(validate_manifest(&m).is_ok());
358    }
359
360    #[test]
361    fn test_validate_invalid_manifest_rejects_bad_entity_type() {
362        // Build a manifest-like JSON value with an invalid entity_type.
363        // We can't use Manifest deserialization because serde rejects the enum.
364        let json = serde_json::json!({
365            "id": "nap://starwars/character/luke",
366            "name": "Luke",
367            "entity_type": "invalid_type",
368            "version": 1,
369        });
370        // Validate the raw JSON value directly against the schema
371        let schema = manifest_schema();
372        let validator = jsonschema::validator_for(&schema).unwrap();
373        let errors: Vec<String> = validator
374            .iter_errors(&json)
375            .map(|e| format!("{}: {}", e.instance_path, e))
376            .collect();
377        assert!(
378            !errors.is_empty(),
379            "expected validation errors for invalid entity_type"
380        );
381        let joined = errors.join(" ");
382        assert!(
383            joined.contains("entity_type"),
384            "expected entity_type error in output, got: {joined}"
385        );
386    }
387
388    #[test]
389    fn test_manifest_schema_is_valid_json() {
390        let schema = manifest_schema();
391        // Verify it's an object with required top-level keys
392        assert!(schema.is_object());
393        assert!(schema.get("title").is_some());
394        assert!(schema.get("properties").is_some());
395        assert!(schema.get("definitions").is_some());
396
397        // Verify entity_type enum
398        let props = schema.get("properties").unwrap();
399        let et = props.get("entity_type").unwrap();
400        let enum_vals = et.get("enum").unwrap();
401        let types: Vec<String> = enum_vals
402            .as_array()
403            .unwrap()
404            .iter()
405            .map(|v| v.as_str().unwrap().to_string())
406            .collect();
407        assert!(types.contains(&"character".to_string()));
408        assert!(types.contains(&"location".to_string()));
409        assert!(types.contains(&"scene".to_string()));
410        assert!(types.contains(&"prop".to_string()));
411        assert!(types.contains(&"world".to_string()));
412    }
413
414    #[test]
415    fn test_commit_schema_is_valid_json() {
416        let schema = commit_schema();
417        assert!(schema.is_object());
418        assert!(schema.get("title").is_some());
419        assert!(schema.get("properties").is_some());
420        assert!(schema.get("definitions").is_some());
421    }
422
423    #[test]
424    fn test_schemas_serialize_to_valid_json() {
425        let m = manifest_schema();
426        let json = serde_json::to_string(&m).unwrap();
427        // Parse it back — if it fails, it's not valid JSON
428        let parsed: Value = serde_json::from_str(&json).unwrap();
429        assert_eq!(
430            parsed.get("title").unwrap().as_str().unwrap(),
431            "NAP Manifest"
432        );
433
434        let c = commit_schema();
435        let json = serde_json::to_string(&c).unwrap();
436        let parsed: Value = serde_json::from_str(&json).unwrap();
437        assert_eq!(parsed.get("title").unwrap().as_str().unwrap(), "NAP Commit");
438    }
439}