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