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