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