1use serde_json::Value;
31use std::fmt;
32
33const REGISTRY: &[(&str, &str)] = &[
37 (
38 "memory.write.v1",
39 include_str!("schemas/memory.write.v1.json"),
40 ),
41 (
42 "memory.read.v1",
43 include_str!("schemas/memory.read.v1.json"),
44 ),
45 ("boundary.v1", include_str!("schemas/boundary.v1.json")),
46 ("agent_card.v1", include_str!("schemas/agent_card.v1.json")),
47 (
48 "agent_card_revocation.v1",
49 include_str!("schemas/agent_card_revocation.v1.json"),
50 ),
51 ("session.v1", include_str!("schemas/session.v1.json")),
52 ("agent_cert.v1", include_str!("schemas/agent_cert.v1.json")),
53 ("profile.v1", include_str!("schemas/profile.v1.json")),
54];
55
56pub fn schema_json(suffix: &str) -> Option<&'static str> {
59 REGISTRY.iter().find(|(k, _)| *k == suffix).map(|(_, s)| *s)
60}
61
62pub fn registered_suffixes() -> Vec<&'static str> {
64 REGISTRY.iter().map(|(k, _)| *k).collect()
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum PredicateError {
70 MissingField { suffix: String, field: String },
72 TypeMismatch {
74 suffix: String,
75 field: String,
76 expected: String,
77 },
78 NotAnObject { suffix: String },
80 NotInEnum {
84 suffix: String,
85 field: String,
86 allowed: String,
87 },
88 SchemaParse { suffix: String, detail: String },
90}
91
92impl fmt::Display for PredicateError {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 match self {
95 PredicateError::MissingField { suffix, field } => {
96 write!(f, "{suffix}: missing required field `{field}`")
97 }
98 PredicateError::TypeMismatch {
99 suffix,
100 field,
101 expected,
102 } => write!(
103 f,
104 "{suffix}: field `{field}` has the wrong type (expected {expected})"
105 ),
106 PredicateError::NotAnObject { suffix } => {
107 write!(f, "{suffix}: payload must be a JSON object")
108 }
109 PredicateError::NotInEnum {
110 suffix,
111 field,
112 allowed,
113 } => write!(
114 f,
115 "{suffix}: field `{field}` has a value outside its allowed set ({allowed})"
116 ),
117 PredicateError::SchemaParse { suffix, detail } => {
118 write!(f, "{suffix}: registered schema is invalid JSON: {detail}")
119 }
120 }
121 }
122}
123
124impl std::error::Error for PredicateError {}
125
126pub fn validate(suffix: &str, payload: Option<&Value>) -> Result<(), PredicateError> {
135 let Some(schema_str) = schema_json(suffix) else {
136 return Ok(());
137 };
138 let schema: Value =
139 serde_json::from_str(schema_str).map_err(|e| PredicateError::SchemaParse {
140 suffix: suffix.to_string(),
141 detail: e.to_string(),
142 })?;
143
144 let empty = Value::Object(serde_json::Map::new());
147 let value = payload.unwrap_or(&empty);
148 let map = value
149 .as_object()
150 .ok_or_else(|| PredicateError::NotAnObject {
151 suffix: suffix.to_string(),
152 })?;
153
154 if let Some(required) = schema.get("required").and_then(Value::as_array) {
155 for entry in required {
156 if let Some(name) = entry.as_str() {
157 if !map.contains_key(name) {
158 return Err(PredicateError::MissingField {
159 suffix: suffix.to_string(),
160 field: name.to_string(),
161 });
162 }
163 }
164 }
165 }
166
167 if let Some(props) = schema.get("properties").and_then(Value::as_object) {
168 for (field, subschema) in props {
169 let Some(actual) = map.get(field) else {
170 continue; };
172
173 if let Some(type_decl) = subschema.get("type") {
175 if !type_matches(actual, type_decl) {
176 return Err(PredicateError::TypeMismatch {
177 suffix: suffix.to_string(),
178 field: field.to_string(),
179 expected: type_decl.to_string(),
180 });
181 }
182 }
183
184 if let Some(allowed) = subschema.get("enum").and_then(Value::as_array) {
190 if !allowed.iter().any(|a| a == actual) {
191 return Err(PredicateError::NotInEnum {
192 suffix: suffix.to_string(),
193 field: field.to_string(),
194 allowed: Value::Array(allowed.clone()).to_string(),
195 });
196 }
197 }
198 if let Some(constant) = subschema.get("const") {
199 if actual != constant {
200 return Err(PredicateError::NotInEnum {
201 suffix: suffix.to_string(),
202 field: field.to_string(),
203 allowed: constant.to_string(),
204 });
205 }
206 }
207 }
208 }
209
210 Ok(())
211}
212
213fn type_matches(value: &Value, type_decl: &Value) -> bool {
216 match type_decl {
217 Value::String(t) => json_is(value, t),
218 Value::Array(types) => types
219 .iter()
220 .any(|t| t.as_str().is_some_and(|t| json_is(value, t))),
221 _ => true,
224 }
225}
226
227fn json_is(value: &Value, ty: &str) -> bool {
230 match ty {
231 "string" => value.is_string(),
232 "integer" => value.is_i64() || value.is_u64(),
233 "number" => value.is_number(),
234 "boolean" => value.is_boolean(),
235 "object" => value.is_object(),
236 "array" => value.is_array(),
237 "null" => value.is_null(),
238 _ => true,
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use serde_json::json;
247
248 #[test]
249 fn registry_lists_the_three_seed_predicates() {
250 let suffixes = registered_suffixes();
251 assert!(suffixes.contains(&"memory.write.v1"));
252 assert!(suffixes.contains(&"memory.read.v1"));
253 assert!(suffixes.contains(&"boundary.v1"));
254 assert!(suffixes.contains(&"agent_card.v1"));
255 assert!(schema_json("memory.write.v1").is_some());
256 assert!(schema_json("nope.v1").is_none());
257 }
258
259 #[test]
260 fn embedded_schemas_parse() {
261 for s in registered_suffixes() {
262 let raw = schema_json(s).unwrap();
263 serde_json::from_str::<Value>(raw).expect("embedded schema must be valid JSON");
264 }
265 }
266
267 #[test]
268 fn unregistered_suffix_is_backward_compatible() {
269 assert!(validate("custom.kind.v1", None).is_ok());
271 assert!(validate("custom.kind.v1", Some(&json!({"anything": 1}))).is_ok());
272 }
273
274 #[test]
275 fn agent_cert_valid_passes() {
276 let payload = json!({
277 "agent": "agent://deployer",
278 "subject_key_id": "key_abc123",
279 "subject_public_key": "vEQfSDqVCz4rtqbu5iuhpFuYrah6QALUSCGJYdOKeCY",
280 "issuer": "ship://ship_b49ff5f291a279c7",
281 "issued_at": "2026-07-06T12:00:00Z",
282 "valid_until": "2027-07-06T12:00:00Z",
283 "model": "claude-fable-5",
284 "description": null
285 });
286 assert!(validate("agent_cert.v1", Some(&payload)).is_ok());
287 }
288
289 #[test]
290 fn agent_cert_missing_subject_key_fails_closed() {
291 let payload = json!({
292 "agent": "agent://deployer",
293 "subject_key_id": "key_abc123",
294 "issuer": "ship://ship_x",
295 "issued_at": "2026-07-06T12:00:00Z",
296 "valid_until": "2027-07-06T12:00:00Z"
297 }); let err = validate("agent_cert.v1", Some(&payload)).unwrap_err();
299 assert_eq!(
300 err,
301 PredicateError::MissingField {
302 suffix: "agent_cert.v1".into(),
303 field: "subject_public_key".into()
304 }
305 );
306 }
307
308 #[test]
309 fn session_record_valid_passes() {
310 let payload = json!({
311 "session_id": "ssn_abc123",
312 "actor": "agent://hermes",
313 "headline": "Fixed keystore hostname-drift bug",
314 "outcome": "completed",
315 "started_at": "2026-07-06T14:00:00Z",
316 "closed_at": "2026-07-06T15:30:00Z",
317 "duration_ms": 5400000,
318 "harness": "claude-code",
319 "attestation_class": "runtime",
320 "action_count": 212,
321 "approval_count": 2,
322 "handoff_count": 0,
323 "event_count": 340,
324 "tools_exercised": ["Bash(git:*)", "Edit(*)"],
325 "receipt_digest": "sha256:deadbeef",
326 "receipt_merkle_root": "sha256:cafebabe",
327 "report_url": null
328 });
329 assert!(validate("session.v1", Some(&payload)).is_ok());
330 }
331
332 #[test]
333 fn session_record_out_of_enum_class_fails_closed() {
334 let payload = json!({
338 "session_id": "ssn_abc123",
339 "actor": "agent://hermes",
340 "outcome": "completed",
341 "started_at": "2026-07-06T14:00:00Z",
342 "closed_at": "2026-07-06T15:30:00Z",
343 "attestation_class": "super-trusted",
344 "receipt_digest": "sha256:deadbeef"
345 });
346 let err = validate("session.v1", Some(&payload)).unwrap_err();
347 assert!(
348 matches!(err, PredicateError::NotInEnum { ref field, .. } if field == "attestation_class"),
349 "expected NotInEnum for attestation_class, got {err:?}"
350 );
351 }
352
353 #[test]
354 fn session_record_out_of_enum_outcome_fails_closed() {
355 let payload = json!({
357 "session_id": "ssn_abc123",
358 "actor": "agent://hermes",
359 "outcome": "totally-shipped",
360 "started_at": "2026-07-06T14:00:00Z",
361 "closed_at": "2026-07-06T15:30:00Z",
362 "attestation_class": "self",
363 "receipt_digest": "sha256:deadbeef"
364 });
365 assert!(matches!(
366 validate("session.v1", Some(&payload)).unwrap_err(),
367 PredicateError::NotInEnum { .. }
368 ));
369 }
370
371 #[test]
372 fn session_record_missing_required_fails_closed() {
373 let payload = json!({
374 "session_id": "ssn_abc123",
375 "actor": "agent://hermes",
376 "outcome": "completed",
377 "started_at": "2026-07-06T14:00:00Z",
378 "closed_at": "2026-07-06T15:30:00Z",
379 "receipt_digest": "sha256:deadbeef"
380 }); let err = validate("session.v1", Some(&payload)).unwrap_err();
382 assert_eq!(
383 err,
384 PredicateError::MissingField {
385 suffix: "session.v1".into(),
386 field: "attestation_class".into()
387 }
388 );
389 }
390
391 #[test]
392 fn session_record_wrong_type_fails_closed() {
393 let payload = json!({
394 "session_id": "ssn_abc123",
395 "actor": "agent://hermes",
396 "outcome": "completed",
397 "started_at": "2026-07-06T14:00:00Z",
398 "closed_at": "2026-07-06T15:30:00Z",
399 "attestation_class": "runtime",
400 "receipt_digest": "sha256:deadbeef",
401 "tools_exercised": "Bash(git:*)"
402 }); let err = validate("session.v1", Some(&payload)).unwrap_err();
404 assert!(matches!(err, PredicateError::TypeMismatch { .. }));
405 }
406
407 #[test]
408 fn memory_write_valid_passes() {
409 let payload = json!({
410 "memory_id": "mem_abc",
411 "content_hash": "sha256:deadbeef",
412 "memory_type": "episodic",
413 "scope": "tenant://acme",
414 "activegraph_run_id": "run_1",
415 "supersedes": null
416 });
417 assert!(validate("memory.write.v1", Some(&payload)).is_ok());
418 }
419
420 #[test]
421 fn memory_write_missing_required_fails_closed() {
422 let payload = json!({
423 "memory_id": "mem_abc",
424 "memory_type": "episodic",
425 "scope": "tenant://acme"
426 }); let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
428 assert_eq!(
429 err,
430 PredicateError::MissingField {
431 suffix: "memory.write.v1".into(),
432 field: "content_hash".into()
433 }
434 );
435 }
436
437 #[test]
438 fn memory_write_wrong_type_fails() {
439 let payload = json!({
440 "memory_id": "mem_abc",
441 "content_hash": 12345, "memory_type": "episodic",
443 "scope": "tenant://acme"
444 });
445 let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
446 assert!(
447 matches!(err, PredicateError::TypeMismatch { field, .. } if field == "content_hash")
448 );
449 }
450
451 #[test]
452 fn memory_write_nullable_supersedes_accepts_string_and_null() {
453 let base = |sup: Value| {
454 json!({
455 "memory_id": "m", "content_hash": "h", "memory_type": "t", "scope": "s",
456 "supersedes": sup
457 })
458 };
459 assert!(validate("memory.write.v1", Some(&base(json!("mem_old")))).is_ok());
460 assert!(validate("memory.write.v1", Some(&base(Value::Null))).is_ok());
461 assert!(validate("memory.write.v1", Some(&base(json!(7)))).is_err());
463 }
464
465 #[test]
466 fn registered_predicate_requires_a_payload() {
467 let err = validate("memory.write.v1", None).unwrap_err();
468 assert!(matches!(err, PredicateError::MissingField { .. }));
469 }
470
471 #[test]
472 fn memory_read_valid_and_integer_enforced() {
473 let ok = json!({
474 "zmem_receipt_id": "act_1",
475 "trace_sha256": "abcd",
476 "query_hash": "qh",
477 "retrieval_mode": "semantic",
478 "memories_returned": 3
479 });
480 assert!(validate("memory.read.v1", Some(&ok)).is_ok());
481
482 let bad = json!({
483 "zmem_receipt_id": "act_1",
484 "trace_sha256": "abcd",
485 "query_hash": "qh",
486 "retrieval_mode": "semantic",
487 "memories_returned": "three" });
489 assert!(matches!(
490 validate("memory.read.v1", Some(&bad)).unwrap_err(),
491 PredicateError::TypeMismatch { field, .. } if field == "memories_returned"
492 ));
493 }
494
495 #[test]
496 fn memory_read_missing_required_fails() {
497 let payload = json!({
498 "zmem_receipt_id": "act_1",
499 "trace_sha256": "abcd",
500 "retrieval_mode": "semantic",
501 "memories_returned": 3
502 }); assert!(matches!(
504 validate("memory.read.v1", Some(&payload)).unwrap_err(),
505 PredicateError::MissingField { field, .. } if field == "query_hash"
506 ));
507 }
508
509 #[test]
510 fn boundary_structural_required_fields_enforced() {
511 let valid = json!({
515 "schema": "treeship.boundary.v1",
516 "subject_ref": "art_aabbccdd11223344",
517 "actor": {"uri": "agent://codex", "keyid": "key_aaaa1111"},
518 "checker": {"uri": "human://alice", "keyid": "key_bbbb2222"},
519 "decision": "allow",
520 "policy": {"digest": "sha256:p"},
521 "diet_root": "sha256:r",
522 "diet": [{"type": "memory_bundle", "digest": "sha256:d"}],
523 "committed_at": {"anchor": "merkle://zmem/checkpoint#4821", "ts": "2026-06-06T00:00:00Z"}
524 });
525 assert!(validate("boundary.v1", Some(&valid)).is_ok());
526
527 let mut wrong = valid.clone();
529 wrong.as_object_mut().unwrap()["committed_at"] = json!("not-an-object");
530 assert!(matches!(
531 validate("boundary.v1", Some(&wrong)).unwrap_err(),
532 PredicateError::TypeMismatch { field, .. } if field == "committed_at"
533 ));
534
535 let mut missing = valid.clone();
536 missing.as_object_mut().unwrap().remove("decision");
537 assert!(matches!(
538 validate("boundary.v1", Some(&missing)).unwrap_err(),
539 PredicateError::MissingField { field, .. } if field == "decision"
540 ));
541 }
542
543 #[test]
544 fn agent_card_valid_passes() {
545 let card = json!({
546 "schema": "agent_card.v1",
547 "agent": "agent://deployer",
548 "keyid": "key_9f8e7d6c",
549 "owner": "human://alice",
550 "version": "1.2.0",
551 "capabilities": {
552 "tools": ["file.read", "file.write", "db.*"],
553 "models": ["claude-sonnet-4"],
554 "can_delegate": true
555 },
556 "evidence_anchor": { "receipt_count": 1247, "merkle_root": "mroot_a0be" },
557 "supersedes": null
558 });
559 assert!(validate("agent_card.v1", Some(&card)).is_ok());
560 }
561
562 #[test]
563 fn agent_card_missing_keyid_fails_closed() {
564 let card = json!({
566 "schema": "agent_card.v1",
567 "agent": "agent://deployer",
568 "version": "1.0.0",
569 "capabilities": { "tools": ["file.read"] }
570 });
571 assert!(matches!(
572 validate("agent_card.v1", Some(&card)).unwrap_err(),
573 PredicateError::MissingField { field, .. } if field == "keyid"
574 ));
575 }
576
577 #[test]
578 fn agent_card_capabilities_must_be_an_object() {
579 let card = json!({
580 "schema": "agent_card.v1",
581 "agent": "agent://deployer",
582 "keyid": "key_1",
583 "version": "1.0.0",
584 "capabilities": ["file.read"] });
586 assert!(matches!(
587 validate("agent_card.v1", Some(&card)).unwrap_err(),
588 PredicateError::TypeMismatch { field, .. } if field == "capabilities"
589 ));
590 }
591
592 #[test]
593 fn agent_card_revocation_valid_passes() {
594 let rev = json!({
595 "schema": "agent_card_revocation.v1",
596 "card": "art_deadbeefdeadbeef",
597 "keyid": "key_1",
598 "reason": "key-rotation",
599 "revoked_at": "2026-06-23T00:00:00Z"
600 });
601 assert!(validate("agent_card_revocation.v1", Some(&rev)).is_ok());
602 }
603
604 #[test]
605 fn agent_card_revocation_requires_card_id() {
606 let rev = json!({
607 "schema": "agent_card_revocation.v1",
608 "revoked_at": "2026-06-23T00:00:00Z"
609 });
611 assert!(matches!(
612 validate("agent_card_revocation.v1", Some(&rev)).unwrap_err(),
613 PredicateError::MissingField { field, .. } if field == "card"
614 ));
615 }
616}