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