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