1use serde_json::Value;
10
11pub 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
208pub 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
302pub 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
323pub 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 let json = serde_json::json!({
365 "id": "nap://starwars/character/luke",
366 "name": "Luke",
367 "entity_type": "invalid_type",
368 "version": 1,
369 });
370 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 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 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 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}