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