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