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