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 session_liveness;
26pub use session_liveness::{LivenessVerdict, SessionLivenessStatement, TYPE_SESSION_LIVENESS};
27
28mod approval_use;
29pub use approval_use::{
30 approval_revocation_record_digest, approval_use_record_digest,
31 journal_checkpoint_record_digest, nonce_digest, verify_hub_checkpoint_signature,
32 ApprovalRevocation, ApprovalUse, CheckpointKind, HubCheckpointVerification, JournalCheckpoint,
33 ReplayCheck, ReplayCheckLevel, TYPE_APPROVAL_REVOCATION, TYPE_APPROVAL_USE,
34 TYPE_JOURNAL_CHECKPOINT,
35};
36
37pub mod invitation;
43pub mod session_participant;
44pub use invitation::{
45 parse_rfc3339_to_unix, GrantedCapabilities, InvitationError, InvitationStatement,
46 InviteeRestriction, DEFAULT_INVITATION_LIFETIME_SECS, MAX_INVITATION_LIFETIME_SECS,
47 TYPE_INVITATION,
48};
49pub use session_participant::{
50 verify_participant_envelope, ParticipantVerifyError, SessionParticipantStatement,
51 TYPE_SESSION_PARTICIPANT,
52};
53
54pub mod action_v2;
60pub use action_v2::{
61 action_in_scope, check_resolution, payload_type_v2, resolve_grant_chain, verify_effect,
62 verify_grant_chain, verify_mandate, ActionStatementV2, ChainResolveError, Cost, DeadlineEvent,
63 Effect, EffectConfidence, EffectFinality, EffectVerdict, Grant, GrantChainError, Mandate,
64 MandateVerdict, NoRevocationSource, NoWitnessAuthority, Resolution, ResolutionStatus,
65 Revocation, RevocationSource, RevocationStatus, RuntimeIdentity, Witness, WitnessAuthority,
66 TYPE_ACTION_V2,
67};
68
69use serde::{Deserialize, Serialize};
70
71#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct SubjectRef {
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub digest: Option<String>,
78
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub uri: Option<String>,
82
83 #[serde(rename = "artifactId", skip_serializing_if = "Option::is_none")]
85 pub artifact_id: Option<String>,
86}
87
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct ApprovalScope {
104 #[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
108 pub max_actions: Option<u32>,
109
110 #[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")]
115 pub valid_until: Option<String>,
116
117 #[serde(
120 rename = "allowedActors",
121 skip_serializing_if = "Vec::is_empty",
122 default
123 )]
124 pub allowed_actors: Vec<String>,
125
126 #[serde(
129 rename = "allowedActions",
130 skip_serializing_if = "Vec::is_empty",
131 default
132 )]
133 pub allowed_actions: Vec<String>,
134
135 #[serde(
140 rename = "allowedSubjects",
141 skip_serializing_if = "Vec::is_empty",
142 default
143 )]
144 pub allowed_subjects: Vec<String>,
145
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub extra: Option<serde_json::Value>,
149}
150
151impl ApprovalScope {
152 pub fn is_unscoped(&self) -> bool {
157 self.max_actions.is_none()
158 && self.valid_until.is_none()
159 && self.allowed_actors.is_empty()
160 && self.allowed_actions.is_empty()
161 && self.allowed_subjects.is_empty()
162 && self.extra.is_none()
163 }
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ActionStatement {
172 #[serde(rename = "type")]
174 pub type_: String,
175
176 pub timestamp: String,
178
179 pub actor: String,
181
182 pub action: String,
184
185 #[serde(default, skip_serializing_if = "is_empty_subject")]
186 pub subject: SubjectRef,
187
188 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
190 pub parent_id: Option<String>,
191
192 #[serde(rename = "approvalNonce", skip_serializing_if = "Option::is_none")]
196 pub approval_nonce: Option<String>,
197
198 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
199 pub policy_ref: Option<String>,
200
201 #[serde(skip_serializing_if = "Option::is_none")]
202 pub meta: Option<serde_json::Value>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct ApprovalStatement {
213 #[serde(rename = "type")]
214 pub type_: String,
215 pub timestamp: String,
216
217 pub approver: String,
219
220 #[serde(default, skip_serializing_if = "is_empty_subject")]
221 pub subject: SubjectRef,
222
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub description: Option<String>,
225
226 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
228 pub expires_at: Option<String>,
229
230 pub delegatable: bool,
232
233 pub nonce: String,
237
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub scope: Option<ApprovalScope>,
240
241 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
242 pub policy_ref: Option<String>,
243
244 #[serde(skip_serializing_if = "Option::is_none")]
251 pub irreversibility: Option<String>,
252
253 #[serde(rename = "quarantineReceipt", skip_serializing_if = "Option::is_none")]
258 pub quarantine_receipt: Option<String>,
259
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub meta: Option<serde_json::Value>,
262}
263
264pub const IRREVERSIBILITY_CLASSES: &[&str] = &[
270 "two_way",
271 "one_way_recoverable",
272 "one_way_consequential",
273 "one_way_terminal",
274];
275
276pub fn is_irreversibility_class(class: &str) -> bool {
278 IRREVERSIBILITY_CLASSES.contains(&class)
279}
280
281pub fn irreversibility_requires_quarantine(class: &str) -> bool {
285 !matches!(class, "two_way" | "one_way_recoverable")
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct HandoffStatement {
294 #[serde(rename = "type")]
295 pub type_: String,
296 pub timestamp: String,
297
298 pub from: String,
300 pub to: String,
302
303 pub artifacts: Vec<String>,
305
306 #[serde(rename = "approvalIds", default, skip_serializing_if = "Vec::is_empty")]
308 pub approval_ids: Vec<String>,
309
310 #[serde(default, skip_serializing_if = "Vec::is_empty")]
312 pub obligations: Vec<String>,
313
314 pub delegatable: bool,
315
316 #[serde(rename = "taskRef", skip_serializing_if = "Option::is_none")]
317 pub task_ref: Option<String>,
318
319 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
320 pub policy_ref: Option<String>,
321
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub meta: Option<serde_json::Value>,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct EndorsementStatement {
331 #[serde(rename = "type")]
332 pub type_: String,
333 pub timestamp: String,
334
335 pub endorser: String,
337 pub subject: SubjectRef,
338
339 pub kind: String,
342
343 #[serde(skip_serializing_if = "Option::is_none")]
344 pub rationale: Option<String>,
345
346 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
347 pub expires_at: Option<String>,
348
349 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
350 pub policy_ref: Option<String>,
351
352 #[serde(skip_serializing_if = "Option::is_none")]
353 pub meta: Option<serde_json::Value>,
354}
355
356impl EndorsementStatement {
357 pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
358 Self {
359 type_: TYPE_ENDORSEMENT.into(),
360 timestamp: now_rfc3339(),
361 endorser: endorser.into(),
362 subject: SubjectRef::default(),
363 kind: kind.into(),
364 rationale: None,
365 expires_at: None,
366 policy_ref: None,
367 meta: None,
368 }
369 }
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct ReceiptStatement {
377 #[serde(rename = "type")]
378 pub type_: String,
379 pub timestamp: String,
380
381 pub system: String,
384
385 #[serde(skip_serializing_if = "Option::is_none")]
386 pub subject: Option<SubjectRef>,
387
388 pub kind: String,
390
391 #[serde(skip_serializing_if = "Option::is_none")]
392 pub payload: Option<serde_json::Value>,
393
394 #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
395 pub payload_digest: Option<String>,
396
397 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
398 pub policy_ref: Option<String>,
399
400 #[serde(skip_serializing_if = "Option::is_none")]
401 pub meta: Option<serde_json::Value>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct ArtifactRef {
407 pub id: String,
408 pub digest: String,
409 #[serde(rename = "type")]
410 pub type_: String,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct BundleStatement {
416 #[serde(rename = "type")]
417 pub type_: String,
418 pub timestamp: String,
419
420 #[serde(skip_serializing_if = "Option::is_none")]
421 pub tag: Option<String>,
422
423 #[serde(skip_serializing_if = "Option::is_none")]
424 pub description: Option<String>,
425
426 pub artifacts: Vec<ArtifactRef>,
427
428 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
429 pub policy_ref: Option<String>,
430
431 #[serde(skip_serializing_if = "Option::is_none")]
432 pub meta: Option<serde_json::Value>,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct DecisionStatement {
441 #[serde(rename = "type")]
443 pub type_: String,
444
445 pub timestamp: String,
447
448 pub actor: String,
450
451 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
453 pub parent_id: Option<String>,
454
455 #[serde(skip_serializing_if = "Option::is_none")]
457 pub model: Option<String>,
458
459 #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
461 pub model_version: Option<String>,
462
463 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub provider: Option<String>,
478
479 #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
481 pub tokens_in: Option<u64>,
482
483 #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
485 pub tokens_out: Option<u64>,
486
487 #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
489 pub prompt_digest: Option<String>,
490
491 #[serde(skip_serializing_if = "Option::is_none")]
493 pub summary: Option<String>,
494
495 #[serde(skip_serializing_if = "Option::is_none")]
497 pub confidence: Option<f64>,
498
499 #[serde(skip_serializing_if = "Option::is_none")]
501 pub alternatives: Option<Vec<String>>,
502
503 #[serde(skip_serializing_if = "Option::is_none")]
505 pub meta: Option<serde_json::Value>,
506}
507
508fn is_empty_subject(s: &SubjectRef) -> bool {
510 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
511}
512
513impl ActionStatement {
516 pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
517 Self {
518 type_: TYPE_ACTION.into(),
519 timestamp: now_rfc3339(),
520 actor: actor.into(),
521 action: action.into(),
522 subject: SubjectRef::default(),
523 parent_id: None,
524 approval_nonce: None,
525 policy_ref: None,
526 meta: None,
527 }
528 }
529}
530
531impl ApprovalStatement {
532 pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
533 Self {
534 type_: TYPE_APPROVAL.into(),
535 timestamp: now_rfc3339(),
536 approver: approver.into(),
537 subject: SubjectRef::default(),
538 description: None,
539 expires_at: None,
540 delegatable: false,
541 nonce: nonce.into(),
542 scope: None,
543 policy_ref: None,
544 irreversibility: None,
545 quarantine_receipt: None,
546 meta: None,
547 }
548 }
549}
550
551impl HandoffStatement {
552 pub fn new(from: impl Into<String>, to: impl Into<String>, artifacts: Vec<String>) -> Self {
553 Self {
554 type_: TYPE_HANDOFF.into(),
555 timestamp: now_rfc3339(),
556 from: from.into(),
557 to: to.into(),
558 artifacts,
559 approval_ids: vec![],
560 obligations: vec![],
561 delegatable: false,
562 task_ref: None,
563 policy_ref: None,
564 meta: None,
565 }
566 }
567}
568
569impl ReceiptStatement {
570 pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
571 Self {
572 type_: TYPE_RECEIPT.into(),
573 timestamp: now_rfc3339(),
574 system: system.into(),
575 subject: None,
576 kind: kind.into(),
577 payload: None,
578 payload_digest: None,
579 policy_ref: None,
580 meta: None,
581 }
582 }
583}
584
585impl DecisionStatement {
586 pub fn new(actor: impl Into<String>) -> Self {
587 Self {
588 type_: TYPE_DECISION.into(),
589 timestamp: now_rfc3339(),
590 actor: actor.into(),
591 parent_id: None,
592 model: None,
593 model_version: None,
594 provider: None,
595 tokens_in: None,
596 tokens_out: None,
597 prompt_digest: None,
598 summary: None,
599 confidence: None,
600 alternatives: None,
601 meta: None,
602 }
603 }
604}
605
606fn now_rfc3339() -> String {
607 use std::time::{SystemTime, UNIX_EPOCH};
610 let secs = SystemTime::now()
611 .duration_since(UNIX_EPOCH)
612 .unwrap_or_default()
613 .as_secs();
614 unix_to_rfc3339(secs)
615}
616
617pub fn unix_to_rfc3339(secs: u64) -> String {
618 let s = secs;
621 let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
622 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
623}
624
625fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
626 let sec = s % 60;
627 let mins = s / 60;
628 let min = mins % 60;
629 let hrs = mins / 60;
630 let hour = hrs % 24;
631 let days = hrs / 24;
632
633 let (y, m, d) = days_to_ymd(days);
635 (y, m, d, hour, min, sec)
636}
637
638fn days_to_ymd(days: u64) -> (u64, u64, u64) {
639 let mut d = days;
641 let mut year = 1970u64;
642 loop {
643 let dy = if is_leap(year) { 366 } else { 365 };
644 if d < dy {
645 break;
646 }
647 d -= dy;
648 year += 1;
649 }
650 let months = if is_leap(year) {
651 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
652 } else {
653 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
654 };
655 let mut month = 1u64;
656 for dm in months {
657 if d < dm {
658 break;
659 }
660 d -= dm;
661 month += 1;
662 }
663 (year, month, d + 1)
664}
665
666fn is_leap(y: u64) -> bool {
667 (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use crate::attestation::{sign, Ed25519Signer, Verifier};
674
675 #[test]
676 fn payload_type_format() {
677 assert_eq!(
678 payload_type("action"),
679 "application/vnd.treeship.action.v1+json"
680 );
681 assert_eq!(
682 payload_type("approval"),
683 "application/vnd.treeship.approval.v1+json"
684 );
685 }
686
687 #[test]
688 fn action_statement_sign_verify() {
689 let signer = Ed25519Signer::generate("key_test").unwrap();
690 let verifier = Verifier::from_signer(&signer);
691
692 let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
693 stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
694
695 let pt = payload_type("action");
696 let result = sign(&pt, &stmt, &signer).unwrap();
697
698 assert!(result.artifact_id.starts_with("art_"));
699
700 let vr = verifier.verify(&result.envelope).unwrap();
701 assert_eq!(vr.artifact_id, result.artifact_id);
702
703 let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
705 assert_eq!(decoded.actor, "agent://researcher");
706 assert_eq!(decoded.action, "tool.call");
707 assert_eq!(decoded.type_, TYPE_ACTION);
708 }
709
710 #[test]
711 fn approval_statement_with_nonce() {
712 let signer = Ed25519Signer::generate("key_human").unwrap();
713
714 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
715 approval.description = Some("approve laptop purchase < $1500".into());
716 approval.scope = Some(ApprovalScope {
717 max_actions: Some(1),
718 allowed_actions: vec!["stripe.payment_intent.create".into()],
719 ..Default::default()
720 });
721
722 let pt = payload_type("approval");
723 let result = sign(&pt, &approval, &signer).unwrap();
724 assert!(result.artifact_id.starts_with("art_"));
725
726 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
727 assert_eq!(decoded.nonce, "nonce_abc123");
728 assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
729 }
730
731 #[test]
732 fn approval_without_irreversibility_keeps_canonical_bytes() {
733 let approval = ApprovalStatement::new("human://alice", "nonce_abc123");
737 let bytes = serde_json::to_string(&approval).unwrap();
738 assert!(!bytes.contains("irreversibility"));
739 assert!(!bytes.contains("quarantineReceipt"));
740 }
741
742 #[test]
743 fn approval_irreversibility_fields_roundtrip_signed() {
744 let signer = Ed25519Signer::generate("key_human").unwrap();
745 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
746 approval.irreversibility = Some("one_way_consequential".into());
747 approval.quarantine_receipt = Some("art_deadbeef00112233".into());
748
749 let pt = payload_type("approval");
750 let result = sign(&pt, &approval, &signer).unwrap();
751 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
752 assert_eq!(
753 decoded.irreversibility.as_deref(),
754 Some("one_way_consequential")
755 );
756 assert_eq!(
757 decoded.quarantine_receipt.as_deref(),
758 Some("art_deadbeef00112233")
759 );
760 }
761
762 #[test]
763 fn irreversibility_vocabulary_is_closed_and_fails_strict() {
764 for c in IRREVERSIBILITY_CLASSES {
765 assert!(is_irreversibility_class(c));
766 }
767 assert!(!is_irreversibility_class("reversible"));
768 assert!(!is_irreversibility_class(""));
769 assert!(!irreversibility_requires_quarantine("two_way"));
771 assert!(!irreversibility_requires_quarantine("one_way_recoverable"));
772 assert!(irreversibility_requires_quarantine("one_way_consequential"));
773 assert!(irreversibility_requires_quarantine("one_way_terminal"));
774 assert!(irreversibility_requires_quarantine(
776 "definitely_fine_trust_me"
777 ));
778 }
779
780 #[test]
781 fn approval_scope_full_grant_roundtrips() {
782 let signer = Ed25519Signer::generate("key_piyush").unwrap();
786
787 let mut approval = ApprovalStatement::new("human://piyush", "nonce_deadbeef");
788 approval.description = Some("Deploy production after final review".into());
789 approval.scope = Some(ApprovalScope {
790 max_actions: Some(1),
791 valid_until: None,
792 allowed_actors: vec!["agent://deployer".into()],
793 allowed_actions: vec!["deploy.production".into()],
794 allowed_subjects: vec!["env://production".into()],
795 extra: None,
796 });
797
798 let pt = payload_type("approval");
799 let result = sign(&pt, &approval, &signer).unwrap();
800 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
801 let scope = decoded.scope.expect("scope must round-trip");
802
803 assert_eq!(scope.allowed_actors, vec!["agent://deployer".to_string()]);
804 assert_eq!(scope.allowed_actions, vec!["deploy.production".to_string()]);
805 assert_eq!(scope.allowed_subjects, vec!["env://production".to_string()]);
806 assert_eq!(scope.max_actions, Some(1));
807 }
808
809 #[test]
810 fn approval_scope_is_unscoped_predicate() {
811 assert!(ApprovalScope::default().is_unscoped());
813
814 assert!(!ApprovalScope {
816 max_actions: Some(1),
817 ..Default::default()
818 }
819 .is_unscoped());
820 assert!(!ApprovalScope {
821 valid_until: Some("2030-01-01T00:00:00Z".into()),
822 ..Default::default()
823 }
824 .is_unscoped());
825 assert!(!ApprovalScope {
826 allowed_actors: vec!["agent://x".into()],
827 ..Default::default()
828 }
829 .is_unscoped());
830 assert!(!ApprovalScope {
831 allowed_actions: vec!["doit".into()],
832 ..Default::default()
833 }
834 .is_unscoped());
835 assert!(!ApprovalScope {
836 allowed_subjects: vec!["env://prod".into()],
837 ..Default::default()
838 }
839 .is_unscoped());
840 }
841
842 #[test]
843 fn approval_scope_legacy_payloads_decode_with_empty_new_fields() {
844 let legacy = serde_json::json!({
848 "maxActions": 1,
849 "allowedActions": ["stripe.payment_intent.create"]
850 });
851 let scope: ApprovalScope = serde_json::from_value(legacy).unwrap();
852 assert_eq!(scope.max_actions, Some(1));
853 assert_eq!(
854 scope.allowed_actions,
855 vec!["stripe.payment_intent.create".to_string()]
856 );
857 assert!(scope.allowed_actors.is_empty());
859 assert!(scope.allowed_subjects.is_empty());
860 assert!(!scope.is_unscoped()); }
862
863 #[test]
864 fn handoff_statement() {
865 let signer = Ed25519Signer::generate("key_agent").unwrap();
866
867 let handoff = HandoffStatement::new(
868 "agent://researcher",
869 "agent://checkout",
870 vec!["art_aabbccdd11223344aabbccdd11223344".into()],
871 );
872
873 let pt = payload_type("handoff");
874 let result = sign(&pt, &handoff, &signer).unwrap();
875 let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
876
877 assert_eq!(decoded.from, "agent://researcher");
878 assert_eq!(decoded.to, "agent://checkout");
879 assert_eq!(decoded.artifacts.len(), 1);
880 }
881
882 #[test]
883 fn receipt_statement() {
884 let signer = Ed25519Signer::generate("key_system").unwrap();
885
886 let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
887 receipt.payload = Some(serde_json::json!({
888 "eventId": "evt_abc123",
889 "status": "succeeded"
890 }));
891
892 let pt = payload_type("receipt");
893 let result = sign(&pt, &receipt, &signer).unwrap();
894 let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
895
896 assert_eq!(decoded.system, "system://stripe-webhook");
897 assert_eq!(decoded.kind, "confirmation");
898 }
899
900 #[test]
901 fn nonce_binding_survives_serialization() {
902 let signer = Ed25519Signer::generate("key_test").unwrap();
903
904 let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
907 let pt = payload_type("approval");
908 let signed = sign(&pt, &approval, &signer).unwrap();
909
910 let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
911 assert_eq!(
912 decoded.nonce, "secure_nonce_xyz",
913 "nonce must survive serialization"
914 );
915 }
916
917 #[test]
918 fn decision_statement_sign_verify() {
919 let signer = Ed25519Signer::generate("key_test").unwrap();
920 let verifier = Verifier::from_signer(&signer);
921
922 let mut stmt = DecisionStatement::new("agent://analyst");
923 stmt.model = Some("claude-opus-4".into());
924 stmt.tokens_in = Some(8432);
925 stmt.tokens_out = Some(1247);
926 stmt.summary = Some("Contract looks standard.".into());
927 stmt.confidence = Some(0.91);
928
929 let pt = payload_type("decision");
930 let result = sign(&pt, &stmt, &signer).unwrap();
931
932 assert!(result.artifact_id.starts_with("art_"));
933
934 let vr = verifier.verify(&result.envelope).unwrap();
935 assert_eq!(vr.artifact_id, result.artifact_id);
936
937 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
939 assert_eq!(decoded.actor, "agent://analyst");
940 assert_eq!(decoded.model, Some("claude-opus-4".into()));
941 assert_eq!(decoded.tokens_in, Some(8432));
942 assert_eq!(decoded.tokens_out, Some(1247));
943 assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
944 assert_eq!(decoded.confidence, Some(0.91));
945 assert_eq!(decoded.type_, TYPE_DECISION);
946 }
947
948 #[test]
949 fn decision_statement_provider_roundtrips() {
950 let signer = Ed25519Signer::generate("key_test").unwrap();
954 let verifier = Verifier::from_signer(&signer);
955
956 let mut stmt = DecisionStatement::new("agent://researcher");
957 stmt.model = Some("kimi-k2".into());
958 stmt.provider = Some("moonshot".into());
959
960 let pt = payload_type("decision");
961 let result = sign(&pt, &stmt, &signer).unwrap();
962 verifier.verify(&result.envelope).unwrap();
963
964 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
965 assert_eq!(decoded.model, Some("kimi-k2".into()));
966 assert_eq!(decoded.provider, Some("moonshot".into()));
967 }
968
969 #[test]
970 fn decision_statement_legacy_payload_without_provider_decodes() {
971 let raw = serde_json::json!({
977 "type": TYPE_DECISION,
978 "timestamp": "2026-04-30T12:00:00Z",
979 "actor": "agent://legacy",
980 "model": "claude-opus-4",
981 });
982 let parsed: DecisionStatement = serde_json::from_value(raw).unwrap();
983 assert_eq!(parsed.model, Some("claude-opus-4".into()));
984 assert_eq!(parsed.provider, None);
985 }
986
987 #[test]
988 fn different_statement_types_different_ids() {
989 let signer = Ed25519Signer::generate("key_test").unwrap();
992
993 let action = ActionStatement::new("agent://test", "do.thing");
994 let approval = ApprovalStatement::new("human://test", "nonce_123");
995
996 let r_action = sign(&payload_type("action"), &action, &signer).unwrap();
997 let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
998
999 assert_ne!(r_action.artifact_id, r_approval.artifact_id);
1000 }
1001
1002 #[test]
1003 fn timestamp_format() {
1004 let ts = unix_to_rfc3339(0);
1005 assert_eq!(ts, "1970-01-01T00:00:00Z");
1006
1007 let ts2 = unix_to_rfc3339(1_000_000_000);
1008 assert_eq!(ts2, "2001-09-09T01:46:40Z");
1009 }
1010}