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        "verification.packet.v1",
70        include_str!("schemas/verification.packet.v1.json"),
71    ),
72    (
73        "verification.recompute.v1",
74        include_str!("schemas/verification.recompute.v1.json"),
75    ),
76    ("evaluation.v1", include_str!("schemas/evaluation.v1.json")),
77    ("coverage.v1", include_str!("schemas/coverage.v1.json")),
78    ("halt.v1", include_str!("schemas/halt.v1.json")),
79    ("judgement.v1", include_str!("schemas/judgement.v1.json")),
80];
81
82/// Returns the raw JSON Schema text for a registered predicate suffix, if any.
83/// This is the canonical published schema for the predicate.
84pub fn schema_json(suffix: &str) -> Option<&'static str> {
85    REGISTRY.iter().find(|(k, _)| *k == suffix).map(|(_, s)| *s)
86}
87
88/// Every registered predicate suffix.
89pub fn registered_suffixes() -> Vec<&'static str> {
90    REGISTRY.iter().map(|(k, _)| *k).collect()
91}
92
93/// A payload that does not conform to its predicate schema.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum PredicateError {
96    /// A `required` field was absent from the payload.
97    MissingField { suffix: String, field: String },
98    /// A present field did not match its declared type.
99    TypeMismatch {
100        suffix: String,
101        field: String,
102        expected: String,
103    },
104    /// The payload was not a JSON object (registered predicates require one).
105    NotAnObject { suffix: String },
106    /// A present field's value was not among the schema's `enum` (or did not
107    /// equal its `const`). This is what stops a self-declared field from
108    /// carrying an out-of-vocabulary value (AUD-06).
109    NotInEnum {
110        suffix: String,
111        field: String,
112        allowed: String,
113    },
114    /// The embedded schema itself failed to parse (a build-time bug).
115    SchemaParse { suffix: String, detail: String },
116    /// A registered predicate with nested control semantics failed its full,
117    /// typed validator. Structural top-level validation alone is not enough
118    /// for workflow graphs because it cannot detect dangling edges or cycles.
119    InvalidPayload { suffix: String, detail: String },
120}
121
122impl fmt::Display for PredicateError {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self {
125            PredicateError::MissingField { suffix, field } => {
126                write!(f, "{suffix}: missing required field `{field}`")
127            }
128            PredicateError::TypeMismatch {
129                suffix,
130                field,
131                expected,
132            } => write!(
133                f,
134                "{suffix}: field `{field}` has the wrong type (expected {expected})"
135            ),
136            PredicateError::NotAnObject { suffix } => {
137                write!(f, "{suffix}: payload must be a JSON object")
138            }
139            PredicateError::NotInEnum {
140                suffix,
141                field,
142                allowed,
143            } => write!(
144                f,
145                "{suffix}: field `{field}` has a value outside its allowed set ({allowed})"
146            ),
147            PredicateError::SchemaParse { suffix, detail } => {
148                write!(f, "{suffix}: registered schema is invalid JSON: {detail}")
149            }
150            PredicateError::InvalidPayload { suffix, detail } => {
151                write!(f, "{suffix}: invalid payload: {detail}")
152            }
153        }
154    }
155}
156
157impl std::error::Error for PredicateError {}
158
159/// Validate a receipt payload against the registered schema for `suffix`.
160///
161/// - If `suffix` is **not** registered, returns `Ok(())` (backward compatible:
162///   the receipt attests sign-on-submit, exactly as before).
163/// - If `suffix` **is** registered, the payload must be a JSON object that
164///   carries every `required` field and whose present fields match their
165///   declared primitive types. A missing payload is treated as the empty object
166///   and therefore fails any predicate that has required fields.
167pub fn validate(suffix: &str, payload: Option<&Value>) -> Result<(), PredicateError> {
168    let Some(schema_str) = schema_json(suffix) else {
169        return Ok(());
170    };
171    let schema: Value =
172        serde_json::from_str(schema_str).map_err(|e| PredicateError::SchemaParse {
173            suffix: suffix.to_string(),
174            detail: e.to_string(),
175        })?;
176
177    // A registered predicate requires a JSON object. A missing payload is the
178    // empty object, so any predicate with required fields fails closed here.
179    let empty = Value::Object(serde_json::Map::new());
180    let value = payload.unwrap_or(&empty);
181    let map = value
182        .as_object()
183        .ok_or_else(|| PredicateError::NotAnObject {
184            suffix: suffix.to_string(),
185        })?;
186
187    // The schema walk is recursive: `required`, `type`, `enum`, `const`,
188    // numeric bounds and string patterns are enforced at every depth, and
189    // `items` applies to each array element. Before 0.31.6 only the top
190    // level was checked, so a nested `required` (agent_card.v1's
191    // capability_provenance grade, boundary.v1's digests) or a nested enum
192    // (judgement.v1's outcome vocabulary inside `judge` and `question`) was a
193    // documented contract nothing enforced before signing.
194    walk_object(suffix, "", &schema, map)?;
195
196    // workflow.v1 carries nested control semantics that the dependency-free
197    // top-level schema walk above cannot enforce. Run the same typed validator
198    // the conformance reducer uses before signing, so an unknown control field,
199    // dangling edge, or undeclared cycle cannot enter a signed declaration.
200    if suffix == "workflow.v1" {
201        use crate::verify::workflow_conformance::WorkflowDeclaration;
202        let declaration: WorkflowDeclaration =
203            serde_json::from_value(value.clone()).map_err(|e| PredicateError::InvalidPayload {
204                suffix: suffix.to_string(),
205                detail: e.to_string(),
206            })?;
207        declaration
208            .validate()
209            .map_err(|errors| PredicateError::InvalidPayload {
210                suffix: suffix.to_string(),
211                detail: errors
212                    .iter()
213                    .map(ToString::to_string)
214                    .collect::<Vec<_>>()
215                    .join("; "),
216            })?;
217    }
218
219    Ok(())
220}
221
222/// Recursive structural validation of `map` against `schema`. `path` is the
223/// dotted field path for error messages ("" at the top level).
224fn walk_object(
225    suffix: &str,
226    path: &str,
227    schema: &Value,
228    map: &serde_json::Map<String, Value>,
229) -> Result<(), PredicateError> {
230    let at = |field: &str| -> String {
231        if path.is_empty() {
232            field.to_string()
233        } else {
234            format!("{path}.{field}")
235        }
236    };
237    if let Some(required) = schema.get("required").and_then(Value::as_array) {
238        for entry in required {
239            if let Some(name) = entry.as_str() {
240                if !map.contains_key(name) {
241                    return Err(PredicateError::MissingField {
242                        suffix: suffix.to_string(),
243                        field: at(name),
244                    });
245                }
246            }
247        }
248    }
249    if let Some(props) = schema.get("properties").and_then(Value::as_object) {
250        for (field, subschema) in props {
251            let Some(actual) = map.get(field) else {
252                continue; // optional-and-absent; `required` already enforced presence
253            };
254            walk_value(suffix, &at(field), subschema, actual)?;
255        }
256    }
257    if let Some(extra) = schema.get("additionalProperties") {
258        if extra.is_object() {
259            if let Some(declared) = schema.get("properties").and_then(Value::as_object) {
260                for (field, actual) in map {
261                    if !declared.contains_key(field) {
262                        walk_value(suffix, &at(field), extra, actual)?;
263                    }
264                }
265            } else {
266                for (field, actual) in map {
267                    walk_value(suffix, &at(field), extra, actual)?;
268                }
269            }
270        }
271    }
272    Ok(())
273}
274
275/// One value against one subschema: type, enum/const, numeric bounds,
276/// string pattern, then recursion into objects and array items.
277fn walk_value(
278    suffix: &str,
279    path: &str,
280    subschema: &Value,
281    actual: &Value,
282) -> Result<(), PredicateError> {
283    if let Some(type_decl) = subschema.get("type") {
284        if !type_matches(actual, type_decl) {
285            return Err(PredicateError::TypeMismatch {
286                suffix: suffix.to_string(),
287                field: path.to_string(),
288                expected: type_decl.to_string(),
289            });
290        }
291    }
292    // AUD-06: enforce `enum` and `const`, independently of whether a
293    // `type` is also declared, so an out-of-vocabulary value never passes on
294    // type alone.
295    if let Some(allowed) = subschema.get("enum").and_then(Value::as_array) {
296        if !allowed.iter().any(|a| a == actual) {
297            return Err(PredicateError::NotInEnum {
298                suffix: suffix.to_string(),
299                field: path.to_string(),
300                allowed: Value::Array(allowed.clone()).to_string(),
301            });
302        }
303    }
304    if let Some(constant) = subschema.get("const") {
305        if actual != constant {
306            return Err(PredicateError::NotInEnum {
307                suffix: suffix.to_string(),
308                field: path.to_string(),
309                allowed: constant.to_string(),
310            });
311        }
312    }
313    if let Some(n) = actual.as_f64() {
314        if let Some(min) = subschema.get("minimum").and_then(Value::as_f64) {
315            if n < min {
316                return Err(PredicateError::InvalidPayload {
317                    suffix: suffix.to_string(),
318                    detail: format!("{path} is {n}, below the minimum {min}"),
319                });
320            }
321        }
322        if let Some(max) = subschema.get("maximum").and_then(Value::as_f64) {
323            if n > max {
324                return Err(PredicateError::InvalidPayload {
325                    suffix: suffix.to_string(),
326                    detail: format!("{path} is {n}, above the maximum {max}"),
327                });
328            }
329        }
330    }
331    if let (Some(s), Some(pat)) = (
332        actual.as_str(),
333        subschema.get("pattern").and_then(Value::as_str),
334    ) {
335        if !pattern_matches(pat, s) {
336            return Err(PredicateError::InvalidPayload {
337                suffix: suffix.to_string(),
338                detail: format!("{path} does not match {pat}"),
339            });
340        }
341    }
342    match actual {
343        Value::Object(inner) => walk_object(suffix, path, subschema, inner)?,
344        Value::Array(items) => {
345            if let Some(item_schema) = subschema.get("items") {
346                for (i, item) in items.iter().enumerate() {
347                    walk_value(suffix, &format!("{path}[{i}]"), item_schema, item)?;
348                }
349            }
350        }
351        _ => {}
352    }
353    Ok(())
354}
355
356/// The one pattern shape the registered schemas use, without a regex
357/// dependency: `^<literal>[<class>]{<n>}$` (a digest such as
358/// `^sha256:[0-9a-f]{64}$`). Any other pattern is not enforced structurally;
359/// the canonical schema is the full contract.
360fn pattern_matches(pat: &str, s: &str) -> bool {
361    let Some(body) = pat.strip_prefix('^').and_then(|p| p.strip_suffix('$')) else {
362        return true;
363    };
364    let Some(open) = body.find('[') else {
365        return true;
366    };
367    let literal = &body[..open];
368    let Some(close) = body[open..].find(']') else {
369        return true;
370    };
371    let class = &body[open + 1..open + close];
372    let rest = &body[open + close + 1..];
373    let Some(count) = rest
374        .strip_prefix('{')
375        .and_then(|r| r.strip_suffix('}'))
376        .and_then(|n| n.parse::<usize>().ok())
377    else {
378        return true;
379    };
380    let Some(tail) = s.strip_prefix(literal) else {
381        return false;
382    };
383    if tail.chars().count() != count {
384        return false;
385    }
386    let in_class = |c: char| -> bool {
387        let cs: Vec<char> = class.chars().collect();
388        let mut i = 0;
389        while i < cs.len() {
390            if i + 2 < cs.len() && cs[i + 1] == '-' {
391                if c >= cs[i] && c <= cs[i + 2] {
392                    return true;
393                }
394                i += 3;
395            } else {
396                if c == cs[i] {
397                    return true;
398                }
399                i += 1;
400            }
401        }
402        false
403    };
404    tail.chars().all(in_class)
405}
406
407/// Does `value` satisfy a JSON Schema `type` declaration (a string, or an array
408/// of strings for a union)?
409fn type_matches(value: &Value, type_decl: &Value) -> bool {
410    match type_decl {
411        Value::String(t) => json_is(value, t),
412        Value::Array(types) => types
413            .iter()
414            .any(|t| t.as_str().is_some_and(|t| json_is(value, t))),
415        // A type declaration we don't recognize is not structurally enforced
416        // here; the canonical schema is the full contract.
417        _ => true,
418    }
419}
420
421/// Map a JSON Schema primitive type name onto a `serde_json::Value` shape.
422/// `integer` requires a non-fractional number.
423fn json_is(value: &Value, ty: &str) -> bool {
424    match ty {
425        "string" => value.is_string(),
426        "integer" => value.is_i64() || value.is_u64(),
427        "number" => value.is_number(),
428        "boolean" => value.is_boolean(),
429        "object" => value.is_object(),
430        "array" => value.is_array(),
431        "null" => value.is_null(),
432        // Unknown type keyword: not enforced structurally.
433        _ => true,
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use serde_json::json;
441
442    #[test]
443    fn registry_lists_the_three_seed_predicates() {
444        let suffixes = registered_suffixes();
445        assert!(suffixes.contains(&"memory.write.v1"));
446        assert!(suffixes.contains(&"memory.read.v1"));
447        assert!(suffixes.contains(&"boundary.v1"));
448        assert!(suffixes.contains(&"agent_card.v1"));
449        assert!(schema_json("memory.write.v1").is_some());
450        assert!(schema_json("nope.v1").is_none());
451    }
452
453    #[test]
454    fn embedded_schemas_parse() {
455        for s in registered_suffixes() {
456            let raw = schema_json(s).unwrap();
457            serde_json::from_str::<Value>(raw).expect("embedded schema must be valid JSON");
458        }
459    }
460
461    #[test]
462    fn workflow_declaration_runs_full_typed_validation_before_signing() {
463        let valid: Value = serde_json::from_str(include_str!(
464            "../../tests/fixtures/workflow-conformance/declaration.json"
465        ))
466        .expect("golden workflow declaration parses");
467        assert!(validate("workflow.v1", Some(&valid)).is_ok());
468
469        let mut unknown_field = valid.clone();
470        unknown_field["nodes"][0]["retry_policy"] = json!({ "max": 99 });
471        assert!(matches!(
472            validate("workflow.v1", Some(&unknown_field)),
473            Err(PredicateError::InvalidPayload { .. })
474        ));
475
476        let mut missing_allowed_tools = valid.clone();
477        missing_allowed_tools["nodes"][0]
478            .as_object_mut()
479            .expect("workflow node is an object")
480            .remove("allowed_tools");
481        let error = validate("workflow.v1", Some(&missing_allowed_tools))
482            .expect_err("schema-required nested fields must be refused before signing");
483        // The recursive schema walk refuses it first (nodes[0].allowed_tools);
484        // the typed validator would too. Either is a refusal before signing.
485        assert!(matches!(
486            error,
487            PredicateError::InvalidPayload { .. } | PredicateError::MissingField { .. }
488        ));
489        assert!(error.to_string().contains("allowed_tools"));
490
491        let mut unbounded_cycle = valid;
492        unbounded_cycle["edges"]
493            .as_array_mut()
494            .expect("edges is an array")
495            .push(json!({ "from": "finish", "to": "inspect", "when": "always" }));
496        let error = validate("workflow.v1", Some(&unbounded_cycle))
497            .expect_err("an undeclared workflow cycle must be refused before signing");
498        assert!(matches!(error, PredicateError::InvalidPayload { .. }));
499        assert!(error.to_string().contains("bounded loop"));
500    }
501
502    #[test]
503    fn quarantine_check_valid_passes() {
504        let payload = json!({
505            "action_id": "aac_1f2e3d4c",
506            "provider": "system://zmem",
507            "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
508            "decision_seq": 1042,
509            "clean": true,
510            "quarantined_triggers": [],
511            "checked_at": "2026-07-17T19:00:00Z"
512        });
513        assert!(validate("memory.quarantine-check.v1", Some(&payload)).is_ok());
514    }
515
516    #[test]
517    fn quarantine_check_missing_verdict_fails_closed() {
518        let payload = json!({
519            "action_id": "aac_1f2e3d4c",
520            "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
521            "decision_seq": 1042
522        }); // `clean` missing — the field the whole gate hangs on
523        let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
524        assert_eq!(
525            err,
526            PredicateError::MissingField {
527                suffix: "memory.quarantine-check.v1".into(),
528                field: "clean".into()
529            }
530        );
531    }
532
533    #[test]
534    fn quarantine_check_stringly_typed_verdict_fails_closed() {
535        // A "true" string must not pass for a boolean verdict — a lenient
536        // parse here would let a provider bug (or an attacker) launder an
537        // ambiguous verdict into a clean one.
538        let payload = json!({
539            "action_id": "aac_1f2e3d4c",
540            "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
541            "decision_seq": 1042,
542            "clean": "true"
543        });
544        let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
545        assert_eq!(
546            err,
547            PredicateError::TypeMismatch {
548                suffix: "memory.quarantine-check.v1".into(),
549                field: "clean".into(),
550                expected: "\"boolean\"".into()
551            }
552        );
553    }
554
555    #[test]
556    fn quarantine_check_non_integer_seq_fails_closed() {
557        // decision_seq binds the verdict to a ledger state; a non-integer
558        // seq breaks chain-root rederivation for Class-2 verifiers.
559        let payload = json!({
560            "action_id": "aac_1f2e3d4c",
561            "chain_root": "u3v9xJ2kQm4Zr8pW1sTnA7bCdEfGhIjKlMnOpQrStUv",
562            "decision_seq": "1042",
563            "clean": true
564        });
565        let err = validate("memory.quarantine-check.v1", Some(&payload)).unwrap_err();
566        assert_eq!(
567            err,
568            PredicateError::TypeMismatch {
569                suffix: "memory.quarantine-check.v1".into(),
570                field: "decision_seq".into(),
571                expected: "\"integer\"".into()
572            }
573        );
574    }
575
576    /// Hand-authored from the public zerker.reason.authorization.v1 schema,
577    /// complete down to every nested `required` field, since the validator
578    /// walks the whole tree. A structural fixture, not a cryptographic vector.
579    fn reason_authorization_payload() -> Value {
580        json!({
581            "schema": "zerker.reason.authorization.v1",
582            "status": "authorized",
583            "request_digest": format!("sha256:{}", "1".repeat(64)),
584            "mission": {
585                "id": "mission_release_140",
586                "digest": format!("sha256:{}", "2".repeat(64))
587            },
588            "action": {
589                "id": "action_deploy_140",
590                "digest": format!("sha256:{}", "3".repeat(64)),
591                "tool": "deploy_release",
592                "arguments": {"environment": "production"},
593                "effects": []
594            },
595            "reasoning": {
596                "schema": "zerker.reason.result.v2",
597                "status": "proved",
598                "query": {"predicate": "authorized", "arguments": ["action_deploy_140"]},
599                "program_digest": format!("sha256:{}", "4".repeat(64)),
600                "ontology": {},
601                "authority": {"classes": ["human-authorized"], "default_admit": ["human-authorized"]},
602                "proof": {"root": "authorized(action_deploy_140)"},
603                "disproof": null,
604                "conflict": null,
605                "missing": [],
606                "assumptions": [],
607                "metrics": {"facts": 3, "rules": 1}
608            },
609            "issues": []
610        })
611    }
612
613    #[test]
614    fn reason_authorization_valid_shape_passes() {
615        assert!(validate(
616            "reason.authorization.v1",
617            Some(&reason_authorization_payload())
618        )
619        .is_ok());
620    }
621
622    #[test]
623    fn reason_authorization_nested_required_is_enforced() {
624        // Before the validator walked the tree, an action with no id passed.
625        let mut p = reason_authorization_payload();
626        p["action"].as_object_mut().unwrap().remove("id");
627        assert_eq!(
628            validate("reason.authorization.v1", Some(&p)),
629            Err(PredicateError::MissingField {
630                suffix: "reason.authorization.v1".into(),
631                field: "action.id".into()
632            })
633        );
634        let mut p = reason_authorization_payload();
635        p["reasoning"]["status"] = json!("vibes");
636        assert!(matches!(
637            validate("reason.authorization.v1", Some(&p)),
638            Err(PredicateError::NotInEnum { field, .. }) if field == "reasoning.status"
639        ));
640    }
641
642    #[test]
643    fn reason_authorization_missing_request_digest_fails_closed() {
644        let mut payload = reason_authorization_payload();
645        payload.as_object_mut().unwrap().remove("request_digest");
646        let err = validate("reason.authorization.v1", Some(&payload)).unwrap_err();
647        assert_eq!(
648            err,
649            PredicateError::MissingField {
650                suffix: "reason.authorization.v1".into(),
651                field: "request_digest".into()
652            }
653        );
654    }
655
656    #[test]
657    fn reason_authorization_out_of_vocabulary_status_fails_closed() {
658        let mut payload = reason_authorization_payload();
659        payload["status"] = json!("probably_safe");
660        let err = validate("reason.authorization.v1", Some(&payload)).unwrap_err();
661        assert!(
662            matches!(&err, PredicateError::NotInEnum { field, .. } if field == "status"),
663            "expected NotInEnum on status, got {err:?}"
664        );
665    }
666
667    #[test]
668    fn reason_authorization_wrong_action_shape_fails_closed() {
669        let payload = json!({
670            "schema": "zerker.reason.authorization.v1",
671            "status": "denied",
672            "request_digest": format!("sha256:{}", "1".repeat(64)),
673            "mission": {},
674            "action": "action_deploy_140",
675            "reasoning": {},
676            "issues": []
677        });
678        let err = validate("reason.authorization.v1", Some(&payload)).unwrap_err();
679        assert_eq!(
680            err,
681            PredicateError::TypeMismatch {
682                suffix: "reason.authorization.v1".into(),
683                field: "action".into(),
684                expected: "\"object\"".into()
685            }
686        );
687    }
688
689    #[test]
690    fn blocked_valid_passes() {
691        let payload = json!({
692            "reason_class": "quarantine_triggered",
693            "refused_kind": "approval",
694            "approver": "human://alice",
695            "irreversibility": "one_way_consequential",
696            "description": "quarantine check reports DIRTY",
697            "quarantine_receipt": "art_deadbeef00112233"
698        });
699        assert!(validate("blocked.v1", Some(&payload)).is_ok());
700    }
701
702    #[test]
703    fn blocked_out_of_vocabulary_reason_fails_closed() {
704        // A refusal record whose reason is not in the closed vocabulary
705        // must not validate -- otherwise "blocked" becomes a freeform
706        // label that policy checks cannot rely on (AUD-06).
707        let payload = json!({
708            "reason_class": "just_felt_like_it",
709            "refused_kind": "approval"
710        });
711        let err = validate("blocked.v1", Some(&payload)).unwrap_err();
712        assert!(
713            matches!(err, PredicateError::NotInEnum { ref field, .. } if field == "reason_class"),
714            "expected NotInEnum on reason_class, got {err:?}"
715        );
716    }
717
718    #[test]
719    fn blocked_missing_reason_fails_closed() {
720        let payload = json!({ "refused_kind": "approval" });
721        let err = validate("blocked.v1", Some(&payload)).unwrap_err();
722        assert_eq!(
723            err,
724            PredicateError::MissingField {
725                suffix: "blocked.v1".into(),
726                field: "reason_class".into()
727            }
728        );
729    }
730
731    #[test]
732    fn unregistered_suffix_is_backward_compatible() {
733        // No schema -> attest proceeds as today, even with no payload.
734        assert!(validate("custom.kind.v1", None).is_ok());
735        assert!(validate("custom.kind.v1", Some(&json!({"anything": 1}))).is_ok());
736    }
737
738    #[test]
739    fn agent_cert_valid_passes() {
740        let payload = json!({
741            "agent": "agent://deployer",
742            "subject_key_id": "key_abc123",
743            "subject_public_key": "vEQfSDqVCz4rtqbu5iuhpFuYrah6QALUSCGJYdOKeCY",
744            "issuer": "ship://ship_b49ff5f291a279c7",
745            "issued_at": "2026-07-06T12:00:00Z",
746            "valid_until": "2027-07-06T12:00:00Z",
747            "model": "claude-fable-5",
748            "description": null
749        });
750        assert!(validate("agent_cert.v1", Some(&payload)).is_ok());
751    }
752
753    #[test]
754    fn agent_cert_missing_subject_key_fails_closed() {
755        let payload = json!({
756            "agent": "agent://deployer",
757            "subject_key_id": "key_abc123",
758            "issuer": "ship://ship_x",
759            "issued_at": "2026-07-06T12:00:00Z",
760            "valid_until": "2027-07-06T12:00:00Z"
761        }); // subject_public_key missing — the field the whole chain hangs on
762        let err = validate("agent_cert.v1", Some(&payload)).unwrap_err();
763        assert_eq!(
764            err,
765            PredicateError::MissingField {
766                suffix: "agent_cert.v1".into(),
767                field: "subject_public_key".into()
768            }
769        );
770    }
771
772    #[test]
773    fn session_record_valid_passes() {
774        let payload = json!({
775            "session_id": "ssn_abc123",
776            "actor": "agent://hermes",
777            "headline": "Fixed keystore hostname-drift bug",
778            "outcome": "completed",
779            "started_at": "2026-07-06T14:00:00Z",
780            "closed_at": "2026-07-06T15:30:00Z",
781            "duration_ms": 5400000,
782            "harness": "claude-code",
783            "attestation_class": "runtime",
784            "action_count": 212,
785            "approval_count": 2,
786            "handoff_count": 0,
787            "event_count": 340,
788            "tools_exercised": ["Bash(git:*)", "Edit(*)"],
789            "receipt_digest": "sha256:deadbeef",
790            "receipt_merkle_root": "sha256:cafebabe",
791            "report_url": null
792        });
793        assert!(validate("session.v1", Some(&payload)).is_ok());
794    }
795
796    #[test]
797    fn session_record_out_of_enum_class_fails_closed() {
798        // AUD-06: before enum enforcement, an out-of-vocabulary
799        // attestation_class passed on type (string) alone. It must now be
800        // rejected against the schema's enum.
801        let payload = json!({
802            "session_id": "ssn_abc123",
803            "actor": "agent://hermes",
804            "outcome": "completed",
805            "started_at": "2026-07-06T14:00:00Z",
806            "closed_at": "2026-07-06T15:30:00Z",
807            "attestation_class": "super-trusted",
808            "receipt_digest": "sha256:deadbeef"
809        });
810        let err = validate("session.v1", Some(&payload)).unwrap_err();
811        assert!(
812            matches!(err, PredicateError::NotInEnum { ref field, .. } if field == "attestation_class"),
813            "expected NotInEnum for attestation_class, got {err:?}"
814        );
815    }
816
817    #[test]
818    fn session_record_out_of_enum_outcome_fails_closed() {
819        // `outcome` also carries an enum; a bogus value must be rejected.
820        let payload = json!({
821            "session_id": "ssn_abc123",
822            "actor": "agent://hermes",
823            "outcome": "totally-shipped",
824            "started_at": "2026-07-06T14:00:00Z",
825            "closed_at": "2026-07-06T15:30:00Z",
826            "attestation_class": "self",
827            "receipt_digest": "sha256:deadbeef"
828        });
829        assert!(matches!(
830            validate("session.v1", Some(&payload)).unwrap_err(),
831            PredicateError::NotInEnum { .. }
832        ));
833    }
834
835    #[test]
836    fn session_record_missing_required_fails_closed() {
837        let payload = json!({
838            "session_id": "ssn_abc123",
839            "actor": "agent://hermes",
840            "outcome": "completed",
841            "started_at": "2026-07-06T14:00:00Z",
842            "closed_at": "2026-07-06T15:30:00Z",
843            "receipt_digest": "sha256:deadbeef"
844        }); // attestation_class missing
845        let err = validate("session.v1", Some(&payload)).unwrap_err();
846        assert_eq!(
847            err,
848            PredicateError::MissingField {
849                suffix: "session.v1".into(),
850                field: "attestation_class".into()
851            }
852        );
853    }
854
855    #[test]
856    fn session_record_wrong_type_fails_closed() {
857        let payload = json!({
858            "session_id": "ssn_abc123",
859            "actor": "agent://hermes",
860            "outcome": "completed",
861            "started_at": "2026-07-06T14:00:00Z",
862            "closed_at": "2026-07-06T15:30:00Z",
863            "attestation_class": "runtime",
864            "receipt_digest": "sha256:deadbeef",
865            "tools_exercised": "Bash(git:*)"
866        }); // tools_exercised must be an array, not a string
867        let err = validate("session.v1", Some(&payload)).unwrap_err();
868        assert!(matches!(err, PredicateError::TypeMismatch { .. }));
869    }
870
871    #[test]
872    fn memory_write_valid_passes() {
873        let payload = json!({
874            "memory_id": "mem_abc",
875            "content_hash": "sha256:deadbeef",
876            "memory_type": "episodic",
877            "scope": "tenant://acme",
878            "activegraph_run_id": "run_1",
879            "supersedes": null
880        });
881        assert!(validate("memory.write.v1", Some(&payload)).is_ok());
882    }
883
884    #[test]
885    fn memory_write_missing_required_fails_closed() {
886        let payload = json!({
887            "memory_id": "mem_abc",
888            "memory_type": "episodic",
889            "scope": "tenant://acme"
890        }); // content_hash missing
891        let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
892        assert_eq!(
893            err,
894            PredicateError::MissingField {
895                suffix: "memory.write.v1".into(),
896                field: "content_hash".into()
897            }
898        );
899    }
900
901    #[test]
902    fn memory_write_wrong_type_fails() {
903        let payload = json!({
904            "memory_id": "mem_abc",
905            "content_hash": 12345, // should be string
906            "memory_type": "episodic",
907            "scope": "tenant://acme"
908        });
909        let err = validate("memory.write.v1", Some(&payload)).unwrap_err();
910        assert!(
911            matches!(err, PredicateError::TypeMismatch { field, .. } if field == "content_hash")
912        );
913    }
914
915    #[test]
916    fn memory_write_nullable_supersedes_accepts_string_and_null() {
917        let base = |sup: Value| {
918            json!({
919                "memory_id": "m", "content_hash": "h", "memory_type": "t", "scope": "s",
920                "supersedes": sup
921            })
922        };
923        assert!(validate("memory.write.v1", Some(&base(json!("mem_old")))).is_ok());
924        assert!(validate("memory.write.v1", Some(&base(Value::Null))).is_ok());
925        // a number is neither string nor null
926        assert!(validate("memory.write.v1", Some(&base(json!(7)))).is_err());
927    }
928
929    #[test]
930    fn registered_predicate_requires_a_payload() {
931        let err = validate("memory.write.v1", None).unwrap_err();
932        assert!(matches!(err, PredicateError::MissingField { .. }));
933    }
934
935    #[test]
936    fn memory_read_valid_and_integer_enforced() {
937        let ok = json!({
938            "zmem_receipt_id": "act_1",
939            "trace_sha256": "abcd",
940            "query_hash": "qh",
941            "retrieval_mode": "semantic",
942            "memories_returned": 3
943        });
944        assert!(validate("memory.read.v1", Some(&ok)).is_ok());
945
946        let bad = json!({
947            "zmem_receipt_id": "act_1",
948            "trace_sha256": "abcd",
949            "query_hash": "qh",
950            "retrieval_mode": "semantic",
951            "memories_returned": "three" // must be integer
952        });
953        assert!(matches!(
954            validate("memory.read.v1", Some(&bad)).unwrap_err(),
955            PredicateError::TypeMismatch { field, .. } if field == "memories_returned"
956        ));
957    }
958
959    #[test]
960    fn memory_read_missing_required_fails() {
961        let payload = json!({
962            "zmem_receipt_id": "act_1",
963            "trace_sha256": "abcd",
964            "retrieval_mode": "semantic",
965            "memories_returned": 3
966        }); // query_hash missing
967        assert!(matches!(
968            validate("memory.read.v1", Some(&payload)).unwrap_err(),
969            PredicateError::MissingField { field, .. } if field == "query_hash"
970        ));
971    }
972
973    #[test]
974    fn boundary_structural_required_fields_enforced() {
975        // Structural check: all top-level required present + declared types
976        // match. Field shapes mirror schemas/examples/boundary.v1.memory.valid
977        // (actor/checker are objects, committed_at is an object, diet an array).
978        let valid = json!({
979            "schema": "treeship.boundary.v1",
980            "subject_ref": "art_aabbccdd11223344",
981            "actor": {"uri": "agent://codex", "keyid": "key_aaaa1111"},
982            "checker": {"uri": "human://alice", "keyid": "key_bbbb2222"},
983            "decision": "allow",
984            "policy": {"digest": "sha256:p"},
985            "diet_root": "sha256:r",
986            "diet": [{"type": "memory_bundle", "digest": "sha256:d"}],
987            "committed_at": {"anchor": "merkle://zmem/checkpoint#4821", "ts": "2026-06-06T00:00:00Z"}
988        });
989        assert!(validate("boundary.v1", Some(&valid)).is_ok());
990
991        // A top-level field with the wrong type is caught structurally too.
992        let mut wrong = valid.clone();
993        wrong.as_object_mut().unwrap()["committed_at"] = json!("not-an-object");
994        assert!(matches!(
995            validate("boundary.v1", Some(&wrong)).unwrap_err(),
996            PredicateError::TypeMismatch { field, .. } if field == "committed_at"
997        ));
998
999        let mut missing = valid.clone();
1000        missing.as_object_mut().unwrap().remove("decision");
1001        assert!(matches!(
1002            validate("boundary.v1", Some(&missing)).unwrap_err(),
1003            PredicateError::MissingField { field, .. } if field == "decision"
1004        ));
1005    }
1006
1007    #[test]
1008    fn agent_card_valid_passes() {
1009        let card = json!({
1010            "schema": "agent_card.v1",
1011            "agent": "agent://deployer",
1012            "keyid": "key_9f8e7d6c",
1013            "owner": "human://alice",
1014            "version": "1.2.0",
1015            "capabilities": {
1016                "tools": ["file.read", "file.write", "db.*"],
1017                "models": ["claude-sonnet-4"],
1018                "can_delegate": true
1019            },
1020            "evidence_anchor": { "receipt_count": 1247, "merkle_root": "mroot_a0be" },
1021            "supersedes": null
1022        });
1023        assert!(validate("agent_card.v1", Some(&card)).is_ok());
1024    }
1025
1026    #[test]
1027    fn agent_card_missing_keyid_fails_closed() {
1028        // keyid is the binding; a card without it is meaningless.
1029        let card = json!({
1030            "schema": "agent_card.v1",
1031            "agent": "agent://deployer",
1032            "version": "1.0.0",
1033            "capabilities": { "tools": ["file.read"] }
1034        });
1035        assert!(matches!(
1036            validate("agent_card.v1", Some(&card)).unwrap_err(),
1037            PredicateError::MissingField { field, .. } if field == "keyid"
1038        ));
1039    }
1040
1041    #[test]
1042    fn agent_card_capabilities_must_be_an_object() {
1043        let card = json!({
1044            "schema": "agent_card.v1",
1045            "agent": "agent://deployer",
1046            "keyid": "key_1",
1047            "version": "1.0.0",
1048            "capabilities": ["file.read"] // array, not the required object
1049        });
1050        assert!(matches!(
1051            validate("agent_card.v1", Some(&card)).unwrap_err(),
1052            PredicateError::TypeMismatch { field, .. } if field == "capabilities"
1053        ));
1054    }
1055
1056    #[test]
1057    fn agent_card_revocation_valid_passes() {
1058        let rev = json!({
1059            "schema": "agent_card_revocation.v1",
1060            "card": "art_deadbeefdeadbeef",
1061            "keyid": "key_1",
1062            "reason": "key-rotation",
1063            "revoked_at": "2026-06-23T00:00:00Z"
1064        });
1065        assert!(validate("agent_card_revocation.v1", Some(&rev)).is_ok());
1066    }
1067
1068    #[test]
1069    fn agent_card_revocation_requires_card_id() {
1070        let rev = json!({
1071            "schema": "agent_card_revocation.v1",
1072            "revoked_at": "2026-06-23T00:00:00Z"
1073            // missing `card`
1074        });
1075        assert!(matches!(
1076            validate("agent_card_revocation.v1", Some(&rev)).unwrap_err(),
1077            PredicateError::MissingField { field, .. } if field == "card"
1078        ));
1079    }
1080
1081    fn packet_payload() -> serde_json::Value {
1082        json!({
1083            "schema": "verification.packet.v1",
1084            "packet_id": "pkt_000042",
1085            "stream_id": "tap-7/req-9f2a",
1086            "sequence": 42,
1087            "prev_packet_id": "pkt_000041",
1088            "model_digest": "sha256:aa11",
1089            "input_digest": "sha256:bb22",
1090            "output_digest": "sha256:cc33",
1091            "reproducibility": "bit_exact",
1092            "produced_at": "2026-09-17T15:00:00Z"
1093        })
1094    }
1095
1096    #[test]
1097    fn verification_packet_valid_passes() {
1098        assert!(validate("verification.packet.v1", Some(&packet_payload())).is_ok());
1099    }
1100
1101    #[test]
1102    fn verification_packet_missing_output_digest_fails_closed() {
1103        let mut p = packet_payload();
1104        p.as_object_mut().unwrap().remove("output_digest");
1105        assert_eq!(
1106            validate("verification.packet.v1", Some(&p)),
1107            Err(PredicateError::MissingField {
1108                suffix: "verification.packet.v1".into(),
1109                field: "output_digest".into(),
1110            })
1111        );
1112    }
1113
1114    #[test]
1115    fn verification_packet_stringly_typed_sequence_fails_closed() {
1116        let mut p = packet_payload();
1117        p["sequence"] = json!("42");
1118        assert!(matches!(
1119            validate("verification.packet.v1", Some(&p)),
1120            Err(PredicateError::TypeMismatch { field, .. }) if field == "sequence"
1121        ));
1122    }
1123
1124    #[test]
1125    fn verification_packet_out_of_vocabulary_reproducibility_fails_closed() {
1126        let mut p = packet_payload();
1127        p["reproducibility"] = json!("probably");
1128        assert!(matches!(
1129            validate("verification.packet.v1", Some(&p)),
1130            Err(PredicateError::NotInEnum { field, .. }) if field == "reproducibility"
1131        ));
1132    }
1133
1134    #[test]
1135    fn verification_packet_wrong_schema_const_fails_closed() {
1136        let mut p = packet_payload();
1137        p["schema"] = json!("verification.recompute.v1");
1138        assert!(matches!(
1139            validate("verification.packet.v1", Some(&p)),
1140            Err(PredicateError::NotInEnum { field, .. }) if field == "schema"
1141        ));
1142    }
1143
1144    fn recompute_payload() -> serde_json::Value {
1145        json!({
1146            "schema": "verification.recompute.v1",
1147            "packet": "art_0123456789abcdef0123456789abcdef",
1148            "packet_id": "pkt_000042",
1149            "method": "difr",
1150            "verdict": "match",
1151            "distance": 0.0012,
1152            "threshold": 0.01,
1153            "recomputed_at": "2026-09-17T15:05:00Z"
1154        })
1155    }
1156
1157    #[test]
1158    fn verification_recompute_valid_passes() {
1159        assert!(validate("verification.recompute.v1", Some(&recompute_payload())).is_ok());
1160    }
1161
1162    #[test]
1163    fn verification_recompute_out_of_vocabulary_verdict_fails_closed() {
1164        let mut p = recompute_payload();
1165        p["verdict"] = json!("mostly");
1166        assert!(matches!(
1167            validate("verification.recompute.v1", Some(&p)),
1168            Err(PredicateError::NotInEnum { field, .. }) if field == "verdict"
1169        ));
1170    }
1171
1172    #[test]
1173    fn verification_recompute_missing_packet_fails_closed() {
1174        let mut p = recompute_payload();
1175        p.as_object_mut().unwrap().remove("packet");
1176        assert_eq!(
1177            validate("verification.recompute.v1", Some(&p)),
1178            Err(PredicateError::MissingField {
1179                suffix: "verification.recompute.v1".into(),
1180                field: "packet".into(),
1181            })
1182        );
1183    }
1184
1185    fn evaluation_payload() -> serde_json::Value {
1186        json!({
1187            "schema": "evaluation.v1",
1188            "subject_kind": "model",
1189            "subject_digest": "sha256:aa11",
1190            "suite_id": "sandbox-escape-v3",
1191            "suite_digest": "sha256:bb22",
1192            "result_digest": "sha256:cc33",
1193            "verdict": "pass",
1194            "score": 0.02,
1195            "threshold": 0.05,
1196            "capability": "sandbox-escape",
1197            "evaluated_at": "2026-09-17T18:00:00Z"
1198        })
1199    }
1200
1201    #[test]
1202    fn evaluation_valid_passes() {
1203        assert!(validate("evaluation.v1", Some(&evaluation_payload())).is_ok());
1204    }
1205
1206    #[test]
1207    fn evaluation_out_of_vocabulary_verdict_fails_closed() {
1208        let mut p = evaluation_payload();
1209        p["verdict"] = json!("mostly");
1210        assert!(matches!(
1211            validate("evaluation.v1", Some(&p)),
1212            Err(PredicateError::NotInEnum { field, .. }) if field == "verdict"
1213        ));
1214    }
1215
1216    #[test]
1217    fn evaluation_out_of_vocabulary_subject_kind_fails_closed() {
1218        let mut p = evaluation_payload();
1219        p["subject_kind"] = json!("vibes");
1220        assert!(matches!(
1221            validate("evaluation.v1", Some(&p)),
1222            Err(PredicateError::NotInEnum { field, .. }) if field == "subject_kind"
1223        ));
1224    }
1225
1226    #[test]
1227    fn evaluation_missing_suite_digest_fails_closed() {
1228        let mut p = evaluation_payload();
1229        p.as_object_mut().unwrap().remove("suite_digest");
1230        assert_eq!(
1231            validate("evaluation.v1", Some(&p)),
1232            Err(PredicateError::MissingField {
1233                suffix: "evaluation.v1".into(),
1234                field: "suite_digest".into(),
1235            })
1236        );
1237    }
1238
1239    #[test]
1240    fn evaluation_stringly_typed_score_fails_closed() {
1241        let mut p = evaluation_payload();
1242        p["score"] = json!("0.02");
1243        assert!(matches!(
1244            validate("evaluation.v1", Some(&p)),
1245            Err(PredicateError::TypeMismatch { field, .. }) if field == "score"
1246        ));
1247    }
1248    fn coverage_payload() -> serde_json::Value {
1249        json!({
1250            "schema": "coverage.v1",
1251            "session_id": "ssn_0011223344556677",
1252            "actor": "agent://claude-code",
1253            "declared_level": "high",
1254            "harnesses": [{
1255                "harness_id": "claude-code",
1256                "status": "instrumented",
1257                "coverage": "high",
1258                "connection_modes": ["native-hook", "mcp", "git-reconcile"],
1259                "known_gaps": ["Built-in tools the user invokes outside hooks rely on git-reconcile."]
1260            }],
1261            "observed": {
1262                "events": 14,
1263                "event_types": {"session.started": 1, "agent.called_tool": 12, "session.closed": 1},
1264                "hosts": ["host_aa"],
1265                "agent_instances": 1,
1266                "event_log_skipped": 0
1267            },
1268            "gaps": ["Built-in tools the user invokes outside hooks rely on git-reconcile."],
1269            "closed_at": "2026-09-18T20:00:00Z"
1270        })
1271    }
1272
1273    #[test]
1274    fn coverage_valid_passes() {
1275        assert!(validate("coverage.v1", Some(&coverage_payload())).is_ok());
1276    }
1277
1278    #[test]
1279    fn coverage_requires_observed() {
1280        let mut p = coverage_payload();
1281        p.as_object_mut().unwrap().remove("observed");
1282        assert_eq!(
1283            validate("coverage.v1", Some(&p)),
1284            Err(PredicateError::MissingField {
1285                suffix: "coverage.v1".into(),
1286                field: "observed".into(),
1287            })
1288        );
1289    }
1290
1291    #[test]
1292    fn coverage_rejects_unknown_level() {
1293        let mut p = coverage_payload();
1294        p["declared_level"] = json!("total");
1295        assert!(validate("coverage.v1", Some(&p)).is_err());
1296    }
1297
1298    #[test]
1299    fn coverage_none_level_with_no_harnesses_is_valid() {
1300        let mut p = coverage_payload();
1301        p["declared_level"] = json!("none");
1302        p["harnesses"] = json!([]);
1303        assert!(validate("coverage.v1", Some(&p)).is_ok());
1304    }
1305
1306    #[test]
1307    fn halt_valid_passes() {
1308        let p = json!({"schema":"halt.v1","action":"halt","actor":"agent://claude-code","reason":"off-task network calls","issued_at":"2026-09-18T10:00:00Z"});
1309        assert!(validate("halt.v1", Some(&p)).is_ok());
1310        let l = json!({"schema":"halt.v1","action":"lift","actor":"agent://claude-code","halt":"art_0123","issued_at":"2026-09-18T11:00:00Z"});
1311        assert!(validate("halt.v1", Some(&l)).is_ok());
1312    }
1313
1314    #[test]
1315    fn halt_out_of_vocabulary_action_fails_closed() {
1316        let p = json!({"schema":"halt.v1","action":"pause","actor":"agent://x","issued_at":"2026-09-18T10:00:00Z"});
1317        assert!(matches!(
1318            validate("halt.v1", Some(&p)),
1319            Err(PredicateError::NotInEnum { field, .. }) if field == "action"
1320        ));
1321    }
1322
1323    #[test]
1324    fn halt_missing_actor_fails_closed() {
1325        let p = json!({"schema":"halt.v1","action":"halt","issued_at":"2026-09-18T10:00:00Z"});
1326        assert_eq!(
1327            validate("halt.v1", Some(&p)),
1328            Err(PredicateError::MissingField {
1329                suffix: "halt.v1".into(),
1330                field: "actor".into()
1331            })
1332        );
1333    }
1334    fn judgement_payload() -> serde_json::Value {
1335        json!({
1336            "schema": "judgement.v1",
1337            "judge": {"model": "jev-1.13.0", "provider": "typesafe", "kind": "decision-model", "replayable": false},
1338            "state_digest": format!("sha256:{}", "ab".repeat(32)),
1339            "questions_digest": format!("sha256:{}", "cd".repeat(32)),
1340            "question": {"key": "destructive", "type": "noul",
1341                         "instructions": "Does this command delete or overwrite files outside the workspace?"},
1342            "answer": {"noul": 0.93},
1343            "threshold": {"value": 0.85, "applies_to": "noul", "set_by": "card:agent://claude-code"},
1344            "outcome": "refused",
1345            "effect": "deny",
1346            "latency_ms": 140,
1347            "judged_at": "2026-09-22T20:00:00Z"
1348        })
1349    }
1350
1351    #[test]
1352    fn judgement_valid_passes() {
1353        assert!(validate("judgement.v1", Some(&judgement_payload())).is_ok());
1354    }
1355
1356    #[test]
1357    fn judgement_rejects_unknown_outcome() {
1358        let mut p = judgement_payload();
1359        p["outcome"] = json!("shrugged");
1360        assert!(validate("judgement.v1", Some(&p)).is_err());
1361    }
1362
1363    #[test]
1364    fn judgement_rejects_unknown_question_type() {
1365        let mut p = judgement_payload();
1366        p["question"]["type"] = json!("essay");
1367        assert!(validate("judgement.v1", Some(&p)).is_err());
1368    }
1369
1370    #[test]
1371    fn judgement_requires_judge_model() {
1372        let mut p = judgement_payload();
1373        p["judge"] = json!({"provider": "typesafe"});
1374        assert!(validate("judgement.v1", Some(&p)).is_err());
1375    }
1376
1377    #[test]
1378    fn judgement_rejects_probability_out_of_range() {
1379        let mut p = judgement_payload();
1380        p["answer"]["noul"] = json!(1.4);
1381        assert!(validate("judgement.v1", Some(&p)).is_err());
1382    }
1383}