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