1pub 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
22mod approval_use;
26pub use approval_use::{
27 approval_revocation_record_digest, approval_use_record_digest,
28 journal_checkpoint_record_digest, nonce_digest, verify_hub_checkpoint_signature,
29 ApprovalRevocation, ApprovalUse, CheckpointKind, HubCheckpointVerification, JournalCheckpoint,
30 ReplayCheck, ReplayCheckLevel, TYPE_APPROVAL_REVOCATION, TYPE_APPROVAL_USE,
31 TYPE_JOURNAL_CHECKPOINT,
32};
33
34pub mod invitation;
40pub mod session_participant;
41pub use invitation::{
42 parse_rfc3339_to_unix, GrantedCapabilities, InvitationError, InvitationStatement,
43 InviteeRestriction, DEFAULT_INVITATION_LIFETIME_SECS, MAX_INVITATION_LIFETIME_SECS,
44 TYPE_INVITATION,
45};
46pub use session_participant::{
47 verify_participant_envelope, ParticipantVerifyError, SessionParticipantStatement,
48 TYPE_SESSION_PARTICIPANT,
49};
50
51use serde::{Deserialize, Serialize};
52
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct SubjectRef {
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub digest: Option<String>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub uri: Option<String>,
64
65 #[serde(rename = "artifactId", skip_serializing_if = "Option::is_none")]
67 pub artifact_id: Option<String>,
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85pub struct ApprovalScope {
86 #[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
90 pub max_actions: Option<u32>,
91
92 #[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")]
97 pub valid_until: Option<String>,
98
99 #[serde(
102 rename = "allowedActors",
103 skip_serializing_if = "Vec::is_empty",
104 default
105 )]
106 pub allowed_actors: Vec<String>,
107
108 #[serde(
111 rename = "allowedActions",
112 skip_serializing_if = "Vec::is_empty",
113 default
114 )]
115 pub allowed_actions: Vec<String>,
116
117 #[serde(
122 rename = "allowedSubjects",
123 skip_serializing_if = "Vec::is_empty",
124 default
125 )]
126 pub allowed_subjects: Vec<String>,
127
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub extra: Option<serde_json::Value>,
131}
132
133impl ApprovalScope {
134 pub fn is_unscoped(&self) -> bool {
139 self.max_actions.is_none()
140 && self.valid_until.is_none()
141 && self.allowed_actors.is_empty()
142 && self.allowed_actions.is_empty()
143 && self.allowed_subjects.is_empty()
144 && self.extra.is_none()
145 }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ActionStatement {
154 #[serde(rename = "type")]
156 pub type_: String,
157
158 pub timestamp: String,
160
161 pub actor: String,
163
164 pub action: String,
166
167 #[serde(default, skip_serializing_if = "is_empty_subject")]
168 pub subject: SubjectRef,
169
170 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
172 pub parent_id: Option<String>,
173
174 #[serde(rename = "approvalNonce", skip_serializing_if = "Option::is_none")]
178 pub approval_nonce: Option<String>,
179
180 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
181 pub policy_ref: Option<String>,
182
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub meta: Option<serde_json::Value>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct ApprovalStatement {
195 #[serde(rename = "type")]
196 pub type_: String,
197 pub timestamp: String,
198
199 pub approver: String,
201
202 #[serde(default, skip_serializing_if = "is_empty_subject")]
203 pub subject: SubjectRef,
204
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub description: Option<String>,
207
208 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
210 pub expires_at: Option<String>,
211
212 pub delegatable: bool,
214
215 pub nonce: String,
219
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub scope: Option<ApprovalScope>,
222
223 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
224 pub policy_ref: Option<String>,
225
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub meta: Option<serde_json::Value>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct HandoffStatement {
236 #[serde(rename = "type")]
237 pub type_: String,
238 pub timestamp: String,
239
240 pub from: String,
242 pub to: String,
244
245 pub artifacts: Vec<String>,
247
248 #[serde(rename = "approvalIds", default, skip_serializing_if = "Vec::is_empty")]
250 pub approval_ids: Vec<String>,
251
252 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 pub obligations: Vec<String>,
255
256 pub delegatable: bool,
257
258 #[serde(rename = "taskRef", skip_serializing_if = "Option::is_none")]
259 pub task_ref: Option<String>,
260
261 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
262 pub policy_ref: Option<String>,
263
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub meta: Option<serde_json::Value>,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct EndorsementStatement {
273 #[serde(rename = "type")]
274 pub type_: String,
275 pub timestamp: String,
276
277 pub endorser: String,
279 pub subject: SubjectRef,
280
281 pub kind: String,
284
285 #[serde(skip_serializing_if = "Option::is_none")]
286 pub rationale: Option<String>,
287
288 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
289 pub expires_at: Option<String>,
290
291 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
292 pub policy_ref: Option<String>,
293
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub meta: Option<serde_json::Value>,
296}
297
298impl EndorsementStatement {
299 pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
300 Self {
301 type_: TYPE_ENDORSEMENT.into(),
302 timestamp: now_rfc3339(),
303 endorser: endorser.into(),
304 subject: SubjectRef::default(),
305 kind: kind.into(),
306 rationale: None,
307 expires_at: None,
308 policy_ref: None,
309 meta: None,
310 }
311 }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct ReceiptStatement {
319 #[serde(rename = "type")]
320 pub type_: String,
321 pub timestamp: String,
322
323 pub system: String,
326
327 #[serde(skip_serializing_if = "Option::is_none")]
328 pub subject: Option<SubjectRef>,
329
330 pub kind: String,
332
333 #[serde(skip_serializing_if = "Option::is_none")]
334 pub payload: Option<serde_json::Value>,
335
336 #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
337 pub payload_digest: Option<String>,
338
339 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
340 pub policy_ref: Option<String>,
341
342 #[serde(skip_serializing_if = "Option::is_none")]
343 pub meta: Option<serde_json::Value>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct ArtifactRef {
349 pub id: String,
350 pub digest: String,
351 #[serde(rename = "type")]
352 pub type_: String,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct BundleStatement {
358 #[serde(rename = "type")]
359 pub type_: String,
360 pub timestamp: String,
361
362 #[serde(skip_serializing_if = "Option::is_none")]
363 pub tag: Option<String>,
364
365 #[serde(skip_serializing_if = "Option::is_none")]
366 pub description: Option<String>,
367
368 pub artifacts: Vec<ArtifactRef>,
369
370 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
371 pub policy_ref: Option<String>,
372
373 #[serde(skip_serializing_if = "Option::is_none")]
374 pub meta: Option<serde_json::Value>,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct DecisionStatement {
383 #[serde(rename = "type")]
385 pub type_: String,
386
387 pub timestamp: String,
389
390 pub actor: String,
392
393 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
395 pub parent_id: Option<String>,
396
397 #[serde(skip_serializing_if = "Option::is_none")]
399 pub model: Option<String>,
400
401 #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
403 pub model_version: Option<String>,
404
405 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub provider: Option<String>,
420
421 #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
423 pub tokens_in: Option<u64>,
424
425 #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
427 pub tokens_out: Option<u64>,
428
429 #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
431 pub prompt_digest: Option<String>,
432
433 #[serde(skip_serializing_if = "Option::is_none")]
435 pub summary: Option<String>,
436
437 #[serde(skip_serializing_if = "Option::is_none")]
439 pub confidence: Option<f64>,
440
441 #[serde(skip_serializing_if = "Option::is_none")]
443 pub alternatives: Option<Vec<String>>,
444
445 #[serde(skip_serializing_if = "Option::is_none")]
447 pub meta: Option<serde_json::Value>,
448}
449
450fn is_empty_subject(s: &SubjectRef) -> bool {
452 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
453}
454
455impl ActionStatement {
458 pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
459 Self {
460 type_: TYPE_ACTION.into(),
461 timestamp: now_rfc3339(),
462 actor: actor.into(),
463 action: action.into(),
464 subject: SubjectRef::default(),
465 parent_id: None,
466 approval_nonce: None,
467 policy_ref: None,
468 meta: None,
469 }
470 }
471}
472
473impl ApprovalStatement {
474 pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
475 Self {
476 type_: TYPE_APPROVAL.into(),
477 timestamp: now_rfc3339(),
478 approver: approver.into(),
479 subject: SubjectRef::default(),
480 description: None,
481 expires_at: None,
482 delegatable: false,
483 nonce: nonce.into(),
484 scope: None,
485 policy_ref: None,
486 meta: None,
487 }
488 }
489}
490
491impl HandoffStatement {
492 pub fn new(from: impl Into<String>, to: impl Into<String>, artifacts: Vec<String>) -> Self {
493 Self {
494 type_: TYPE_HANDOFF.into(),
495 timestamp: now_rfc3339(),
496 from: from.into(),
497 to: to.into(),
498 artifacts,
499 approval_ids: vec![],
500 obligations: vec![],
501 delegatable: false,
502 task_ref: None,
503 policy_ref: None,
504 meta: None,
505 }
506 }
507}
508
509impl ReceiptStatement {
510 pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
511 Self {
512 type_: TYPE_RECEIPT.into(),
513 timestamp: now_rfc3339(),
514 system: system.into(),
515 subject: None,
516 kind: kind.into(),
517 payload: None,
518 payload_digest: None,
519 policy_ref: None,
520 meta: None,
521 }
522 }
523}
524
525impl DecisionStatement {
526 pub fn new(actor: impl Into<String>) -> Self {
527 Self {
528 type_: TYPE_DECISION.into(),
529 timestamp: now_rfc3339(),
530 actor: actor.into(),
531 parent_id: None,
532 model: None,
533 model_version: None,
534 provider: None,
535 tokens_in: None,
536 tokens_out: None,
537 prompt_digest: None,
538 summary: None,
539 confidence: None,
540 alternatives: None,
541 meta: None,
542 }
543 }
544}
545
546fn now_rfc3339() -> String {
547 use std::time::{SystemTime, UNIX_EPOCH};
550 let secs = SystemTime::now()
551 .duration_since(UNIX_EPOCH)
552 .unwrap_or_default()
553 .as_secs();
554 unix_to_rfc3339(secs)
555}
556
557pub fn unix_to_rfc3339(secs: u64) -> String {
558 let s = secs;
561 let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
562 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
563}
564
565fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
566 let sec = s % 60;
567 let mins = s / 60;
568 let min = mins % 60;
569 let hrs = mins / 60;
570 let hour = hrs % 24;
571 let days = hrs / 24;
572
573 let (y, m, d) = days_to_ymd(days);
575 (y, m, d, hour, min, sec)
576}
577
578fn days_to_ymd(days: u64) -> (u64, u64, u64) {
579 let mut d = days;
581 let mut year = 1970u64;
582 loop {
583 let dy = if is_leap(year) { 366 } else { 365 };
584 if d < dy {
585 break;
586 }
587 d -= dy;
588 year += 1;
589 }
590 let months = if is_leap(year) {
591 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
592 } else {
593 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
594 };
595 let mut month = 1u64;
596 for dm in months {
597 if d < dm {
598 break;
599 }
600 d -= dm;
601 month += 1;
602 }
603 (year, month, d + 1)
604}
605
606fn is_leap(y: u64) -> bool {
607 (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use crate::attestation::{sign, Ed25519Signer, Verifier};
614
615 #[test]
616 fn payload_type_format() {
617 assert_eq!(
618 payload_type("action"),
619 "application/vnd.treeship.action.v1+json"
620 );
621 assert_eq!(
622 payload_type("approval"),
623 "application/vnd.treeship.approval.v1+json"
624 );
625 }
626
627 #[test]
628 fn action_statement_sign_verify() {
629 let signer = Ed25519Signer::generate("key_test").unwrap();
630 let verifier = Verifier::from_signer(&signer);
631
632 let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
633 stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
634
635 let pt = payload_type("action");
636 let result = sign(&pt, &stmt, &signer).unwrap();
637
638 assert!(result.artifact_id.starts_with("art_"));
639
640 let vr = verifier.verify(&result.envelope).unwrap();
641 assert_eq!(vr.artifact_id, result.artifact_id);
642
643 let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
645 assert_eq!(decoded.actor, "agent://researcher");
646 assert_eq!(decoded.action, "tool.call");
647 assert_eq!(decoded.type_, TYPE_ACTION);
648 }
649
650 #[test]
651 fn approval_statement_with_nonce() {
652 let signer = Ed25519Signer::generate("key_human").unwrap();
653
654 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
655 approval.description = Some("approve laptop purchase < $1500".into());
656 approval.scope = Some(ApprovalScope {
657 max_actions: Some(1),
658 allowed_actions: vec!["stripe.payment_intent.create".into()],
659 ..Default::default()
660 });
661
662 let pt = payload_type("approval");
663 let result = sign(&pt, &approval, &signer).unwrap();
664 assert!(result.artifact_id.starts_with("art_"));
665
666 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
667 assert_eq!(decoded.nonce, "nonce_abc123");
668 assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
669 }
670
671 #[test]
672 fn approval_scope_full_grant_roundtrips() {
673 let signer = Ed25519Signer::generate("key_piyush").unwrap();
677
678 let mut approval = ApprovalStatement::new("human://piyush", "nonce_deadbeef");
679 approval.description = Some("Deploy production after final review".into());
680 approval.scope = Some(ApprovalScope {
681 max_actions: Some(1),
682 valid_until: None,
683 allowed_actors: vec!["agent://deployer".into()],
684 allowed_actions: vec!["deploy.production".into()],
685 allowed_subjects: vec!["env://production".into()],
686 extra: None,
687 });
688
689 let pt = payload_type("approval");
690 let result = sign(&pt, &approval, &signer).unwrap();
691 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
692 let scope = decoded.scope.expect("scope must round-trip");
693
694 assert_eq!(scope.allowed_actors, vec!["agent://deployer".to_string()]);
695 assert_eq!(scope.allowed_actions, vec!["deploy.production".to_string()]);
696 assert_eq!(scope.allowed_subjects, vec!["env://production".to_string()]);
697 assert_eq!(scope.max_actions, Some(1));
698 }
699
700 #[test]
701 fn approval_scope_is_unscoped_predicate() {
702 assert!(ApprovalScope::default().is_unscoped());
704
705 assert!(!ApprovalScope {
707 max_actions: Some(1),
708 ..Default::default()
709 }
710 .is_unscoped());
711 assert!(!ApprovalScope {
712 valid_until: Some("2030-01-01T00:00:00Z".into()),
713 ..Default::default()
714 }
715 .is_unscoped());
716 assert!(!ApprovalScope {
717 allowed_actors: vec!["agent://x".into()],
718 ..Default::default()
719 }
720 .is_unscoped());
721 assert!(!ApprovalScope {
722 allowed_actions: vec!["doit".into()],
723 ..Default::default()
724 }
725 .is_unscoped());
726 assert!(!ApprovalScope {
727 allowed_subjects: vec!["env://prod".into()],
728 ..Default::default()
729 }
730 .is_unscoped());
731 }
732
733 #[test]
734 fn approval_scope_legacy_payloads_decode_with_empty_new_fields() {
735 let legacy = serde_json::json!({
739 "maxActions": 1,
740 "allowedActions": ["stripe.payment_intent.create"]
741 });
742 let scope: ApprovalScope = serde_json::from_value(legacy).unwrap();
743 assert_eq!(scope.max_actions, Some(1));
744 assert_eq!(
745 scope.allowed_actions,
746 vec!["stripe.payment_intent.create".to_string()]
747 );
748 assert!(scope.allowed_actors.is_empty());
750 assert!(scope.allowed_subjects.is_empty());
751 assert!(!scope.is_unscoped()); }
753
754 #[test]
755 fn handoff_statement() {
756 let signer = Ed25519Signer::generate("key_agent").unwrap();
757
758 let handoff = HandoffStatement::new(
759 "agent://researcher",
760 "agent://checkout",
761 vec!["art_aabbccdd11223344aabbccdd11223344".into()],
762 );
763
764 let pt = payload_type("handoff");
765 let result = sign(&pt, &handoff, &signer).unwrap();
766 let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
767
768 assert_eq!(decoded.from, "agent://researcher");
769 assert_eq!(decoded.to, "agent://checkout");
770 assert_eq!(decoded.artifacts.len(), 1);
771 }
772
773 #[test]
774 fn receipt_statement() {
775 let signer = Ed25519Signer::generate("key_system").unwrap();
776
777 let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
778 receipt.payload = Some(serde_json::json!({
779 "eventId": "evt_abc123",
780 "status": "succeeded"
781 }));
782
783 let pt = payload_type("receipt");
784 let result = sign(&pt, &receipt, &signer).unwrap();
785 let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
786
787 assert_eq!(decoded.system, "system://stripe-webhook");
788 assert_eq!(decoded.kind, "confirmation");
789 }
790
791 #[test]
792 fn nonce_binding_survives_serialization() {
793 let signer = Ed25519Signer::generate("key_test").unwrap();
794
795 let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
798 let pt = payload_type("approval");
799 let signed = sign(&pt, &approval, &signer).unwrap();
800
801 let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
802 assert_eq!(
803 decoded.nonce, "secure_nonce_xyz",
804 "nonce must survive serialization"
805 );
806 }
807
808 #[test]
809 fn decision_statement_sign_verify() {
810 let signer = Ed25519Signer::generate("key_test").unwrap();
811 let verifier = Verifier::from_signer(&signer);
812
813 let mut stmt = DecisionStatement::new("agent://analyst");
814 stmt.model = Some("claude-opus-4".into());
815 stmt.tokens_in = Some(8432);
816 stmt.tokens_out = Some(1247);
817 stmt.summary = Some("Contract looks standard.".into());
818 stmt.confidence = Some(0.91);
819
820 let pt = payload_type("decision");
821 let result = sign(&pt, &stmt, &signer).unwrap();
822
823 assert!(result.artifact_id.starts_with("art_"));
824
825 let vr = verifier.verify(&result.envelope).unwrap();
826 assert_eq!(vr.artifact_id, result.artifact_id);
827
828 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
830 assert_eq!(decoded.actor, "agent://analyst");
831 assert_eq!(decoded.model, Some("claude-opus-4".into()));
832 assert_eq!(decoded.tokens_in, Some(8432));
833 assert_eq!(decoded.tokens_out, Some(1247));
834 assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
835 assert_eq!(decoded.confidence, Some(0.91));
836 assert_eq!(decoded.type_, TYPE_DECISION);
837 }
838
839 #[test]
840 fn decision_statement_provider_roundtrips() {
841 let signer = Ed25519Signer::generate("key_test").unwrap();
845 let verifier = Verifier::from_signer(&signer);
846
847 let mut stmt = DecisionStatement::new("agent://researcher");
848 stmt.model = Some("kimi-k2".into());
849 stmt.provider = Some("moonshot".into());
850
851 let pt = payload_type("decision");
852 let result = sign(&pt, &stmt, &signer).unwrap();
853 verifier.verify(&result.envelope).unwrap();
854
855 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
856 assert_eq!(decoded.model, Some("kimi-k2".into()));
857 assert_eq!(decoded.provider, Some("moonshot".into()));
858 }
859
860 #[test]
861 fn decision_statement_legacy_payload_without_provider_decodes() {
862 let raw = serde_json::json!({
868 "type": TYPE_DECISION,
869 "timestamp": "2026-04-30T12:00:00Z",
870 "actor": "agent://legacy",
871 "model": "claude-opus-4",
872 });
873 let parsed: DecisionStatement = serde_json::from_value(raw).unwrap();
874 assert_eq!(parsed.model, Some("claude-opus-4".into()));
875 assert_eq!(parsed.provider, None);
876 }
877
878 #[test]
879 fn different_statement_types_different_ids() {
880 let signer = Ed25519Signer::generate("key_test").unwrap();
883
884 let action = ActionStatement::new("agent://test", "do.thing");
885 let approval = ApprovalStatement::new("human://test", "nonce_123");
886
887 let r_action = sign(&payload_type("action"), &action, &signer).unwrap();
888 let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
889
890 assert_ne!(r_action.artifact_id, r_approval.artifact_id);
891 }
892
893 #[test]
894 fn timestamp_format() {
895 let ts = unix_to_rfc3339(0);
896 assert_eq!(ts, "1970-01-01T00:00:00Z");
897
898 let ts2 = unix_to_rfc3339(1_000_000_000);
899 assert_eq!(ts2, "2001-09-09T01:46:40Z");
900 }
901}