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 — *who* may perform *what* against
42/// *which subject*, *how many times*, and *until when*.
43///
44/// Treeship's verify pass enforces these constraints statelessly (every
45/// field except `max_actions` can be checked from the signed envelope
46/// alone). `max_actions` is signed into the grant so a future ledger /
47/// Hub layer can enforce single-use across the global view; for now it
48/// is descriptive, and verify reports the replay-check posture honestly
49/// rather than claiming enforcement that did not happen.
50///
51/// An empty `allowed_*` list means "no constraint on that axis."
52/// All-empty scope is equivalent to no scope at all (an unscoped /
53/// bearer approval) — which `verify` flags with a warning so callers
54/// know the binding is the only thing being attested.
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct ApprovalScope {
57    /// Maximum number of actions this approval authorises. Signed into
58    /// the grant for future stateful enforcement; not yet checked
59    /// statelessly.
60    #[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
61    pub max_actions: Option<u32>,
62
63    /// ISO 8601 timestamp after which the approval is no longer valid.
64    /// Independent of `ApprovalStatement.expires_at` so a single approval
65    /// can have an outer "key valid until X" and a tighter "scope valid
66    /// until Y" if the operator wants both. Verify enforces both.
67    #[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")]
68    pub valid_until: Option<String>,
69
70    /// Actor URIs permitted to consume this approval. Empty = no
71    /// constraint on actor.
72    #[serde(rename = "allowedActors", skip_serializing_if = "Vec::is_empty", default)]
73    pub allowed_actors: Vec<String>,
74
75    /// Action labels permitted under this approval. Empty = no
76    /// constraint on action.
77    #[serde(rename = "allowedActions", skip_serializing_if = "Vec::is_empty", default)]
78    pub allowed_actions: Vec<String>,
79
80    /// Subject URIs permitted as the target of an action under this
81    /// approval. Matched against `ActionStatement.subject.uri` (or
82    /// `artifact_id` for chain-internal subjects). Empty = no
83    /// constraint on subject.
84    #[serde(rename = "allowedSubjects", skip_serializing_if = "Vec::is_empty", default)]
85    pub allowed_subjects: Vec<String>,
86
87    /// Arbitrary additional constraints (e.g. max payment amount).
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub extra: Option<serde_json::Value>,
90}
91
92impl ApprovalScope {
93    /// True when no constraint axis is populated. An unscoped approval
94    /// proves only nonce binding -- it does NOT bind actor, action, or
95    /// subject. Verify warns when this is true so the audit reader
96    /// knows the limit of what was signed.
97    pub fn is_unscoped(&self) -> bool {
98        self.max_actions.is_none()
99            && self.valid_until.is_none()
100            && self.allowed_actors.is_empty()
101            && self.allowed_actions.is_empty()
102            && self.allowed_subjects.is_empty()
103            && self.extra.is_none()
104    }
105}
106
107/// Records that an actor performed an action.
108///
109/// This is the most common statement type — every tool call, API request,
110/// file write, or agent operation produces one.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct ActionStatement {
113    /// Always `TYPE_ACTION`
114    #[serde(rename = "type")]
115    pub type_: String,
116
117    /// RFC 3339 timestamp, set at sign time.
118    pub timestamp: String,
119
120    /// DID-style actor URI. e.g. "agent://researcher", "human://alice"
121    pub actor: String,
122
123    /// Dot-namespaced action label. e.g. "tool.call", "stripe.charge.create"
124    pub action: String,
125
126    #[serde(default, skip_serializing_if = "is_empty_subject")]
127    pub subject: SubjectRef,
128
129    /// Links this artifact to its parent in the chain.
130    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
131    pub parent_id: Option<String>,
132
133    /// Must match the `nonce` field of the approval authorising this action.
134    /// Provides cryptographic one-to-one binding between approval and action,
135    /// preventing approval reuse across multiple actions.
136    #[serde(rename = "approvalNonce", skip_serializing_if = "Option::is_none")]
137    pub approval_nonce: Option<String>,
138
139    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
140    pub policy_ref: Option<String>,
141
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub meta: Option<serde_json::Value>,
144}
145
146/// Records that an approver authorised an intent or action.
147///
148/// The `nonce` field is the cornerstone of approval security: the consuming
149/// `ActionStatement` must echo the same nonce in its `approval_nonce` field.
150/// This cryptographically binds each approval to exactly one action (or
151/// `max_actions` actions when set), preventing approval reuse.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ApprovalStatement {
154    #[serde(rename = "type")]
155    pub type_: String,
156    pub timestamp: String,
157
158    /// DID-style approver URI. e.g. "human://alice"
159    pub approver: String,
160
161    #[serde(default, skip_serializing_if = "is_empty_subject")]
162    pub subject: SubjectRef,
163
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub description: Option<String>,
166
167    /// ISO 8601 expiry timestamp. None means no expiry.
168    #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
169    pub expires_at: Option<String>,
170
171    /// Whether the receiving actor may re-delegate this approval.
172    pub delegatable: bool,
173
174    /// Random token. The consuming ActionStatement must set its
175    /// `approval_nonce` field to this value. Generated by the SDK if
176    /// not provided by the caller.
177    pub nonce: String,
178
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub scope: Option<ApprovalScope>,
181
182    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
183    pub policy_ref: Option<String>,
184
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub meta: Option<serde_json::Value>,
187}
188
189/// Records that work moved from one actor/domain to another.
190///
191/// This is the core of Treeship's multi-agent trust story. A handoff
192/// artifact proves custody transfer and carries inherited approvals.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct HandoffStatement {
195    #[serde(rename = "type")]
196    pub type_: String,
197    pub timestamp: String,
198
199    /// Source actor URI
200    pub from: String,
201    /// Destination actor URI
202    pub to: String,
203
204    /// IDs of artifacts being transferred
205    pub artifacts: Vec<String>,
206
207    /// Approval artifact IDs the receiving actor inherits
208    #[serde(rename = "approvalIds", default, skip_serializing_if = "Vec::is_empty")]
209    pub approval_ids: Vec<String>,
210
211    /// Constraints the receiving actor must satisfy
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    pub obligations: Vec<String>,
214
215    pub delegatable: bool,
216
217    #[serde(rename = "taskRef", skip_serializing_if = "Option::is_none")]
218    pub task_ref: Option<String>,
219
220    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
221    pub policy_ref: Option<String>,
222
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub meta: Option<serde_json::Value>,
225}
226
227/// Records that a signer asserts confidence about an existing artifact.
228///
229/// Used for post-hoc validation, compliance sign-off, countersignatures.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct EndorsementStatement {
232    #[serde(rename = "type")]
233    pub type_: String,
234    pub timestamp: String,
235
236    /// DID-style endorser URI
237    pub endorser: String,
238    pub subject: SubjectRef,
239
240    /// Endorsement category: "validation", "compliance", "countersignature",
241    /// "review", or any custom string.
242    pub kind: String,
243
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub rationale: Option<String>,
246
247    #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
248    pub expires_at: Option<String>,
249
250    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
251    pub policy_ref: Option<String>,
252
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub meta: Option<serde_json::Value>,
255}
256
257impl EndorsementStatement {
258    pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
259        Self {
260            type_: TYPE_ENDORSEMENT.into(),
261            timestamp: now_rfc3339(),
262            endorser: endorser.into(),
263            subject: SubjectRef::default(),
264            kind: kind.into(),
265            rationale: None,
266            expires_at: None,
267            policy_ref: None,
268            meta: None,
269        }
270    }
271}
272
273/// Records that an external system observed or confirmed an event.
274///
275/// Used for Stripe webhooks, RFC 3161 timestamps, inclusion proofs.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct ReceiptStatement {
278    #[serde(rename = "type")]
279    pub type_: String,
280    pub timestamp: String,
281
282    /// URI of the system producing this receipt.
283    /// e.g. "system://stripe-webhook", "system://tsauthority"
284    pub system: String,
285
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub subject: Option<SubjectRef>,
288
289    /// Receipt category: "confirmation", "timestamp", "inclusion", "webhook"
290    pub kind: String,
291
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub payload: Option<serde_json::Value>,
294
295    #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
296    pub payload_digest: Option<String>,
297
298    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
299    pub policy_ref: Option<String>,
300
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub meta: Option<serde_json::Value>,
303}
304
305/// A reference to one artifact within a bundle.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct ArtifactRef {
308    pub id:     String,
309    pub digest: String,
310    #[serde(rename = "type")]
311    pub type_:  String,
312}
313
314/// Groups a set of artifacts into a named, signed bundle.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct BundleStatement {
317    #[serde(rename = "type")]
318    pub type_: String,
319    pub timestamp: String,
320
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub tag: Option<String>,
323
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub description: Option<String>,
326
327    pub artifacts: Vec<ArtifactRef>,
328
329    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
330    pub policy_ref: Option<String>,
331
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub meta: Option<serde_json::Value>,
334}
335
336/// Records an agent's reasoning and decision context.
337///
338/// This is the "why" layer -- agents provide this explicitly to explain
339/// inference decisions, model usage, and confidence levels.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct DecisionStatement {
342    /// Always `TYPE_DECISION`
343    #[serde(rename = "type")]
344    pub type_: String,
345
346    /// RFC 3339 timestamp, set at sign time.
347    pub timestamp: String,
348
349    /// DID-style actor URI. e.g. "agent://analyst"
350    pub actor: String,
351
352    /// Links this artifact to its parent in the chain.
353    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
354    pub parent_id: Option<String>,
355
356    /// Model used for inference. e.g. "claude-opus-4"
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub model: Option<String>,
359
360    /// Model version if known.
361    #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
362    pub model_version: Option<String>,
363
364    /// Number of input tokens consumed.
365    #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
366    pub tokens_in: Option<u64>,
367
368    /// Number of output tokens produced.
369    #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
370    pub tokens_out: Option<u64>,
371
372    /// SHA-256 digest of the full prompt (not the prompt itself).
373    #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
374    pub prompt_digest: Option<String>,
375
376    /// Human-readable summary of the decision.
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub summary: Option<String>,
379
380    /// Confidence level 0.0-1.0 if the agent provides it.
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub confidence: Option<f64>,
383
384    /// Other options the agent considered.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub alternatives: Option<Vec<String>>,
387
388    /// Arbitrary additional metadata.
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub meta: Option<serde_json::Value>,
391}
392
393// Helpers for skip_serializing_if
394fn is_empty_subject(s: &SubjectRef) -> bool {
395    s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
396}
397
398// --- Constructors ---
399
400impl ActionStatement {
401    pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
402        Self {
403            type_: TYPE_ACTION.into(),
404            timestamp: now_rfc3339(),
405            actor: actor.into(),
406            action: action.into(),
407            subject: SubjectRef::default(),
408            parent_id: None,
409            approval_nonce: None,
410            policy_ref: None,
411            meta: None,
412        }
413    }
414}
415
416impl ApprovalStatement {
417    pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
418        Self {
419            type_: TYPE_APPROVAL.into(),
420            timestamp: now_rfc3339(),
421            approver: approver.into(),
422            subject: SubjectRef::default(),
423            description: None,
424            expires_at: None,
425            delegatable: false,
426            nonce: nonce.into(),
427            scope: None,
428            policy_ref: None,
429            meta: None,
430        }
431    }
432}
433
434impl HandoffStatement {
435    pub fn new(
436        from:      impl Into<String>,
437        to:        impl Into<String>,
438        artifacts: Vec<String>,
439    ) -> Self {
440        Self {
441            type_: TYPE_HANDOFF.into(),
442            timestamp: now_rfc3339(),
443            from: from.into(),
444            to: to.into(),
445            artifacts,
446            approval_ids: vec![],
447            obligations: vec![],
448            delegatable: false,
449            task_ref: None,
450            policy_ref: None,
451            meta: None,
452        }
453    }
454}
455
456impl ReceiptStatement {
457    pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
458        Self {
459            type_: TYPE_RECEIPT.into(),
460            timestamp: now_rfc3339(),
461            system: system.into(),
462            subject: None,
463            kind: kind.into(),
464            payload: None,
465            payload_digest: None,
466            policy_ref: None,
467            meta: None,
468        }
469    }
470}
471
472impl DecisionStatement {
473    pub fn new(actor: impl Into<String>) -> Self {
474        Self {
475            type_: TYPE_DECISION.into(),
476            timestamp: now_rfc3339(),
477            actor: actor.into(),
478            parent_id: None,
479            model: None,
480            model_version: None,
481            tokens_in: None,
482            tokens_out: None,
483            prompt_digest: None,
484            summary: None,
485            confidence: None,
486            alternatives: None,
487            meta: None,
488        }
489    }
490}
491
492fn now_rfc3339() -> String {
493    // std::time gives us duration since UNIX_EPOCH.
494    // Format as ISO 8601 / RFC 3339 without pulling in chrono.
495    use std::time::{SystemTime, UNIX_EPOCH};
496    let secs = SystemTime::now()
497        .duration_since(UNIX_EPOCH)
498        .unwrap_or_default()
499        .as_secs();
500    unix_to_rfc3339(secs)
501}
502
503pub fn unix_to_rfc3339(secs: u64) -> String {
504    // Minimal RFC 3339 formatter — no external deps.
505    // Accurate for dates 1970–2099.
506    let s = secs;
507    let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
508    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
509}
510
511fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
512    let sec  = s % 60;
513    let mins = s / 60;
514    let min  = mins % 60;
515    let hrs  = mins / 60;
516    let hour = hrs % 24;
517    let days = hrs / 24;
518
519    // Gregorian calendar calculation from day count
520    let (y, m, d) = days_to_ymd(days);
521    (y, m, d, hour, min, sec)
522}
523
524fn days_to_ymd(days: u64) -> (u64, u64, u64) {
525    // Days since 1970-01-01
526    let mut d = days;
527    let mut year = 1970u64;
528    loop {
529        let dy = if is_leap(year) { 366 } else { 365 };
530        if d < dy { break; }
531        d -= dy;
532        year += 1;
533    }
534    let months = if is_leap(year) {
535        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
536    } else {
537        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
538    };
539    let mut month = 1u64;
540    for dm in months {
541        if d < dm { break; }
542        d -= dm;
543        month += 1;
544    }
545    (year, month, d + 1)
546}
547
548fn is_leap(y: u64) -> bool {
549    (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::attestation::{sign, Ed25519Signer, Verifier};
556
557    #[test]
558    fn payload_type_format() {
559        assert_eq!(
560            payload_type("action"),
561            "application/vnd.treeship.action.v1+json"
562        );
563        assert_eq!(
564            payload_type("approval"),
565            "application/vnd.treeship.approval.v1+json"
566        );
567    }
568
569    #[test]
570    fn action_statement_sign_verify() {
571        let signer   = Ed25519Signer::generate("key_test").unwrap();
572        let verifier = Verifier::from_signer(&signer);
573
574        let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
575        stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
576
577        let pt     = payload_type("action");
578        let result = sign(&pt, &stmt, &signer).unwrap();
579
580        assert!(result.artifact_id.starts_with("art_"));
581
582        let vr = verifier.verify(&result.envelope).unwrap();
583        assert_eq!(vr.artifact_id, result.artifact_id);
584
585        // Decode and check the payload survived serialization
586        let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
587        assert_eq!(decoded.actor, "agent://researcher");
588        assert_eq!(decoded.action, "tool.call");
589        assert_eq!(decoded.type_, TYPE_ACTION);
590    }
591
592    #[test]
593    fn approval_statement_with_nonce() {
594        let signer = Ed25519Signer::generate("key_human").unwrap();
595
596        let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
597        approval.description = Some("approve laptop purchase < $1500".into());
598        approval.scope = Some(ApprovalScope {
599            max_actions: Some(1),
600            allowed_actions: vec!["stripe.payment_intent.create".into()],
601            ..Default::default()
602        });
603
604        let pt     = payload_type("approval");
605        let result = sign(&pt, &approval, &signer).unwrap();
606        assert!(result.artifact_id.starts_with("art_"));
607
608        let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
609        assert_eq!(decoded.nonce, "nonce_abc123");
610        assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
611    }
612
613    #[test]
614    fn approval_scope_full_grant_roundtrips() {
615        // Every scope axis populated -- the full "allowed_actors +
616        // allowed_actions + allowed_subjects + max_uses" grant must
617        // serialize, sign, deserialize, and read back identically.
618        let signer = Ed25519Signer::generate("key_piyush").unwrap();
619
620        let mut approval = ApprovalStatement::new("human://piyush", "nonce_deadbeef");
621        approval.description = Some("Deploy production after final review".into());
622        approval.scope = Some(ApprovalScope {
623            max_actions:      Some(1),
624            valid_until:      None,
625            allowed_actors:   vec!["agent://deployer".into()],
626            allowed_actions:  vec!["deploy.production".into()],
627            allowed_subjects: vec!["env://production".into()],
628            extra:            None,
629        });
630
631        let pt = payload_type("approval");
632        let result = sign(&pt, &approval, &signer).unwrap();
633        let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
634        let scope = decoded.scope.expect("scope must round-trip");
635
636        assert_eq!(scope.allowed_actors,   vec!["agent://deployer".to_string()]);
637        assert_eq!(scope.allowed_actions,  vec!["deploy.production".to_string()]);
638        assert_eq!(scope.allowed_subjects, vec!["env://production".to_string()]);
639        assert_eq!(scope.max_actions,      Some(1));
640    }
641
642    #[test]
643    fn approval_scope_is_unscoped_predicate() {
644        // Default scope = unscoped.
645        assert!(ApprovalScope::default().is_unscoped());
646
647        // Any single populated axis flips the predicate.
648        assert!(!ApprovalScope { max_actions: Some(1), ..Default::default() }.is_unscoped());
649        assert!(!ApprovalScope { valid_until: Some("2030-01-01T00:00:00Z".into()), ..Default::default() }.is_unscoped());
650        assert!(!ApprovalScope { allowed_actors:   vec!["agent://x".into()], ..Default::default() }.is_unscoped());
651        assert!(!ApprovalScope { allowed_actions:  vec!["doit".into()],      ..Default::default() }.is_unscoped());
652        assert!(!ApprovalScope { allowed_subjects: vec!["env://prod".into()], ..Default::default() }.is_unscoped());
653    }
654
655    #[test]
656    fn approval_scope_legacy_payloads_decode_with_empty_new_fields() {
657        // Pre-0.9.6 payloads that omitted allowed_actors / allowed_subjects
658        // must continue to deserialize cleanly. We construct the JSON shape
659        // directly to simulate an envelope from an older signer.
660        let legacy = serde_json::json!({
661            "maxActions": 1,
662            "allowedActions": ["stripe.payment_intent.create"]
663        });
664        let scope: ApprovalScope = serde_json::from_value(legacy).unwrap();
665        assert_eq!(scope.max_actions, Some(1));
666        assert_eq!(scope.allowed_actions, vec!["stripe.payment_intent.create".to_string()]);
667        // New fields default to empty -- not present in legacy payload.
668        assert!(scope.allowed_actors.is_empty());
669        assert!(scope.allowed_subjects.is_empty());
670        assert!(!scope.is_unscoped()); // because max_actions IS set
671    }
672
673    #[test]
674    fn handoff_statement() {
675        let signer = Ed25519Signer::generate("key_agent").unwrap();
676
677        let handoff = HandoffStatement::new(
678            "agent://researcher",
679            "agent://checkout",
680            vec!["art_aabbccdd11223344aabbccdd11223344".into()],
681        );
682
683        let pt     = payload_type("handoff");
684        let result = sign(&pt, &handoff, &signer).unwrap();
685        let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
686
687        assert_eq!(decoded.from, "agent://researcher");
688        assert_eq!(decoded.to,   "agent://checkout");
689        assert_eq!(decoded.artifacts.len(), 1);
690    }
691
692    #[test]
693    fn receipt_statement() {
694        let signer = Ed25519Signer::generate("key_system").unwrap();
695
696        let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
697        receipt.payload = Some(serde_json::json!({
698            "eventId": "evt_abc123",
699            "status": "succeeded"
700        }));
701
702        let pt     = payload_type("receipt");
703        let result = sign(&pt, &receipt, &signer).unwrap();
704        let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
705
706        assert_eq!(decoded.system, "system://stripe-webhook");
707        assert_eq!(decoded.kind,   "confirmation");
708    }
709
710    #[test]
711    fn nonce_binding_survives_serialization() {
712        let signer   = Ed25519Signer::generate("key_test").unwrap();
713
714        // The nonce in the approval must survive a sign→verify→decode round-trip.
715        // The verifier checks that action.approval_nonce == approval.nonce.
716        let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
717        let pt       = payload_type("approval");
718        let signed   = sign(&pt, &approval, &signer).unwrap();
719
720        let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
721        assert_eq!(decoded.nonce, "secure_nonce_xyz", "nonce must survive serialization");
722    }
723
724    #[test]
725    fn decision_statement_sign_verify() {
726        let signer = Ed25519Signer::generate("key_test").unwrap();
727        let verifier = Verifier::from_signer(&signer);
728
729        let mut stmt = DecisionStatement::new("agent://analyst");
730        stmt.model = Some("claude-opus-4".into());
731        stmt.tokens_in = Some(8432);
732        stmt.tokens_out = Some(1247);
733        stmt.summary = Some("Contract looks standard.".into());
734        stmt.confidence = Some(0.91);
735
736        let pt = payload_type("decision");
737        let result = sign(&pt, &stmt, &signer).unwrap();
738
739        assert!(result.artifact_id.starts_with("art_"));
740
741        let vr = verifier.verify(&result.envelope).unwrap();
742        assert_eq!(vr.artifact_id, result.artifact_id);
743
744        // Decode and check the payload survived serialization
745        let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
746        assert_eq!(decoded.actor, "agent://analyst");
747        assert_eq!(decoded.model, Some("claude-opus-4".into()));
748        assert_eq!(decoded.tokens_in, Some(8432));
749        assert_eq!(decoded.tokens_out, Some(1247));
750        assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
751        assert_eq!(decoded.confidence, Some(0.91));
752        assert_eq!(decoded.type_, TYPE_DECISION);
753    }
754
755    #[test]
756    fn different_statement_types_different_ids() {
757        // Action and approval with identical fields but different types
758        // must produce different artifact IDs — enforced by payloadType in PAE.
759        let signer = Ed25519Signer::generate("key_test").unwrap();
760
761        let action   = ActionStatement::new("agent://test", "do.thing");
762        let approval = ApprovalStatement::new("human://test", "nonce_123");
763
764        let r_action   = sign(&payload_type("action"),   &action,   &signer).unwrap();
765        let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
766
767        assert_ne!(r_action.artifact_id, r_approval.artifact_id);
768    }
769
770    #[test]
771    fn timestamp_format() {
772        let ts = unix_to_rfc3339(0);
773        assert_eq!(ts, "1970-01-01T00:00:00Z");
774
775        let ts2 = unix_to_rfc3339(1_000_000_000);
776        assert_eq!(ts2, "2001-09-09T01:46:40Z");
777    }
778}