Skip to main content

treeship_core/statements/
mod.rs

1/// Returns the canonical MIME payloadType for a statement type suffix.
2///
3/// ```
4/// use treeship_core::statements::payload_type;
5/// assert_eq!(
6///     payload_type("action"),
7///     "application/vnd.treeship.action.v1+json"
8/// );
9/// ```
10pub fn payload_type(suffix: &str) -> String {
11    format!("application/vnd.treeship.{}.v1+json", suffix)
12}
13
14pub const TYPE_ACTION:      &str = "treeship/action/v1";
15pub const TYPE_APPROVAL:    &str = "treeship/approval/v1";
16pub const TYPE_HANDOFF:     &str = "treeship/handoff/v1";
17pub const TYPE_ENDORSEMENT: &str = "treeship/endorsement/v1";
18pub const TYPE_RECEIPT:     &str = "treeship/receipt/v1";
19pub const TYPE_BUNDLE:      &str = "treeship/bundle/v1";
20pub const TYPE_DECISION:    &str = "treeship/decision/v1";
21
22use serde::{Deserialize, Serialize};
23
24/// A reference to content being attested, approved, or receipted.
25/// At least one field should be set.
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct SubjectRef {
28    /// Content hash: "sha256:<hex>" or "sha3:<hex>"
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub digest: Option<String>,
31
32    /// External URI to the content
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub uri: Option<String>,
35
36    /// ID of another Treeship artifact
37    #[serde(rename = "artifactId", skip_serializing_if = "Option::is_none")]
38    pub artifact_id: Option<String>,
39}
40
41/// Scope constraints on an approval — what it permits and for how long.
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct ApprovalScope {
44    /// Maximum number of actions this approval authorises.
45    #[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
46    pub max_actions: Option<u32>,
47
48    /// ISO 8601 timestamp after which the approval is no longer valid.
49    #[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")]
50    pub valid_until: Option<String>,
51
52    /// If set, only these action labels are permitted.
53    #[serde(rename = "allowedActions", skip_serializing_if = "Vec::is_empty", default)]
54    pub allowed_actions: Vec<String>,
55
56    /// Arbitrary additional constraints (e.g. max payment amount).
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub extra: Option<serde_json::Value>,
59}
60
61/// Records that an actor performed an action.
62///
63/// This is the most common statement type — every tool call, API request,
64/// file write, or agent operation produces one.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ActionStatement {
67    /// Always `TYPE_ACTION`
68    #[serde(rename = "type")]
69    pub type_: String,
70
71    /// RFC 3339 timestamp, set at sign time.
72    pub timestamp: String,
73
74    /// DID-style actor URI. e.g. "agent://researcher", "human://alice"
75    pub actor: String,
76
77    /// Dot-namespaced action label. e.g. "tool.call", "stripe.charge.create"
78    pub action: String,
79
80    #[serde(default, skip_serializing_if = "is_empty_subject")]
81    pub subject: SubjectRef,
82
83    /// Links this artifact to its parent in the chain.
84    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
85    pub parent_id: Option<String>,
86
87    /// Must match the `nonce` field of the approval authorising this action.
88    /// Provides cryptographic one-to-one binding between approval and action,
89    /// preventing approval reuse across multiple actions.
90    #[serde(rename = "approvalNonce", skip_serializing_if = "Option::is_none")]
91    pub approval_nonce: Option<String>,
92
93    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
94    pub policy_ref: Option<String>,
95
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub meta: Option<serde_json::Value>,
98}
99
100/// Records that an approver authorised an intent or action.
101///
102/// The `nonce` field is the cornerstone of approval security: the consuming
103/// `ActionStatement` must echo the same nonce in its `approval_nonce` field.
104/// This cryptographically binds each approval to exactly one action (or
105/// `max_actions` actions when set), preventing approval reuse.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ApprovalStatement {
108    #[serde(rename = "type")]
109    pub type_: String,
110    pub timestamp: String,
111
112    /// DID-style approver URI. e.g. "human://alice"
113    pub approver: String,
114
115    #[serde(default, skip_serializing_if = "is_empty_subject")]
116    pub subject: SubjectRef,
117
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub description: Option<String>,
120
121    /// ISO 8601 expiry timestamp. None means no expiry.
122    #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
123    pub expires_at: Option<String>,
124
125    /// Whether the receiving actor may re-delegate this approval.
126    pub delegatable: bool,
127
128    /// Random token. The consuming ActionStatement must set its
129    /// `approval_nonce` field to this value. Generated by the SDK if
130    /// not provided by the caller.
131    pub nonce: String,
132
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub scope: Option<ApprovalScope>,
135
136    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
137    pub policy_ref: Option<String>,
138
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub meta: Option<serde_json::Value>,
141}
142
143/// Records that work moved from one actor/domain to another.
144///
145/// This is the core of Treeship's multi-agent trust story. A handoff
146/// artifact proves custody transfer and carries inherited approvals.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct HandoffStatement {
149    #[serde(rename = "type")]
150    pub type_: String,
151    pub timestamp: String,
152
153    /// Source actor URI
154    pub from: String,
155    /// Destination actor URI
156    pub to: String,
157
158    /// IDs of artifacts being transferred
159    pub artifacts: Vec<String>,
160
161    /// Approval artifact IDs the receiving actor inherits
162    #[serde(rename = "approvalIds", default, skip_serializing_if = "Vec::is_empty")]
163    pub approval_ids: Vec<String>,
164
165    /// Constraints the receiving actor must satisfy
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub obligations: Vec<String>,
168
169    pub delegatable: bool,
170
171    #[serde(rename = "taskRef", skip_serializing_if = "Option::is_none")]
172    pub task_ref: Option<String>,
173
174    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
175    pub policy_ref: Option<String>,
176
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub meta: Option<serde_json::Value>,
179}
180
181/// Records that a signer asserts confidence about an existing artifact.
182///
183/// Used for post-hoc validation, compliance sign-off, countersignatures.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct EndorsementStatement {
186    #[serde(rename = "type")]
187    pub type_: String,
188    pub timestamp: String,
189
190    /// DID-style endorser URI
191    pub endorser: String,
192    pub subject: SubjectRef,
193
194    /// Endorsement category: "validation", "compliance", "countersignature",
195    /// "review", or any custom string.
196    pub kind: String,
197
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub rationale: Option<String>,
200
201    #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
202    pub expires_at: Option<String>,
203
204    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
205    pub policy_ref: Option<String>,
206
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub meta: Option<serde_json::Value>,
209}
210
211impl EndorsementStatement {
212    pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
213        Self {
214            type_: TYPE_ENDORSEMENT.into(),
215            timestamp: now_rfc3339(),
216            endorser: endorser.into(),
217            subject: SubjectRef::default(),
218            kind: kind.into(),
219            rationale: None,
220            expires_at: None,
221            policy_ref: None,
222            meta: None,
223        }
224    }
225}
226
227/// Records that an external system observed or confirmed an event.
228///
229/// Used for Stripe webhooks, RFC 3161 timestamps, inclusion proofs.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ReceiptStatement {
232    #[serde(rename = "type")]
233    pub type_: String,
234    pub timestamp: String,
235
236    /// URI of the system producing this receipt.
237    /// e.g. "system://stripe-webhook", "system://tsauthority"
238    pub system: String,
239
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub subject: Option<SubjectRef>,
242
243    /// Receipt category: "confirmation", "timestamp", "inclusion", "webhook"
244    pub kind: String,
245
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub payload: Option<serde_json::Value>,
248
249    #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
250    pub payload_digest: Option<String>,
251
252    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
253    pub policy_ref: Option<String>,
254
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub meta: Option<serde_json::Value>,
257}
258
259/// A reference to one artifact within a bundle.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct ArtifactRef {
262    pub id:     String,
263    pub digest: String,
264    #[serde(rename = "type")]
265    pub type_:  String,
266}
267
268/// Groups a set of artifacts into a named, signed bundle.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct BundleStatement {
271    #[serde(rename = "type")]
272    pub type_: String,
273    pub timestamp: String,
274
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub tag: Option<String>,
277
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub description: Option<String>,
280
281    pub artifacts: Vec<ArtifactRef>,
282
283    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
284    pub policy_ref: Option<String>,
285
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub meta: Option<serde_json::Value>,
288}
289
290/// Records an agent's reasoning and decision context.
291///
292/// This is the "why" layer -- agents provide this explicitly to explain
293/// inference decisions, model usage, and confidence levels.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct DecisionStatement {
296    /// Always `TYPE_DECISION`
297    #[serde(rename = "type")]
298    pub type_: String,
299
300    /// RFC 3339 timestamp, set at sign time.
301    pub timestamp: String,
302
303    /// DID-style actor URI. e.g. "agent://analyst"
304    pub actor: String,
305
306    /// Links this artifact to its parent in the chain.
307    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
308    pub parent_id: Option<String>,
309
310    /// Model used for inference. e.g. "claude-opus-4"
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub model: Option<String>,
313
314    /// Model version if known.
315    #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
316    pub model_version: Option<String>,
317
318    /// Number of input tokens consumed.
319    #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
320    pub tokens_in: Option<u64>,
321
322    /// Number of output tokens produced.
323    #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
324    pub tokens_out: Option<u64>,
325
326    /// SHA-256 digest of the full prompt (not the prompt itself).
327    #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
328    pub prompt_digest: Option<String>,
329
330    /// Human-readable summary of the decision.
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub summary: Option<String>,
333
334    /// Confidence level 0.0-1.0 if the agent provides it.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub confidence: Option<f64>,
337
338    /// Other options the agent considered.
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub alternatives: Option<Vec<String>>,
341
342    /// Arbitrary additional metadata.
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub meta: Option<serde_json::Value>,
345}
346
347// Helpers for skip_serializing_if
348fn is_empty_subject(s: &SubjectRef) -> bool {
349    s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
350}
351
352// --- Constructors ---
353
354impl ActionStatement {
355    pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
356        Self {
357            type_: TYPE_ACTION.into(),
358            timestamp: now_rfc3339(),
359            actor: actor.into(),
360            action: action.into(),
361            subject: SubjectRef::default(),
362            parent_id: None,
363            approval_nonce: None,
364            policy_ref: None,
365            meta: None,
366        }
367    }
368}
369
370impl ApprovalStatement {
371    pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
372        Self {
373            type_: TYPE_APPROVAL.into(),
374            timestamp: now_rfc3339(),
375            approver: approver.into(),
376            subject: SubjectRef::default(),
377            description: None,
378            expires_at: None,
379            delegatable: false,
380            nonce: nonce.into(),
381            scope: None,
382            policy_ref: None,
383            meta: None,
384        }
385    }
386}
387
388impl HandoffStatement {
389    pub fn new(
390        from:      impl Into<String>,
391        to:        impl Into<String>,
392        artifacts: Vec<String>,
393    ) -> Self {
394        Self {
395            type_: TYPE_HANDOFF.into(),
396            timestamp: now_rfc3339(),
397            from: from.into(),
398            to: to.into(),
399            artifacts,
400            approval_ids: vec![],
401            obligations: vec![],
402            delegatable: false,
403            task_ref: None,
404            policy_ref: None,
405            meta: None,
406        }
407    }
408}
409
410impl ReceiptStatement {
411    pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
412        Self {
413            type_: TYPE_RECEIPT.into(),
414            timestamp: now_rfc3339(),
415            system: system.into(),
416            subject: None,
417            kind: kind.into(),
418            payload: None,
419            payload_digest: None,
420            policy_ref: None,
421            meta: None,
422        }
423    }
424}
425
426impl DecisionStatement {
427    pub fn new(actor: impl Into<String>) -> Self {
428        Self {
429            type_: TYPE_DECISION.into(),
430            timestamp: now_rfc3339(),
431            actor: actor.into(),
432            parent_id: None,
433            model: None,
434            model_version: None,
435            tokens_in: None,
436            tokens_out: None,
437            prompt_digest: None,
438            summary: None,
439            confidence: None,
440            alternatives: None,
441            meta: None,
442        }
443    }
444}
445
446fn now_rfc3339() -> String {
447    // std::time gives us duration since UNIX_EPOCH.
448    // Format as ISO 8601 / RFC 3339 without pulling in chrono.
449    use std::time::{SystemTime, UNIX_EPOCH};
450    let secs = SystemTime::now()
451        .duration_since(UNIX_EPOCH)
452        .unwrap_or_default()
453        .as_secs();
454    unix_to_rfc3339(secs)
455}
456
457pub fn unix_to_rfc3339(secs: u64) -> String {
458    // Minimal RFC 3339 formatter — no external deps.
459    // Accurate for dates 1970–2099.
460    let s = secs;
461    let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
462    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
463}
464
465fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
466    let sec  = s % 60;
467    let mins = s / 60;
468    let min  = mins % 60;
469    let hrs  = mins / 60;
470    let hour = hrs % 24;
471    let days = hrs / 24;
472
473    // Gregorian calendar calculation from day count
474    let (y, m, d) = days_to_ymd(days);
475    (y, m, d, hour, min, sec)
476}
477
478fn days_to_ymd(days: u64) -> (u64, u64, u64) {
479    // Days since 1970-01-01
480    let mut d = days;
481    let mut year = 1970u64;
482    loop {
483        let dy = if is_leap(year) { 366 } else { 365 };
484        if d < dy { break; }
485        d -= dy;
486        year += 1;
487    }
488    let months = if is_leap(year) {
489        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
490    } else {
491        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
492    };
493    let mut month = 1u64;
494    for dm in months {
495        if d < dm { break; }
496        d -= dm;
497        month += 1;
498    }
499    (year, month, d + 1)
500}
501
502fn is_leap(y: u64) -> bool {
503    (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::attestation::{sign, Ed25519Signer, Verifier};
510
511    #[test]
512    fn payload_type_format() {
513        assert_eq!(
514            payload_type("action"),
515            "application/vnd.treeship.action.v1+json"
516        );
517        assert_eq!(
518            payload_type("approval"),
519            "application/vnd.treeship.approval.v1+json"
520        );
521    }
522
523    #[test]
524    fn action_statement_sign_verify() {
525        let signer   = Ed25519Signer::generate("key_test").unwrap();
526        let verifier = Verifier::from_signer(&signer);
527
528        let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
529        stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
530
531        let pt     = payload_type("action");
532        let result = sign(&pt, &stmt, &signer).unwrap();
533
534        assert!(result.artifact_id.starts_with("art_"));
535
536        let vr = verifier.verify(&result.envelope).unwrap();
537        assert_eq!(vr.artifact_id, result.artifact_id);
538
539        // Decode and check the payload survived serialization
540        let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
541        assert_eq!(decoded.actor, "agent://researcher");
542        assert_eq!(decoded.action, "tool.call");
543        assert_eq!(decoded.type_, TYPE_ACTION);
544    }
545
546    #[test]
547    fn approval_statement_with_nonce() {
548        let signer = Ed25519Signer::generate("key_human").unwrap();
549
550        let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
551        approval.description = Some("approve laptop purchase < $1500".into());
552        approval.scope = Some(ApprovalScope {
553            max_actions: Some(1),
554            allowed_actions: vec!["stripe.payment_intent.create".into()],
555            ..Default::default()
556        });
557
558        let pt     = payload_type("approval");
559        let result = sign(&pt, &approval, &signer).unwrap();
560        assert!(result.artifact_id.starts_with("art_"));
561
562        let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
563        assert_eq!(decoded.nonce, "nonce_abc123");
564        assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
565    }
566
567    #[test]
568    fn handoff_statement() {
569        let signer = Ed25519Signer::generate("key_agent").unwrap();
570
571        let handoff = HandoffStatement::new(
572            "agent://researcher",
573            "agent://checkout",
574            vec!["art_aabbccdd11223344aabbccdd11223344".into()],
575        );
576
577        let pt     = payload_type("handoff");
578        let result = sign(&pt, &handoff, &signer).unwrap();
579        let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
580
581        assert_eq!(decoded.from, "agent://researcher");
582        assert_eq!(decoded.to,   "agent://checkout");
583        assert_eq!(decoded.artifacts.len(), 1);
584    }
585
586    #[test]
587    fn receipt_statement() {
588        let signer = Ed25519Signer::generate("key_system").unwrap();
589
590        let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
591        receipt.payload = Some(serde_json::json!({
592            "eventId": "evt_abc123",
593            "status": "succeeded"
594        }));
595
596        let pt     = payload_type("receipt");
597        let result = sign(&pt, &receipt, &signer).unwrap();
598        let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
599
600        assert_eq!(decoded.system, "system://stripe-webhook");
601        assert_eq!(decoded.kind,   "confirmation");
602    }
603
604    #[test]
605    fn nonce_binding_survives_serialization() {
606        let signer   = Ed25519Signer::generate("key_test").unwrap();
607
608        // The nonce in the approval must survive a sign→verify→decode round-trip.
609        // The verifier checks that action.approval_nonce == approval.nonce.
610        let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
611        let pt       = payload_type("approval");
612        let signed   = sign(&pt, &approval, &signer).unwrap();
613
614        let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
615        assert_eq!(decoded.nonce, "secure_nonce_xyz", "nonce must survive serialization");
616    }
617
618    #[test]
619    fn decision_statement_sign_verify() {
620        let signer = Ed25519Signer::generate("key_test").unwrap();
621        let verifier = Verifier::from_signer(&signer);
622
623        let mut stmt = DecisionStatement::new("agent://analyst");
624        stmt.model = Some("claude-opus-4".into());
625        stmt.tokens_in = Some(8432);
626        stmt.tokens_out = Some(1247);
627        stmt.summary = Some("Contract looks standard.".into());
628        stmt.confidence = Some(0.91);
629
630        let pt = payload_type("decision");
631        let result = sign(&pt, &stmt, &signer).unwrap();
632
633        assert!(result.artifact_id.starts_with("art_"));
634
635        let vr = verifier.verify(&result.envelope).unwrap();
636        assert_eq!(vr.artifact_id, result.artifact_id);
637
638        // Decode and check the payload survived serialization
639        let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
640        assert_eq!(decoded.actor, "agent://analyst");
641        assert_eq!(decoded.model, Some("claude-opus-4".into()));
642        assert_eq!(decoded.tokens_in, Some(8432));
643        assert_eq!(decoded.tokens_out, Some(1247));
644        assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
645        assert_eq!(decoded.confidence, Some(0.91));
646        assert_eq!(decoded.type_, TYPE_DECISION);
647    }
648
649    #[test]
650    fn different_statement_types_different_ids() {
651        // Action and approval with identical fields but different types
652        // must produce different artifact IDs — enforced by payloadType in PAE.
653        let signer = Ed25519Signer::generate("key_test").unwrap();
654
655        let action   = ActionStatement::new("agent://test", "do.thing");
656        let approval = ApprovalStatement::new("human://test", "nonce_123");
657
658        let r_action   = sign(&payload_type("action"),   &action,   &signer).unwrap();
659        let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
660
661        assert_ne!(r_action.artifact_id, r_approval.artifact_id);
662    }
663
664    #[test]
665    fn timestamp_format() {
666        let ts = unix_to_rfc3339(0);
667        assert_eq!(ts, "1970-01-01T00:00:00Z");
668
669        let ts2 = unix_to_rfc3339(1_000_000_000);
670        assert_eq!(ts2, "2001-09-09T01:46:40Z");
671    }
672}