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}$",
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
207pub 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
301pub 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
322pub 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 let json = serde_json::json!({
364 "id": "nap://starwars/character/luke",
365 "name": "Luke",
366 "entity_type": "invalid_type",
367 "version": 1,
368 });
369 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 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 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 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}