Skip to main content

treeship_core/predicates/
mod.rs

1//! Predicate registry: typed, schema-validated payloads for Treeship receipts.
2//!
3//! A Treeship receipt (`treeship/receipt/v1`) carries a free-form `kind` and an
4//! opaque JSON `payload`. The predicate registry makes specific `kind` values
5//! *typed*: each registered suffix is bound to a JSON Schema, and at attest time
6//! the payload is validated against that schema before the receipt is signed
7//! ([`validate`]). A registered predicate that fails validation is rejected, so
8//! a downstream verifier can rely on the shape, not just the signature.
9//!
10//! This is purely additive and backward compatible. A `kind` with no registered
11//! schema attests exactly as before (sign-on-submit); existing artifact types,
12//! signing logic, and chain structure are untouched.
13//!
14//! ## Validation depth, deliberately
15//!
16//! Core does a small, dependency-free **structural** check: every `required`
17//! field is present and each present field whose schema declares a primitive
18//! `type` matches that type (including union types like `["string","null"]`).
19//! That is the *complete* contract for the flat `memory.write.v1` /
20//! `memory.read.v1` predicates, which use only `required` + `type`.
21//!
22//! `boundary.v1` is a richer JSON Schema (`const`/`enum`/`pattern`/`$ref`). Core
23//! enforces its required-field/type structure and ships the full schema as the
24//! canonical published artifact (`schema_json("boundary.v1")`); the complete
25//! constraint set is delegated to that schema for external validators. We keep
26//! the core validator dependency-free on purpose: pulling a full JSON-Schema
27//! engine (and its transitive surface) into the security-critical signing crate,
28//! and into the WASM verifier build, is not worth it for an attest-time check.
29
30use serde_json::Value;
31use std::fmt;
32
33/// Registered predicate suffixes and their JSON Schemas. The suffix is the
34/// receipt `kind`. Schemas are embedded at compile time so there is no runtime
35/// file IO (keeps the WASM build clean).
36const 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    (
46        "memory.quarantine-check.v1",
47        include_str!("schemas/memory.quarantine-check.v1.json"),
48    ),
49    ("blocked.v1", include_str!("schemas/blocked.v1.json")),
50    (
51        "reason.authorization.v1",
52        include_str!("schemas/reason.authorization.v1.json"),
53    ),
54    ("boundary.v1", include_str!("schemas/boundary.v1.json")),
55    ("agent_card.v1", include_str!("schemas/agent_card.v1.json")),
56    (
57        "agent_card_revocation.v1",
58        include_str!("schemas/agent_card_revocation.v1.json"),
59    ),
60    (
61        "grant_revocation.v1",
62        include_str!("schemas/grant_revocation.v1.json"),
63    ),
64    ("session.v1", include_str!("schemas/session.v1.json")),
65    ("agent_cert.v1", include_str!("schemas/agent_cert.v1.json")),
66    ("profile.v1", include_str!("schemas/profile.v1.json")),
67];
68
69/// Returns the raw JSON Schema text for a registered predicate suffix, if any.
70/// This is the canonical published schema for the predicate.
71pub fn schema_json(suffix: &str) -> Option<&'static str> {
72    REGISTRY.iter().find(|(k, _)| *k == suffix).map(|(_, s)| *s)
73}
74
75/// Every registered predicate suffix.
76pub fn registered_suffixes() -> Vec<&'static str> {
77    REGISTRY.iter().map(|(k, _)| *k).collect()
78}
79
80/// A payload that does not conform to its predicate schema.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum PredicateError {
83    /// A `required` field was absent from the payload.
84    MissingField { suffix: String, field: String },
85    /// A present field did not match its declared type.
86    TypeMismatch {
87        suffix: String,
88        field: String,
89        expected: String,
90    },
91    /// The payload was not a JSON object (registered predicates require one).
92    NotAnObject { suffix: String },
93    /// A present field's value was not among the schema's `enum` (or did not
94    /// equal its `const`). This is what stops a self-declared field from
95    /// carrying an out-of-vocabulary value (AUD-06).
96    NotInEnum {
97        suffix: String,
98        field: String,
99        allowed: String,
100    },
101    /// The embedded schema itself failed to parse (a build-time bug).
102    SchemaParse { suffix: String, detail: String },
103}
104
105impl fmt::Display for PredicateError {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            PredicateError::MissingField { suffix, field } => {
109                write!(f, "{suffix}: missing required field `{field}`")
110            }
111            PredicateError::TypeMismatch {
112                suffix,
113                field,
114                expected,
115            } => write!(
116                f,
117                "{suffix}: field `{field}` has the wrong type (expected {expected})"
118            ),
119            PredicateError::NotAnObject { suffix } => {
120                write!(f, "{suffix}: payload must be a JSON object")
121            }
122            PredicateError::NotInEnum {
123                suffix,
124                field,
125                allowed,
126            } => write!(
127                f,
128                "{suffix}: field `{field}` has a value outside its allowed set ({allowed})"
129            ),
130            PredicateError::SchemaParse { suffix, detail } => {
131                write!(f, "{suffix}: registered schema is invalid JSON: {detail}")
132            }
133        }
134    }
135}
136
137impl std::error::Error for PredicateError {}
138
139/// Validate a receipt payload against the registered schema for `suffix`.
140///
141/// - If `suffix` is **not** registered, returns `Ok(())` (backward compatible:
142///   the receipt attests sign-on-submit, exactly as before).
143/// - If `suffix` **is** registered, the payload must be a JSON object that
144///   carries every `required` field and whose present fields match their
145///   declared primitive types. A missing payload is treated as the empty object
146///   and therefore fails any predicate that has required fields.
147pub fn validate(suffix: &str, payload: Option<&Value>) -> Result<(), PredicateError> {
148    let Some(schema_str) = schema_json(suffix) else {
149        return Ok(());
150    };
151    let schema: Value =
152        serde_json::from_str(schema_str).map_err(|e| PredicateError::SchemaParse {
153            suffix: suffix.to_string(),
154            detail: e.to_string(),
155        })?;
156
157    // A registered predicate requires a JSON object. A missing payload is the
158    // empty object, so any predicate with required fields fails closed here.
159    let empty = Value::Object(serde_json::Map::new());
160    let value = payload.unwrap_or(&empty);
161    let map = value
162        .as_object()
163        .ok_or_else(|| PredicateError::NotAnObject {
164            suffix: suffix.to_string(),
165        })?;
166
167    if let Some(required) = schema.get("required").and_then(Value::as_array) {
168        for entry in required {
169            if let Some(name) = entry.as_str() {
170                if !map.contains_key(name) {
171                    return Err(PredicateError::MissingField {
172                        suffix: suffix.to_string(),
173                        field: name.to_string(),
174                    });
175                }
176            }
177        }
178    }
179
180    if let Some(props) = schema.get("properties").and_then(Value::as_object) {
181        for (field, subschema) in props {
182            let Some(actual) = map.get(field) else {
183                continue; // optional-and-absent; `required` already enforced presence
184            };
185
186            // Primitive type, when declared.
187            if let Some(type_decl) = subschema.get("type") {
188                if !type_matches(actual, type_decl) {
189                    return Err(PredicateError::TypeMismatch {
190                        suffix: suffix.to_string(),
191                        field: field.to_string(),
192                        expected: type_decl.to_string(),
193                    });
194                }
195            }
196
197            // AUD-06: enforce `enum` and `const`, independently of whether a
198            // `type` is also declared. Before this, a field with a declared
199            // enum (e.g. session.v1 `attestation_class`) passed on type alone,
200            // so an out-of-vocabulary value slipped through. A missing type is
201            // no longer a free pass either.
202            if let Some(allowed) = subschema.get("enum").and_then(Value::as_array) {
203                if !allowed.iter().any(|a| a == actual) {
204                    return Err(PredicateError::NotInEnum {
205                        suffix: suffix.to_string(),
206                        field: field.to_string(),
207                        allowed: Value::Array(allowed.clone()).to_string(),
208                    });
209                }
210            }
211            if let Some(constant) = subschema.get("const") {
212                if actual != constant {
213                    return Err(PredicateError::NotInEnum {
214                        suffix: suffix.to_string(),
215                        field: field.to_string(),
216                        allowed: constant.to_string(),
217                    });
218                }
219            }
220        }
221    }
222
223    Ok(())
224}
225
226/// Does `value` satisfy a JSON Schema `type` declaration (a string, or an array
227/// of strings for a union)?
228fn type_matches(value: &Value, type_decl: &Value) -> bool {
229    match type_decl {
230        Value::String(t) => json_is(value, t),
231        Value::Array(types) => types
232            .iter()
233            .any(|t| t.as_str().is_some_and(|t| json_is(value, t))),
234        // A type declaration we don't recognize is not structurally enforced
235        // here; the canonical schema is the full contract.
236        _ => true,
237    }
238}
239
240/// Map a JSON Schema primitive type name onto a `serde_json::Value` shape.
241/// `integer` requires a non-fractional number.
242fn json_is(value: &Value, ty: &str) -> bool {
243    match ty {
244        "string" => value.is_string(),
245        "integer" => value.is_i64() || value.is_u64(),
246        "number" => value.is_number(),
247        "boolean" => value.is_boolean(),
248        "object" => value.is_object(),
249        "array" => value.is_array(),
250        "null" => value.is_null(),
251        // Unknown type keyword: not enforced structurally.
252        _ => true,
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use serde_json::json;
260
261    #[test]
262    fn registry_lists_the_three_seed_predicates() {
263        let suffixes = registered_suffixes();
264        assert!(suffixes.contains(&"memory.write.v1"));
265        assert!(suffixes.contains(&"memory.read.v1"));
266        assert!(suffixes.contains(&"boundary.v1"));
267        assert!(suffixes.contains(&"agent_card.v1"));
268        assert!(schema_json("memory.write.v1").is_some());
269        assert!(schema_json("nope.v1").is_none());
270    }
271
272    #[test]
273    fn embedded_schemas_parse() {
274        for s in registered_suffixes() {
275            let raw = schema_json(s).unwrap();
276            serde_json::from_str::<Value>(raw).expect("embedded schema must be valid JSON");
277        }
278    }
279
280    #[test]
281    fn quarantine_check_valid_passes() {
282        let payload = json!({
283            "action_id": "aac_1f2e3d4c",
284            "provider": "system://zmem",
285            "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
286            "decision_seq": 1042,
287            "clean": true,
288            "quarantined_triggers": [],
289            "checked_at": "2026-07-17T19:00:00Z"
290        });
291        assert!(validate("memory.quarantine-check.v1", Some(&payload)).is_ok());
292    }
293
294    #[test]
295    fn quarantine_check_missing_verdict_fails_closed() {
296        let payload = json!({
297            "action_id": "aac_1f2e3d4c",
298            "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
299            "decision_seq": 1042
300        }); // `clean` missing — the field the whole gate hangs on
301        let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
302        assert_eq!(
303            err,
304            PredicateError::MissingField {
305                suffix: "memory.quarantine-check.v1".into(),
306                field: "clean".into()
307            }
308        );
309    }
310
311    #[test]
312    fn quarantine_check_stringly_typed_verdict_fails_closed() {
313        // A "true" string must not pass for a boolean verdict — a lenient
314        // parse here would let a provider bug (or an attacker) launder an
315        // ambiguous verdict into a clean one.
316        let payload = json!({
317            "action_id": "aac_1f2e3d4c",
318            "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
319            "decision_seq": 1042,
320            "clean": "true"
321        });
322        let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
323        assert_eq!(
324            err,
325            PredicateError::TypeMismatch {
326                suffix: "memory.quarantine-check.v1".into(),
327                field: "clean".into(),
328                expected: "\"boolean\"".into()
329            }
330        );
331    }
332
333    #[test]
334    fn quarantine_check_non_integer_seq_fails_closed() {
335        // decision_seq binds the verdict to a ledger state; a non-integer
336        // seq breaks chain-root rederivation for Class-2 verifiers.
337        let payload = json!({
338            "action_id": "aac_1f2e3d4c",
339            "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
340            "decision_seq": "1042",
341            "clean": true
342        });
343        let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
344        assert_eq!(
345            err,
346            PredicateError::TypeMismatch {
347                suffix: "memory.quarantine-check.v1".into(),
348                field: "decision_seq".into(),
349                expected: "\"integer\"".into()
350            }
351        );
352    }
353
354    #[test]
355    fn reason_authorization_valid_shape_passes() {
356        // Hand-authored from the public zerker.reason.authorization.v1 schema.
357        // This is a structural predicate test, not a cryptographic test vector.
358        let payload = json!({
359            "schema": "zerker.reason.authorization.v1",
360            "status": "authorized",
361            "request_digest": format!("sha256:{}", "1".repeat(64)),
362            "mission": {
363                "id": "mission_release_140",
364                "digest": format!("sha256:{}", "2".repeat(64))
365            },
366            "action": {
367                "id": "action_deploy_140",
368                "digest": format!("sha256:{}", "3".repeat(64)),
369                "tool": "deploy_release",
370                "arguments": {"environment": "production"},
371                "effects": []
372            },
373            "reasoning": {
374                "schema": "zerker.reason.result.v2",
375                "status": "proved"
376            },
377            "issues": []
378        });
379        assert!(validate("reason.authorization.v1", Some(&payload)).is_ok());
380    }
381
382    #[test]
383    fn reason_authorization_missing_request_digest_fails_closed() {
384        let payload = json!({
385            "schema": "zerker.reason.authorization.v1",
386            "status": "authorized",
387            "mission": {},
388            "action": {},
389            "reasoning": {},
390            "issues": []
391        });
392        let err = validate("reason.authorization.v1", Some(&payload)).unwrap_err();
393        assert_eq!(
394            err,
395            PredicateError::MissingField {
396                suffix: "reason.authorization.v1".into(),
397                field: "request_digest".into()
398            }
399        );
400    }
401
402    #[test]
403    fn reason_authorization_out_of_vocabulary_status_fails_closed() {
404        let payload = json!({
405            "schema": "zerker.reason.authorization.v1",
406            "status": "probably_safe",
407            "request_digest": format!("sha256:{}", "1".repeat(64)),
408            "mission": {},
409            "action": {},
410            "reasoning": {},
411            "issues": []
412        });
413        let err = validate("reason.authorization.v1", Some(&payload)).unwrap_err();
414        assert!(
415            matches!(err, PredicateError::NotInEnum { ref field, .. } if field == "status"),
416            "expected NotInEnum on status, got {err:?}"
417        );
418    }
419
420    #[test]
421    fn reason_authorization_wrong_action_shape_fails_closed() {
422        let payload = json!({
423            "schema": "zerker.reason.authorization.v1",
424            "status": "denied",
425            "request_digest": format!("sha256:{}", "1".repeat(64)),
426            "mission": {},
427            "action": "action_deploy_140",
428            "reasoning": {},
429            "issues": []
430        });
431        let err = validate("reason.authorization.v1", Some(&payload)).unwrap_err();
432        assert_eq!(
433            err,
434            PredicateError::TypeMismatch {
435                suffix: "reason.authorization.v1".into(),
436                field: "action".into(),
437                expected: "\"object\"".into()
438            }
439        );
440    }
441
442    #[test]
443    fn blocked_valid_passes() {
444        let payload = json!({
445            "reason_class": "quarantine_triggered",
446            "refused_kind": "approval",
447            "approver": "human://alice",
448            "irreversibility": "one_way_consequential",
449            "description": "quarantine check reports DIRTY",
450            "quarantine_receipt": "art_deadbeef00112233"
451        });
452        assert!(validate("blocked.v1", Some(&payload)).is_ok());
453    }
454
455    #[test]
456    fn blocked_out_of_vocabulary_reason_fails_closed() {
457        // A refusal record whose reason is not in the closed vocabulary
458        // must not validate -- otherwise "blocked" becomes a freeform
459        // label that policy checks cannot rely on (AUD-06).
460        let payload = json!({
461            "reason_class": "just_felt_like_it",
462            "refused_kind": "approval"
463        });
464        let err = validate("blocked.v1", Some(&payload)).unwrap_err();
465        assert!(
466            matches!(err, PredicateError::NotInEnum { ref field, .. } if field == "reason_class"),
467            "expected NotInEnum on reason_class, got {err:?}"
468        );
469    }
470
471    #[test]
472    fn blocked_missing_reason_fails_closed() {
473        let payload = json!({ "refused_kind": "approval" });
474        let err = validate("blocked.v1", Some(&payload)).unwrap_err();
475        assert_eq!(
476            err,
477            PredicateError::MissingField {
478                suffix: "blocked.v1".into(),
479                field: "reason_class".into()
480            }
481        );
482    }
483
484    #[test]
485    fn unregistered_suffix_is_backward_compatible() {
486        // No schema -> attest proceeds as today, even with no payload.
487        assert!(validate("custom.kind.v1", None).is_ok());
488        assert!(validate("custom.kind.v1", Some(&json!({"anything": 1}))).is_ok());
489    }
490
491    #[test]
492    fn agent_cert_valid_passes() {
493        let payload = json!({
494            "agent": "agent://deployer",
495            "subject_key_id": "key_abc123",
496            "subject_public_key": "vEQfSDqVCz4rtqbu5iuhpFuYrah6QALUSCGJYdOKeCY",
497            "issuer": "ship://ship_b49ff5f291a279c7",
498            "issued_at": "2026-07-06T12:00:00Z",
499            "valid_until": "2027-07-06T12:00:00Z",
500            "model": "claude-fable-5",
501            "description": null
502        });
503        assert!(validate("agent_cert.v1", Some(&payload)).is_ok());
504    }
505
506    #[test]
507    fn agent_cert_missing_subject_key_fails_closed() {
508        let payload = json!({
509            "agent": "agent://deployer",
510            "subject_key_id": "key_abc123",
511            "issuer": "ship://ship_x",
512            "issued_at": "2026-07-06T12:00:00Z",
513            "valid_until": "2027-07-06T12:00:00Z"
514        }); // subject_public_key missing — the field the whole chain hangs on
515        let err = validate("agent_cert.v1", Some(&payload)).unwrap_err();
516        assert_eq!(
517            err,
518            PredicateError::MissingField {
519                suffix: "agent_cert.v1".into(),
520                field: "subject_public_key".into()
521            }
522        );
523    }
524
525    #[test]
526    fn session_record_valid_passes() {
527        let payload = json!({
528            "session_id": "ssn_abc123",
529            "actor": "agent://hermes",
530            "headline": "Fixed keystore hostname-drift bug",
531            "outcome": "completed",
532            "started_at": "2026-07-06T14:00:00Z",
533            "closed_at": "2026-07-06T15:30:00Z",
534            "duration_ms": 5400000,
535            "harness": "claude-code",
536            "attestation_class": "runtime",
537            "action_count": 212,
538            "approval_count": 2,
539            "handoff_count": 0,
540            "event_count": 340,
541            "tools_exercised": ["Bash(git:*)", "Edit(*)"],
542            "receipt_digest": "sha256:deadbeef",
543            "receipt_merkle_root": "sha256:cafebabe",
544            "report_url": null
545        });
546        assert!(validate("session.v1", Some(&payload)).is_ok());
547    }
548
549    #[test]
550    fn session_record_out_of_enum_class_fails_closed() {
551        // AUD-06: before enum enforcement, an out-of-vocabulary
552        // attestation_class passed on type (string) alone. It must now be
553        // rejected against the schema's enum.
554        let payload = json!({
555            "session_id": "ssn_abc123",
556            "actor": "agent://hermes",
557            "outcome": "completed",
558            "started_at": "2026-07-06T14:00:00Z",
559            "closed_at": "2026-07-06T15:30:00Z",
560            "attestation_class": "super-trusted",
561            "receipt_digest": "sha256:deadbeef"
562        });
563        let err = validate("session.v1", Some(&payload)).unwrap_err();
564        assert!(
565            matches!(err, PredicateError::NotInEnum { ref field, .. } if field == "attestation_class"),
566            "expected NotInEnum for attestation_class, got {err:?}"
567        );
568    }
569
570    #[test]
571    fn session_record_out_of_enum_outcome_fails_closed() {
572        // `outcome` also carries an enum; a bogus value must be rejected.
573        let payload = json!({
574            "session_id": "ssn_abc123",
575            "actor": "agent://hermes",
576            "outcome": "totally-shipped",
577            "started_at": "2026-07-06T14:00:00Z",
578            "closed_at": "2026-07-06T15:30:00Z",
579            "attestation_class": "self",
580            "receipt_digest": "sha256:deadbeef"
581        });
582        assert!(matches!(
583            validate("session.v1", Some(&payload)).unwrap_err(),
584            PredicateError::NotInEnum { .. }
585        ));
586    }
587
588    #[test]
589    fn session_record_missing_required_fails_closed() {
590        let payload = json!({
591            "session_id": "ssn_abc123",
592            "actor": "agent://hermes",
593            "outcome": "completed",
594            "started_at": "2026-07-06T14:00:00Z",
595            "closed_at": "2026-07-06T15:30:00Z",
596            "receipt_digest": "sha256:deadbeef"
597        }); // attestation_class missing
598        let err = validate("session.v1", Some(&payload)).unwrap_err();
599        assert_eq!(
600            err,
601            PredicateError::MissingField {
602                suffix: "session.v1".into(),
603                field: "attestation_class".into()
604            }
605        );
606    }
607
608    #[test]
609    fn session_record_wrong_type_fails_closed() {
610        let payload = json!({
611            "session_id": "ssn_abc123",
612            "actor": "agent://hermes",
613            "outcome": "completed",
614            "started_at": "2026-07-06T14:00:00Z",
615            "closed_at": "2026-07-06T15:30:00Z",
616            "attestation_class": "runtime",
617            "receipt_digest": "sha256:deadbeef",
618            "tools_exercised": "Bash(git:*)"
619        }); // tools_exercised must be an array, not a string
620        let err = validate("session.v1", Some(&payload)).unwrap_err();
621        assert!(matches!(err, PredicateError::TypeMismatch { .. }));
622    }
623
624    #[test]
625    fn memory_write_valid_passes() {
626        let payload = json!({
627            "memory_id": "mem_abc",
628            "content_hash": "sha256:deadbeef",
629            "memory_type": "episodic",
630            "scope": "tenant://acme",
631            "activegraph_run_id": "run_1",
632            "supersedes": null
633        });
634        assert!(validate("memory.write.v1", Some(&payload)).is_ok());
635    }
636
637    #[test]
638    fn memory_write_missing_required_fails_closed() {
639        let payload = json!({
640            "memory_id": "mem_abc",
641            "memory_type": "episodic",
642            "scope": "tenant://acme"
643        }); // content_hash missing
644        let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
645        assert_eq!(
646            err,
647            PredicateError::MissingField {
648                suffix: "memory.write.v1".into(),
649                field: "content_hash".into()
650            }
651        );
652    }
653
654    #[test]
655    fn memory_write_wrong_type_fails() {
656        let payload = json!({
657            "memory_id": "mem_abc",
658            "content_hash": 12345, // should be string
659            "memory_type": "episodic",
660            "scope": "tenant://acme"
661        });
662        let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
663        assert!(
664            matches!(err, PredicateError::TypeMismatch { field, .. } if field == "content_hash")
665        );
666    }
667
668    #[test]
669    fn memory_write_nullable_supersedes_accepts_string_and_null() {
670        let base = |sup: Value| {
671            json!({
672                "memory_id": "m", "content_hash": "h", "memory_type": "t", "scope": "s",
673                "supersedes": sup
674            })
675        };
676        assert!(validate("memory.write.v1", Some(&base(json!("mem_old")))).is_ok());
677        assert!(validate("memory.write.v1", Some(&base(Value::Null))).is_ok());
678        // a number is neither string nor null
679        assert!(validate("memory.write.v1", Some(&base(json!(7)))).is_err());
680    }
681
682    #[test]
683    fn registered_predicate_requires_a_payload() {
684        let err = validate("memory.write.v1", None).unwrap_err();
685        assert!(matches!(err, PredicateError::MissingField { .. }));
686    }
687
688    #[test]
689    fn memory_read_valid_and_integer_enforced() {
690        let ok = json!({
691            "zmem_receipt_id": "act_1",
692            "trace_sha256": "abcd",
693            "query_hash": "qh",
694            "retrieval_mode": "semantic",
695            "memories_returned": 3
696        });
697        assert!(validate("memory.read.v1", Some(&ok)).is_ok());
698
699        let bad = json!({
700            "zmem_receipt_id": "act_1",
701            "trace_sha256": "abcd",
702            "query_hash": "qh",
703            "retrieval_mode": "semantic",
704            "memories_returned": "three" // must be integer
705        });
706        assert!(matches!(
707            validate("memory.read.v1", Some(&bad)).unwrap_err(),
708            PredicateError::TypeMismatch { field, .. } if field == "memories_returned"
709        ));
710    }
711
712    #[test]
713    fn memory_read_missing_required_fails() {
714        let payload = json!({
715            "zmem_receipt_id": "act_1",
716            "trace_sha256": "abcd",
717            "retrieval_mode": "semantic",
718            "memories_returned": 3
719        }); // query_hash missing
720        assert!(matches!(
721            validate("memory.read.v1", Some(&payload)).unwrap_err(),
722            PredicateError::MissingField { field, .. } if field == "query_hash"
723        ));
724    }
725
726    #[test]
727    fn boundary_structural_required_fields_enforced() {
728        // Structural check: all top-level required present + declared types
729        // match. Field shapes mirror schemas/examples/boundary.v1.memory.valid
730        // (actor/checker are objects, committed_at is an object, diet an array).
731        let valid = json!({
732            "schema": "treeship.boundary.v1",
733            "subject_ref": "art_aabbccdd11223344",
734            "actor": {"uri": "agent://codex", "keyid": "key_aaaa1111"},
735            "checker": {"uri": "human://alice", "keyid": "key_bbbb2222"},
736            "decision": "allow",
737            "policy": {"digest": "sha256:p"},
738            "diet_root": "sha256:r",
739            "diet": [{"type": "memory_bundle", "digest": "sha256:d"}],
740            "committed_at": {"anchor": "merkle://zmem/checkpoint#4821", "ts": "2026-06-06T00:00:00Z"}
741        });
742        assert!(validate("boundary.v1", Some(&valid)).is_ok());
743
744        // A top-level field with the wrong type is caught structurally too.
745        let mut wrong = valid.clone();
746        wrong.as_object_mut().unwrap()["committed_at"] = json!("not-an-object");
747        assert!(matches!(
748            validate("boundary.v1", Some(&wrong)).unwrap_err(),
749            PredicateError::TypeMismatch { field, .. } if field == "committed_at"
750        ));
751
752        let mut missing = valid.clone();
753        missing.as_object_mut().unwrap().remove("decision");
754        assert!(matches!(
755            validate("boundary.v1", Some(&missing)).unwrap_err(),
756            PredicateError::MissingField { field, .. } if field == "decision"
757        ));
758    }
759
760    #[test]
761    fn agent_card_valid_passes() {
762        let card = json!({
763            "schema": "agent_card.v1",
764            "agent": "agent://deployer",
765            "keyid": "key_9f8e7d6c",
766            "owner": "human://alice",
767            "version": "1.2.0",
768            "capabilities": {
769                "tools": ["file.read", "file.write", "db.*"],
770                "models": ["claude-sonnet-4"],
771                "can_delegate": true
772            },
773            "evidence_anchor": { "receipt_count": 1247, "merkle_root": "mroot_a0be" },
774            "supersedes": null
775        });
776        assert!(validate("agent_card.v1", Some(&card)).is_ok());
777    }
778
779    #[test]
780    fn agent_card_missing_keyid_fails_closed() {
781        // keyid is the binding; a card without it is meaningless.
782        let card = json!({
783            "schema": "agent_card.v1",
784            "agent": "agent://deployer",
785            "version": "1.0.0",
786            "capabilities": { "tools": ["file.read"] }
787        });
788        assert!(matches!(
789            validate("agent_card.v1", Some(&card)).unwrap_err(),
790            PredicateError::MissingField { field, .. } if field == "keyid"
791        ));
792    }
793
794    #[test]
795    fn agent_card_capabilities_must_be_an_object() {
796        let card = json!({
797            "schema": "agent_card.v1",
798            "agent": "agent://deployer",
799            "keyid": "key_1",
800            "version": "1.0.0",
801            "capabilities": ["file.read"] // array, not the required object
802        });
803        assert!(matches!(
804            validate("agent_card.v1", Some(&card)).unwrap_err(),
805            PredicateError::TypeMismatch { field, .. } if field == "capabilities"
806        ));
807    }
808
809    #[test]
810    fn agent_card_revocation_valid_passes() {
811        let rev = json!({
812            "schema": "agent_card_revocation.v1",
813            "card": "art_deadbeefdeadbeef",
814            "keyid": "key_1",
815            "reason": "key-rotation",
816            "revoked_at": "2026-06-23T00:00:00Z"
817        });
818        assert!(validate("agent_card_revocation.v1", Some(&rev)).is_ok());
819    }
820
821    #[test]
822    fn agent_card_revocation_requires_card_id() {
823        let rev = json!({
824            "schema": "agent_card_revocation.v1",
825            "revoked_at": "2026-06-23T00:00:00Z"
826            // missing `card`
827        });
828        assert!(matches!(
829            validate("agent_card_revocation.v1", Some(&rev)).unwrap_err(),
830            PredicateError::MissingField { field, .. } if field == "card"
831        ));
832    }
833}