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