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
51pub mod action_v2;
57pub use action_v2::{
58 action_in_scope, payload_type_v2, verify_effect, verify_grant_chain, verify_mandate,
59 ActionStatementV2, Cost, Effect, EffectConfidence, EffectVerdict, Grant, GrantChainError,
60 Mandate, MandateVerdict, NoRevocationSource, NoWitnessAuthority, Revocation, RevocationSource,
61 RevocationStatus, RuntimeIdentity, Witness, WitnessAuthority, TYPE_ACTION_V2,
62};
63
64use serde::{Deserialize, Serialize};
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69pub struct SubjectRef {
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub digest: Option<String>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub uri: Option<String>,
77
78 #[serde(rename = "artifactId", skip_serializing_if = "Option::is_none")]
80 pub artifact_id: Option<String>,
81}
82
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct ApprovalScope {
99 #[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
103 pub max_actions: Option<u32>,
104
105 #[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")]
110 pub valid_until: Option<String>,
111
112 #[serde(
115 rename = "allowedActors",
116 skip_serializing_if = "Vec::is_empty",
117 default
118 )]
119 pub allowed_actors: Vec<String>,
120
121 #[serde(
124 rename = "allowedActions",
125 skip_serializing_if = "Vec::is_empty",
126 default
127 )]
128 pub allowed_actions: Vec<String>,
129
130 #[serde(
135 rename = "allowedSubjects",
136 skip_serializing_if = "Vec::is_empty",
137 default
138 )]
139 pub allowed_subjects: Vec<String>,
140
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub extra: Option<serde_json::Value>,
144}
145
146impl ApprovalScope {
147 pub fn is_unscoped(&self) -> bool {
152 self.max_actions.is_none()
153 && self.valid_until.is_none()
154 && self.allowed_actors.is_empty()
155 && self.allowed_actions.is_empty()
156 && self.allowed_subjects.is_empty()
157 && self.extra.is_none()
158 }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ActionStatement {
167 #[serde(rename = "type")]
169 pub type_: String,
170
171 pub timestamp: String,
173
174 pub actor: String,
176
177 pub action: String,
179
180 #[serde(default, skip_serializing_if = "is_empty_subject")]
181 pub subject: SubjectRef,
182
183 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
185 pub parent_id: Option<String>,
186
187 #[serde(rename = "approvalNonce", skip_serializing_if = "Option::is_none")]
191 pub approval_nonce: Option<String>,
192
193 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
194 pub policy_ref: Option<String>,
195
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub meta: Option<serde_json::Value>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ApprovalStatement {
208 #[serde(rename = "type")]
209 pub type_: String,
210 pub timestamp: String,
211
212 pub approver: String,
214
215 #[serde(default, skip_serializing_if = "is_empty_subject")]
216 pub subject: SubjectRef,
217
218 #[serde(skip_serializing_if = "Option::is_none")]
219 pub description: Option<String>,
220
221 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
223 pub expires_at: Option<String>,
224
225 pub delegatable: bool,
227
228 pub nonce: String,
232
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub scope: Option<ApprovalScope>,
235
236 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
237 pub policy_ref: Option<String>,
238
239 #[serde(skip_serializing_if = "Option::is_none")]
246 pub irreversibility: Option<String>,
247
248 #[serde(rename = "quarantineReceipt", skip_serializing_if = "Option::is_none")]
253 pub quarantine_receipt: Option<String>,
254
255 #[serde(skip_serializing_if = "Option::is_none")]
256 pub meta: Option<serde_json::Value>,
257}
258
259pub const IRREVERSIBILITY_CLASSES: &[&str] = &[
265 "two_way",
266 "one_way_recoverable",
267 "one_way_consequential",
268 "one_way_terminal",
269];
270
271pub fn is_irreversibility_class(class: &str) -> bool {
273 IRREVERSIBILITY_CLASSES.contains(&class)
274}
275
276pub fn irreversibility_requires_quarantine(class: &str) -> bool {
280 !matches!(class, "two_way" | "one_way_recoverable")
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct HandoffStatement {
289 #[serde(rename = "type")]
290 pub type_: String,
291 pub timestamp: String,
292
293 pub from: String,
295 pub to: String,
297
298 pub artifacts: Vec<String>,
300
301 #[serde(rename = "approvalIds", default, skip_serializing_if = "Vec::is_empty")]
303 pub approval_ids: Vec<String>,
304
305 #[serde(default, skip_serializing_if = "Vec::is_empty")]
307 pub obligations: Vec<String>,
308
309 pub delegatable: bool,
310
311 #[serde(rename = "taskRef", skip_serializing_if = "Option::is_none")]
312 pub task_ref: Option<String>,
313
314 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
315 pub policy_ref: Option<String>,
316
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub meta: Option<serde_json::Value>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct EndorsementStatement {
326 #[serde(rename = "type")]
327 pub type_: String,
328 pub timestamp: String,
329
330 pub endorser: String,
332 pub subject: SubjectRef,
333
334 pub kind: String,
337
338 #[serde(skip_serializing_if = "Option::is_none")]
339 pub rationale: Option<String>,
340
341 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
342 pub expires_at: Option<String>,
343
344 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
345 pub policy_ref: Option<String>,
346
347 #[serde(skip_serializing_if = "Option::is_none")]
348 pub meta: Option<serde_json::Value>,
349}
350
351impl EndorsementStatement {
352 pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
353 Self {
354 type_: TYPE_ENDORSEMENT.into(),
355 timestamp: now_rfc3339(),
356 endorser: endorser.into(),
357 subject: SubjectRef::default(),
358 kind: kind.into(),
359 rationale: None,
360 expires_at: None,
361 policy_ref: None,
362 meta: None,
363 }
364 }
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct ReceiptStatement {
372 #[serde(rename = "type")]
373 pub type_: String,
374 pub timestamp: String,
375
376 pub system: String,
379
380 #[serde(skip_serializing_if = "Option::is_none")]
381 pub subject: Option<SubjectRef>,
382
383 pub kind: String,
385
386 #[serde(skip_serializing_if = "Option::is_none")]
387 pub payload: Option<serde_json::Value>,
388
389 #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
390 pub payload_digest: Option<String>,
391
392 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
393 pub policy_ref: Option<String>,
394
395 #[serde(skip_serializing_if = "Option::is_none")]
396 pub meta: Option<serde_json::Value>,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct ArtifactRef {
402 pub id: String,
403 pub digest: String,
404 #[serde(rename = "type")]
405 pub type_: String,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct BundleStatement {
411 #[serde(rename = "type")]
412 pub type_: String,
413 pub timestamp: String,
414
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub tag: Option<String>,
417
418 #[serde(skip_serializing_if = "Option::is_none")]
419 pub description: Option<String>,
420
421 pub artifacts: Vec<ArtifactRef>,
422
423 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
424 pub policy_ref: Option<String>,
425
426 #[serde(skip_serializing_if = "Option::is_none")]
427 pub meta: Option<serde_json::Value>,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct DecisionStatement {
436 #[serde(rename = "type")]
438 pub type_: String,
439
440 pub timestamp: String,
442
443 pub actor: String,
445
446 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
448 pub parent_id: Option<String>,
449
450 #[serde(skip_serializing_if = "Option::is_none")]
452 pub model: Option<String>,
453
454 #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
456 pub model_version: Option<String>,
457
458 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub provider: Option<String>,
473
474 #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
476 pub tokens_in: Option<u64>,
477
478 #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
480 pub tokens_out: Option<u64>,
481
482 #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
484 pub prompt_digest: Option<String>,
485
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub summary: Option<String>,
489
490 #[serde(skip_serializing_if = "Option::is_none")]
492 pub confidence: Option<f64>,
493
494 #[serde(skip_serializing_if = "Option::is_none")]
496 pub alternatives: Option<Vec<String>>,
497
498 #[serde(skip_serializing_if = "Option::is_none")]
500 pub meta: Option<serde_json::Value>,
501}
502
503fn is_empty_subject(s: &SubjectRef) -> bool {
505 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
506}
507
508impl ActionStatement {
511 pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
512 Self {
513 type_: TYPE_ACTION.into(),
514 timestamp: now_rfc3339(),
515 actor: actor.into(),
516 action: action.into(),
517 subject: SubjectRef::default(),
518 parent_id: None,
519 approval_nonce: None,
520 policy_ref: None,
521 meta: None,
522 }
523 }
524}
525
526impl ApprovalStatement {
527 pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
528 Self {
529 type_: TYPE_APPROVAL.into(),
530 timestamp: now_rfc3339(),
531 approver: approver.into(),
532 subject: SubjectRef::default(),
533 description: None,
534 expires_at: None,
535 delegatable: false,
536 nonce: nonce.into(),
537 scope: None,
538 policy_ref: None,
539 irreversibility: None,
540 quarantine_receipt: None,
541 meta: None,
542 }
543 }
544}
545
546impl HandoffStatement {
547 pub fn new(from: impl Into<String>, to: impl Into<String>, artifacts: Vec<String>) -> Self {
548 Self {
549 type_: TYPE_HANDOFF.into(),
550 timestamp: now_rfc3339(),
551 from: from.into(),
552 to: to.into(),
553 artifacts,
554 approval_ids: vec![],
555 obligations: vec![],
556 delegatable: false,
557 task_ref: None,
558 policy_ref: None,
559 meta: None,
560 }
561 }
562}
563
564impl ReceiptStatement {
565 pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
566 Self {
567 type_: TYPE_RECEIPT.into(),
568 timestamp: now_rfc3339(),
569 system: system.into(),
570 subject: None,
571 kind: kind.into(),
572 payload: None,
573 payload_digest: None,
574 policy_ref: None,
575 meta: None,
576 }
577 }
578}
579
580impl DecisionStatement {
581 pub fn new(actor: impl Into<String>) -> Self {
582 Self {
583 type_: TYPE_DECISION.into(),
584 timestamp: now_rfc3339(),
585 actor: actor.into(),
586 parent_id: None,
587 model: None,
588 model_version: None,
589 provider: None,
590 tokens_in: None,
591 tokens_out: None,
592 prompt_digest: None,
593 summary: None,
594 confidence: None,
595 alternatives: None,
596 meta: None,
597 }
598 }
599}
600
601fn now_rfc3339() -> String {
602 use std::time::{SystemTime, UNIX_EPOCH};
605 let secs = SystemTime::now()
606 .duration_since(UNIX_EPOCH)
607 .unwrap_or_default()
608 .as_secs();
609 unix_to_rfc3339(secs)
610}
611
612pub fn unix_to_rfc3339(secs: u64) -> String {
613 let s = secs;
616 let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
617 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
618}
619
620fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
621 let sec = s % 60;
622 let mins = s / 60;
623 let min = mins % 60;
624 let hrs = mins / 60;
625 let hour = hrs % 24;
626 let days = hrs / 24;
627
628 let (y, m, d) = days_to_ymd(days);
630 (y, m, d, hour, min, sec)
631}
632
633fn days_to_ymd(days: u64) -> (u64, u64, u64) {
634 let mut d = days;
636 let mut year = 1970u64;
637 loop {
638 let dy = if is_leap(year) { 366 } else { 365 };
639 if d < dy {
640 break;
641 }
642 d -= dy;
643 year += 1;
644 }
645 let months = if is_leap(year) {
646 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
647 } else {
648 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
649 };
650 let mut month = 1u64;
651 for dm in months {
652 if d < dm {
653 break;
654 }
655 d -= dm;
656 month += 1;
657 }
658 (year, month, d + 1)
659}
660
661fn is_leap(y: u64) -> bool {
662 (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668 use crate::attestation::{sign, Ed25519Signer, Verifier};
669
670 #[test]
671 fn payload_type_format() {
672 assert_eq!(
673 payload_type("action"),
674 "application/vnd.treeship.action.v1+json"
675 );
676 assert_eq!(
677 payload_type("approval"),
678 "application/vnd.treeship.approval.v1+json"
679 );
680 }
681
682 #[test]
683 fn action_statement_sign_verify() {
684 let signer = Ed25519Signer::generate("key_test").unwrap();
685 let verifier = Verifier::from_signer(&signer);
686
687 let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
688 stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
689
690 let pt = payload_type("action");
691 let result = sign(&pt, &stmt, &signer).unwrap();
692
693 assert!(result.artifact_id.starts_with("art_"));
694
695 let vr = verifier.verify(&result.envelope).unwrap();
696 assert_eq!(vr.artifact_id, result.artifact_id);
697
698 let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
700 assert_eq!(decoded.actor, "agent://researcher");
701 assert_eq!(decoded.action, "tool.call");
702 assert_eq!(decoded.type_, TYPE_ACTION);
703 }
704
705 #[test]
706 fn approval_statement_with_nonce() {
707 let signer = Ed25519Signer::generate("key_human").unwrap();
708
709 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
710 approval.description = Some("approve laptop purchase < $1500".into());
711 approval.scope = Some(ApprovalScope {
712 max_actions: Some(1),
713 allowed_actions: vec!["stripe.payment_intent.create".into()],
714 ..Default::default()
715 });
716
717 let pt = payload_type("approval");
718 let result = sign(&pt, &approval, &signer).unwrap();
719 assert!(result.artifact_id.starts_with("art_"));
720
721 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
722 assert_eq!(decoded.nonce, "nonce_abc123");
723 assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
724 }
725
726 #[test]
727 fn approval_without_irreversibility_keeps_canonical_bytes() {
728 let approval = ApprovalStatement::new("human://alice", "nonce_abc123");
732 let bytes = serde_json::to_string(&approval).unwrap();
733 assert!(!bytes.contains("irreversibility"));
734 assert!(!bytes.contains("quarantineReceipt"));
735 }
736
737 #[test]
738 fn approval_irreversibility_fields_roundtrip_signed() {
739 let signer = Ed25519Signer::generate("key_human").unwrap();
740 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
741 approval.irreversibility = Some("one_way_consequential".into());
742 approval.quarantine_receipt = Some("art_deadbeef00112233".into());
743
744 let pt = payload_type("approval");
745 let result = sign(&pt, &approval, &signer).unwrap();
746 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
747 assert_eq!(
748 decoded.irreversibility.as_deref(),
749 Some("one_way_consequential")
750 );
751 assert_eq!(
752 decoded.quarantine_receipt.as_deref(),
753 Some("art_deadbeef00112233")
754 );
755 }
756
757 #[test]
758 fn irreversibility_vocabulary_is_closed_and_fails_strict() {
759 for c in IRREVERSIBILITY_CLASSES {
760 assert!(is_irreversibility_class(c));
761 }
762 assert!(!is_irreversibility_class("reversible"));
763 assert!(!is_irreversibility_class(""));
764 assert!(!irreversibility_requires_quarantine("two_way"));
766 assert!(!irreversibility_requires_quarantine("one_way_recoverable"));
767 assert!(irreversibility_requires_quarantine("one_way_consequential"));
768 assert!(irreversibility_requires_quarantine("one_way_terminal"));
769 assert!(irreversibility_requires_quarantine("definitely_fine_trust_me"));
771 }
772
773 #[test]
774 fn approval_scope_full_grant_roundtrips() {
775 let signer = Ed25519Signer::generate("key_piyush").unwrap();
779
780 let mut approval = ApprovalStatement::new("human://piyush", "nonce_deadbeef");
781 approval.description = Some("Deploy production after final review".into());
782 approval.scope = Some(ApprovalScope {
783 max_actions: Some(1),
784 valid_until: None,
785 allowed_actors: vec!["agent://deployer".into()],
786 allowed_actions: vec!["deploy.production".into()],
787 allowed_subjects: vec!["env://production".into()],
788 extra: None,
789 });
790
791 let pt = payload_type("approval");
792 let result = sign(&pt, &approval, &signer).unwrap();
793 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
794 let scope = decoded.scope.expect("scope must round-trip");
795
796 assert_eq!(scope.allowed_actors, vec!["agent://deployer".to_string()]);
797 assert_eq!(scope.allowed_actions, vec!["deploy.production".to_string()]);
798 assert_eq!(scope.allowed_subjects, vec!["env://production".to_string()]);
799 assert_eq!(scope.max_actions, Some(1));
800 }
801
802 #[test]
803 fn approval_scope_is_unscoped_predicate() {
804 assert!(ApprovalScope::default().is_unscoped());
806
807 assert!(!ApprovalScope {
809 max_actions: Some(1),
810 ..Default::default()
811 }
812 .is_unscoped());
813 assert!(!ApprovalScope {
814 valid_until: Some("2030-01-01T00:00:00Z".into()),
815 ..Default::default()
816 }
817 .is_unscoped());
818 assert!(!ApprovalScope {
819 allowed_actors: vec!["agent://x".into()],
820 ..Default::default()
821 }
822 .is_unscoped());
823 assert!(!ApprovalScope {
824 allowed_actions: vec!["doit".into()],
825 ..Default::default()
826 }
827 .is_unscoped());
828 assert!(!ApprovalScope {
829 allowed_subjects: vec!["env://prod".into()],
830 ..Default::default()
831 }
832 .is_unscoped());
833 }
834
835 #[test]
836 fn approval_scope_legacy_payloads_decode_with_empty_new_fields() {
837 let legacy = serde_json::json!({
841 "maxActions": 1,
842 "allowedActions": ["stripe.payment_intent.create"]
843 });
844 let scope: ApprovalScope = serde_json::from_value(legacy).unwrap();
845 assert_eq!(scope.max_actions, Some(1));
846 assert_eq!(
847 scope.allowed_actions,
848 vec!["stripe.payment_intent.create".to_string()]
849 );
850 assert!(scope.allowed_actors.is_empty());
852 assert!(scope.allowed_subjects.is_empty());
853 assert!(!scope.is_unscoped()); }
855
856 #[test]
857 fn handoff_statement() {
858 let signer = Ed25519Signer::generate("key_agent").unwrap();
859
860 let handoff = HandoffStatement::new(
861 "agent://researcher",
862 "agent://checkout",
863 vec!["art_aabbccdd11223344aabbccdd11223344".into()],
864 );
865
866 let pt = payload_type("handoff");
867 let result = sign(&pt, &handoff, &signer).unwrap();
868 let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
869
870 assert_eq!(decoded.from, "agent://researcher");
871 assert_eq!(decoded.to, "agent://checkout");
872 assert_eq!(decoded.artifacts.len(), 1);
873 }
874
875 #[test]
876 fn receipt_statement() {
877 let signer = Ed25519Signer::generate("key_system").unwrap();
878
879 let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
880 receipt.payload = Some(serde_json::json!({
881 "eventId": "evt_abc123",
882 "status": "succeeded"
883 }));
884
885 let pt = payload_type("receipt");
886 let result = sign(&pt, &receipt, &signer).unwrap();
887 let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
888
889 assert_eq!(decoded.system, "system://stripe-webhook");
890 assert_eq!(decoded.kind, "confirmation");
891 }
892
893 #[test]
894 fn nonce_binding_survives_serialization() {
895 let signer = Ed25519Signer::generate("key_test").unwrap();
896
897 let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
900 let pt = payload_type("approval");
901 let signed = sign(&pt, &approval, &signer).unwrap();
902
903 let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
904 assert_eq!(
905 decoded.nonce, "secure_nonce_xyz",
906 "nonce must survive serialization"
907 );
908 }
909
910 #[test]
911 fn decision_statement_sign_verify() {
912 let signer = Ed25519Signer::generate("key_test").unwrap();
913 let verifier = Verifier::from_signer(&signer);
914
915 let mut stmt = DecisionStatement::new("agent://analyst");
916 stmt.model = Some("claude-opus-4".into());
917 stmt.tokens_in = Some(8432);
918 stmt.tokens_out = Some(1247);
919 stmt.summary = Some("Contract looks standard.".into());
920 stmt.confidence = Some(0.91);
921
922 let pt = payload_type("decision");
923 let result = sign(&pt, &stmt, &signer).unwrap();
924
925 assert!(result.artifact_id.starts_with("art_"));
926
927 let vr = verifier.verify(&result.envelope).unwrap();
928 assert_eq!(vr.artifact_id, result.artifact_id);
929
930 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
932 assert_eq!(decoded.actor, "agent://analyst");
933 assert_eq!(decoded.model, Some("claude-opus-4".into()));
934 assert_eq!(decoded.tokens_in, Some(8432));
935 assert_eq!(decoded.tokens_out, Some(1247));
936 assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
937 assert_eq!(decoded.confidence, Some(0.91));
938 assert_eq!(decoded.type_, TYPE_DECISION);
939 }
940
941 #[test]
942 fn decision_statement_provider_roundtrips() {
943 let signer = Ed25519Signer::generate("key_test").unwrap();
947 let verifier = Verifier::from_signer(&signer);
948
949 let mut stmt = DecisionStatement::new("agent://researcher");
950 stmt.model = Some("kimi-k2".into());
951 stmt.provider = Some("moonshot".into());
952
953 let pt = payload_type("decision");
954 let result = sign(&pt, &stmt, &signer).unwrap();
955 verifier.verify(&result.envelope).unwrap();
956
957 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
958 assert_eq!(decoded.model, Some("kimi-k2".into()));
959 assert_eq!(decoded.provider, Some("moonshot".into()));
960 }
961
962 #[test]
963 fn decision_statement_legacy_payload_without_provider_decodes() {
964 let raw = serde_json::json!({
970 "type": TYPE_DECISION,
971 "timestamp": "2026-04-30T12:00:00Z",
972 "actor": "agent://legacy",
973 "model": "claude-opus-4",
974 });
975 let parsed: DecisionStatement = serde_json::from_value(raw).unwrap();
976 assert_eq!(parsed.model, Some("claude-opus-4".into()));
977 assert_eq!(parsed.provider, None);
978 }
979
980 #[test]
981 fn different_statement_types_different_ids() {
982 let signer = Ed25519Signer::generate("key_test").unwrap();
985
986 let action = ActionStatement::new("agent://test", "do.thing");
987 let approval = ApprovalStatement::new("human://test", "nonce_123");
988
989 let r_action = sign(&payload_type("action"), &action, &signer).unwrap();
990 let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
991
992 assert_ne!(r_action.artifact_id, r_approval.artifact_id);
993 }
994
995 #[test]
996 fn timestamp_format() {
997 let ts = unix_to_rfc3339(0);
998 assert_eq!(ts, "1970-01-01T00:00:00Z");
999
1000 let ts2 = unix_to_rfc3339(1_000_000_000);
1001 assert_eq!(ts2, "2001-09-09T01:46:40Z");
1002 }
1003}