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