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