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