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