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}$",
110                "description": concat!(
111                    "Pointer to the latest VCS commit hash. ",
112                    "History lives in the VCS, not the manifest."
113                )
114            },
115            "metadata": {
116                "type": "object",
117                "additionalProperties": true,
118                "description": "Arbitrary extension metadata. Future-proof escape hatch."
119            }
120        },
121        "definitions": {
122            "Representation": {
123                "type": "object",
124                "required": ["hash", "format"],
125                "properties": {
126                    "hash": {
127                        "type": "string",
128                        "pattern": "^sha256:[a-f0-9]{64}$",
129                        "description": concat!(
130                            "SHA-256 content hash of the asset. ",
131                            "Format: sha256:<64-hex-chars>"
132                        )
133                    },
134                    "format": {
135                        "type": "string",
136                        "description": concat!(
137                            "File format. ",
138                            "Examples: png, glb, onnx, wav, mp4, splat"
139                        )
140                    },
141                    "uri": {
142                        "type": "string",
143                        "description": concat!(
144                            "Optional storage URI. ",
145                            "e.g., gs://assets/starwars/luke/ref.png, ",
146                            "s3://bucket/path/to/file.glb"
147                        )
148                    },
149                    "tier": {
150                        "type": "string",
151                        "enum": ["draft", "production", "distribution"],
152                        "description": concat!(
153                            "Quality tier of the representation. ",
154                            "draft = WIP, production = final, distribution = optimized"
155                        )
156                    }
157                }
158            },
159            "Provenance": {
160                "type": "object",
161                "properties": {
162                    "model": {
163                        "type": "string",
164                        "description": concat!(
165                            "AI model used to generate this entity. ",
166                            "e.g., 'midjourney-v6', 'gpt-4o', 'stable-diffusion-3'"
167                        )
168                    },
169                    "prompt_hash": {
170                        "type": "string",
171                        "pattern": "^sha256:[a-f0-9]{64}$",
172                        "description": "SHA-256 content hash of the generation prompt."
173                    },
174                    "seed": {
175                        "type": "string",
176                        "description": "Generation seed for reproducibility."
177                    },
178                    "parameters": {
179                        "type": "object",
180                        "additionalProperties": {
181                            "type": "string"
182                        },
183                        "description": concat!(
184                            "Additional generation parameters. ",
185                            "e.g., stylize, chaos, temperature, cfg_scale"
186                        )
187                    },
188                    "derived_from": {
189                        "type": "string",
190                        "pattern": "^nap://",
191                        "description": concat!(
192                            "Parent entity URI this was derived from. ",
193                            "e.g., nap://starwars/character/lukeskywalker/v1"
194                        )
195                    },
196                    "created_at": {
197                        "type": "string",
198                        "format": "date-time",
199                        "description": "ISO 8601 creation timestamp."
200                    }
201                }
202            }
203        }
204    })
205}
206
207/// Returns the JSON Schema for a NAP Commit.
208pub fn commit_schema() -> Value {
209    serde_json::json!({
210        "$schema": "https://json-schema.org/draft/2020-12/schema",
211        "$id": "nap://schema/commit.json",
212        "title": "NAP Commit",
213        "description": concat!(
214            "A NAP commit records a point-in-time snapshot of a manifest ",
215            "plus patch metadata describing what changed."
216        ),
217        "type": "object",
218        "required": ["id", "timestamp", "author", "message", "manifest_hash"],
219        "properties": {
220            "id": {
221                "type": "string",
222                "pattern": "^[a-f0-9]{64}$",
223                "description": "SHA-256 content-addressed commit identifier."
224            },
225            "parent": {
226                "type": "string",
227                "pattern": "^[a-f0-9]{64}$",
228                "description": "Parent commit hash. null for the initial commit."
229            },
230            "timestamp": {
231                "type": "string",
232                "format": "date-time",
233                "description": "ISO 8601 timestamp of when the commit was created."
234            },
235            "author": {
236                "type": "string",
237                "description": concat!(
238                    "Author identifier ",
239                    "(DID key, email, or key fingerprint)."
240                )
241            },
242            "signature": {
243                "type": "string",
244                "description": concat!(
245                    "Ed25519 signature over the commit hash ",
246                    "(optional in v0)."
247                )
248            },
249            "message": {
250                "type": "string",
251                "description": "Human-readable commit message describing the changes."
252            },
253            "manifest_hash": {
254                "type": "string",
255                "pattern": "^sha256:[a-f0-9]{64}$",
256                "description": concat!(
257                    "SHA-256 hash of the resulting manifest ",
258                    "after this commit."
259                )
260            },
261            "changes": {
262                "type": "array",
263                "items": {
264                    "$ref": "#/definitions/Change"
265                },
266                "description": "Patch metadata describing what changed in this commit."
267            }
268        },
269        "definitions": {
270            "Change": {
271                "type": "object",
272                "required": ["path", "operation"],
273                "properties": {
274                    "path": {
275                        "type": "string",
276                        "description": concat!(
277                            "Dot-notation path to the changed field. ",
278                            "e.g., 'properties.homeworld', ",
279                            "'representations.reference_image.hash'"
280                        )
281                    },
282                    "operation": {
283                        "type": "string",
284                        "enum": ["set", "delete", "append", "remove"],
285                        "description": "The kind of change operation."
286                    },
287                    "old_value": {
288                        "type": "string",
289                        "description": "Previous value hash (for verification)."
290                    },
291                    "new_value": {
292                        "type": "string",
293                        "description": "New value hash or literal."
294                    }
295                }
296            }
297        }
298    })
299}
300
301/// Validate a manifest against the manifest schema.
302///
303/// Returns `Ok(())` if valid, or `Err` with a list of human-readable
304/// validation error messages (path + description for each violation).
305pub fn validate_manifest(manifest: &crate::manifest::Manifest) -> Result<(), Vec<String>> {
306    let schema = manifest_schema();
307    let instance = serde_json::to_value(manifest)
308        .map_err(|e| vec![format!("manifest serialization error: {e}")])?;
309    let validator = jsonschema::validator_for(&schema)
310        .map_err(|e| vec![format!("schema compilation error: {e}")])?;
311    let errors: Vec<String> = validator
312        .iter_errors(&instance)
313        .map(|e| format!("{}: {}", e.instance_path, e))
314        .collect();
315    if errors.is_empty() {
316        Ok(())
317    } else {
318        Err(errors)
319    }
320}
321
322/// Validate a commit against the commit schema.
323///
324/// Returns `Ok(())` if valid, or `Err` with a list of human-readable
325/// validation error messages.
326pub fn validate_commit(commit: &crate::commit::Commit) -> Result<(), Vec<String>> {
327    let schema = commit_schema();
328    let instance = serde_json::to_value(commit)
329        .map_err(|e| vec![format!("commit serialization error: {e}")])?;
330    let validator = jsonschema::validator_for(&schema)
331        .map_err(|e| vec![format!("schema compilation error: {e}")])?;
332    let errors: Vec<String> = validator
333        .iter_errors(&instance)
334        .map(|e| format!("{}: {}", e.instance_path, e))
335        .collect();
336    if errors.is_empty() {
337        Ok(())
338    } else {
339        Err(errors)
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_validate_valid_manifest() {
349        use crate::manifest::Manifest;
350        let m = Manifest::new(
351            "starwars",
352            crate::types::EntityType::Character,
353            "luke",
354            "Luke Skywalker",
355        );
356        assert!(validate_manifest(&m).is_ok());
357    }
358
359    #[test]
360    fn test_validate_invalid_manifest_rejects_bad_entity_type() {
361        // Build a manifest-like JSON value with an invalid entity_type.
362        // We can't use Manifest deserialization because serde rejects the enum.
363        let json = serde_json::json!({
364            "id": "nap://starwars/character/luke",
365            "name": "Luke",
366            "entity_type": "invalid_type",
367            "version": 1,
368        });
369        // Validate the raw JSON value directly against the schema
370        let schema = manifest_schema();
371        let validator = jsonschema::validator_for(&schema).unwrap();
372        let errors: Vec<String> = validator
373            .iter_errors(&json)
374            .map(|e| format!("{}: {}", e.instance_path, e))
375            .collect();
376        assert!(
377            !errors.is_empty(),
378            "expected validation errors for invalid entity_type"
379        );
380        let joined = errors.join(" ");
381        assert!(
382            joined.contains("entity_type"),
383            "expected entity_type error in output, got: {joined}"
384        );
385    }
386
387    #[test]
388    fn test_manifest_schema_is_valid_json() {
389        let schema = manifest_schema();
390        // Verify it's an object with required top-level keys
391        assert!(schema.is_object());
392        assert!(schema.get("title").is_some());
393        assert!(schema.get("properties").is_some());
394        assert!(schema.get("definitions").is_some());
395
396        // Verify entity_type enum
397        let props = schema.get("properties").unwrap();
398        let et = props.get("entity_type").unwrap();
399        let enum_vals = et.get("enum").unwrap();
400        let types: Vec<String> = enum_vals
401            .as_array()
402            .unwrap()
403            .iter()
404            .map(|v| v.as_str().unwrap().to_string())
405            .collect();
406        assert!(types.contains(&"character".to_string()));
407        assert!(types.contains(&"location".to_string()));
408        assert!(types.contains(&"scene".to_string()));
409        assert!(types.contains(&"prop".to_string()));
410        assert!(types.contains(&"world".to_string()));
411    }
412
413    #[test]
414    fn test_commit_schema_is_valid_json() {
415        let schema = commit_schema();
416        assert!(schema.is_object());
417        assert!(schema.get("title").is_some());
418        assert!(schema.get("properties").is_some());
419        assert!(schema.get("definitions").is_some());
420    }
421
422    #[test]
423    fn test_schemas_serialize_to_valid_json() {
424        let m = manifest_schema();
425        let json = serde_json::to_string(&m).unwrap();
426        // Parse it back — if it fails, it's not valid JSON
427        let parsed: Value = serde_json::from_str(&json).unwrap();
428        assert_eq!(
429            parsed.get("title").unwrap().as_str().unwrap(),
430            "NAP Manifest"
431        );
432
433        let c = commit_schema();
434        let json = serde_json::to_string(&c).unwrap();
435        let parsed: Value = serde_json::from_str(&json).unwrap();
436        assert_eq!(parsed.get("title").unwrap().as_str().unwrap(), "NAP Commit");
437    }
438}