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 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub custody: Option<HandoffCustody>,
334
335 #[serde(rename = "closeLoop", default, skip_serializing_if = "Option::is_none")]
342 pub close_loop: Option<CloseLoopEvidence>,
343}
344
345pub const CUSTODY_LIVE: &str = "live";
349pub const CUSTODY_ASSERTED: &str = "asserted";
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
354pub struct HandoffCustody {
355 pub grade: String,
357
358 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub reason: Option<String>,
363
364 #[serde(
368 rename = "presentationDigest",
369 default,
370 skip_serializing_if = "Option::is_none"
371 )]
372 pub presentation_digest: Option<String>,
373
374 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub challenge: Option<String>,
378
379 #[serde(rename = "cardId", default, skip_serializing_if = "Option::is_none")]
381 pub card_id: Option<String>,
382
383 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub verifier: Option<String>,
387
388 #[serde(
390 rename = "verifiedAt",
391 default,
392 skip_serializing_if = "Option::is_none"
393 )]
394 pub verified_at: Option<String>,
395}
396
397#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct EffectiveCustody {
401 pub live: bool,
402 pub detail: String,
403}
404
405impl HandoffCustody {
406 pub fn asserted(reason: impl Into<String>) -> Self {
408 Self {
409 grade: CUSTODY_ASSERTED.into(),
410 reason: Some(reason.into()),
411 presentation_digest: None,
412 challenge: None,
413 card_id: None,
414 verifier: None,
415 verified_at: None,
416 }
417 }
418
419 pub fn effective(custody: Option<&HandoffCustody>) -> EffectiveCustody {
429 let Some(c) = custody else {
430 return EffectiveCustody {
431 live: false,
432 detail: "asserted (no verification recorded)".into(),
433 };
434 };
435 match c.grade.as_str() {
436 CUSTODY_LIVE => {
437 if c.presentation_digest.is_none() || c.challenge.is_none() {
438 return EffectiveCustody {
439 live: false,
440 detail: "asserted (claims live without presentation digest and challenge)"
441 .into(),
442 };
443 }
444 let mut detail = String::from("live");
445 if let Some(card) = &c.card_id {
446 detail.push_str(&format!(" -- card {card}"));
447 }
448 if let Some(v) = &c.verifier {
449 detail.push_str(&format!(", verified by {v}"));
450 }
451 if let Some(t) = &c.verified_at {
452 detail.push_str(&format!(" at {t}"));
453 }
454 EffectiveCustody { live: true, detail }
455 }
456 CUSTODY_ASSERTED => EffectiveCustody {
457 live: false,
458 detail: match &c.reason {
459 Some(r) => format!("asserted ({r})"),
460 None => "asserted".into(),
461 },
462 },
463 other => EffectiveCustody {
464 live: false,
465 detail: format!("asserted (unknown custody grade {other:?})"),
466 },
467 }
468 }
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474pub struct CloseLoopEvidence {
475 pub kind: String,
477
478 #[serde(rename = "sessionId")]
480 pub session_id: String,
481
482 #[serde(rename = "receiptDigest")]
485 pub receipt_digest: String,
486}
487
488pub const CLOSE_LOOP_SESSION: &str = "session";
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct EndorsementStatement {
495 #[serde(rename = "type")]
496 pub type_: String,
497 pub timestamp: String,
498
499 pub endorser: String,
501 pub subject: SubjectRef,
502
503 pub kind: String,
506
507 #[serde(skip_serializing_if = "Option::is_none")]
508 pub rationale: Option<String>,
509
510 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
511 pub expires_at: Option<String>,
512
513 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
514 pub policy_ref: Option<String>,
515
516 #[serde(skip_serializing_if = "Option::is_none")]
517 pub meta: Option<serde_json::Value>,
518}
519
520impl EndorsementStatement {
521 pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
522 Self {
523 type_: TYPE_ENDORSEMENT.into(),
524 timestamp: now_rfc3339(),
525 endorser: endorser.into(),
526 subject: SubjectRef::default(),
527 kind: kind.into(),
528 rationale: None,
529 expires_at: None,
530 policy_ref: None,
531 meta: None,
532 }
533 }
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct ReceiptStatement {
541 #[serde(rename = "type")]
542 pub type_: String,
543 pub timestamp: String,
544
545 pub system: String,
548
549 #[serde(skip_serializing_if = "Option::is_none")]
550 pub subject: Option<SubjectRef>,
551
552 pub kind: String,
554
555 #[serde(skip_serializing_if = "Option::is_none")]
556 pub payload: Option<serde_json::Value>,
557
558 #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
559 pub payload_digest: Option<String>,
560
561 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
562 pub policy_ref: Option<String>,
563
564 #[serde(skip_serializing_if = "Option::is_none")]
565 pub meta: Option<serde_json::Value>,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct ArtifactRef {
571 pub id: String,
572 pub digest: String,
573 #[serde(rename = "type")]
574 pub type_: String,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct BundleStatement {
580 #[serde(rename = "type")]
581 pub type_: String,
582 pub timestamp: String,
583
584 #[serde(skip_serializing_if = "Option::is_none")]
585 pub tag: Option<String>,
586
587 #[serde(skip_serializing_if = "Option::is_none")]
588 pub description: Option<String>,
589
590 pub artifacts: Vec<ArtifactRef>,
591
592 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
593 pub policy_ref: Option<String>,
594
595 #[serde(skip_serializing_if = "Option::is_none")]
596 pub meta: Option<serde_json::Value>,
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct DecisionStatement {
605 #[serde(rename = "type")]
607 pub type_: String,
608
609 pub timestamp: String,
611
612 pub actor: String,
614
615 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
617 pub parent_id: Option<String>,
618
619 #[serde(skip_serializing_if = "Option::is_none")]
621 pub model: Option<String>,
622
623 #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
625 pub model_version: Option<String>,
626
627 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub provider: Option<String>,
642
643 #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
645 pub tokens_in: Option<u64>,
646
647 #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
649 pub tokens_out: Option<u64>,
650
651 #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
653 pub prompt_digest: Option<String>,
654
655 #[serde(skip_serializing_if = "Option::is_none")]
657 pub summary: Option<String>,
658
659 #[serde(skip_serializing_if = "Option::is_none")]
661 pub confidence: Option<f64>,
662
663 #[serde(skip_serializing_if = "Option::is_none")]
665 pub alternatives: Option<Vec<String>>,
666
667 #[serde(skip_serializing_if = "Option::is_none")]
669 pub meta: Option<serde_json::Value>,
670}
671
672fn is_empty_subject(s: &SubjectRef) -> bool {
674 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
675}
676
677impl ActionStatement {
680 pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
681 Self {
682 type_: TYPE_ACTION.into(),
683 timestamp: now_rfc3339(),
684 actor: actor.into(),
685 action: action.into(),
686 subject: SubjectRef::default(),
687 parent_id: None,
688 approval_nonce: None,
689 policy_ref: None,
690 meta: None,
691 }
692 }
693}
694
695impl ApprovalStatement {
696 pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
697 Self {
698 type_: TYPE_APPROVAL.into(),
699 timestamp: now_rfc3339(),
700 approver: approver.into(),
701 subject: SubjectRef::default(),
702 description: None,
703 expires_at: None,
704 delegatable: false,
705 nonce: nonce.into(),
706 scope: None,
707 policy_ref: None,
708 irreversibility: None,
709 quarantine_receipt: None,
710 meta: None,
711 }
712 }
713}
714
715impl HandoffStatement {
716 pub fn new(from: impl Into<String>, to: impl Into<String>, artifacts: Vec<String>) -> Self {
717 Self {
718 type_: TYPE_HANDOFF.into(),
719 timestamp: now_rfc3339(),
720 from: from.into(),
721 to: to.into(),
722 artifacts,
723 approval_ids: vec![],
724 obligations: vec![],
725 delegatable: false,
726 task_ref: None,
727 policy_ref: None,
728 meta: None,
729 custody: None,
730 close_loop: None,
731 }
732 }
733}
734
735impl ReceiptStatement {
736 pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
737 Self {
738 type_: TYPE_RECEIPT.into(),
739 timestamp: now_rfc3339(),
740 system: system.into(),
741 subject: None,
742 kind: kind.into(),
743 payload: None,
744 payload_digest: None,
745 policy_ref: None,
746 meta: None,
747 }
748 }
749}
750
751impl DecisionStatement {
752 pub fn new(actor: impl Into<String>) -> Self {
753 Self {
754 type_: TYPE_DECISION.into(),
755 timestamp: now_rfc3339(),
756 actor: actor.into(),
757 parent_id: None,
758 model: None,
759 model_version: None,
760 provider: None,
761 tokens_in: None,
762 tokens_out: None,
763 prompt_digest: None,
764 summary: None,
765 confidence: None,
766 alternatives: None,
767 meta: None,
768 }
769 }
770}
771
772fn now_rfc3339() -> String {
773 use std::time::{SystemTime, UNIX_EPOCH};
776 let secs = SystemTime::now()
777 .duration_since(UNIX_EPOCH)
778 .unwrap_or_default()
779 .as_secs();
780 unix_to_rfc3339(secs)
781}
782
783pub fn unix_to_rfc3339(secs: u64) -> String {
784 let s = secs;
787 let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
788 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
789}
790
791fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
792 let sec = s % 60;
793 let mins = s / 60;
794 let min = mins % 60;
795 let hrs = mins / 60;
796 let hour = hrs % 24;
797 let days = hrs / 24;
798
799 let (y, m, d) = days_to_ymd(days);
801 (y, m, d, hour, min, sec)
802}
803
804fn days_to_ymd(days: u64) -> (u64, u64, u64) {
805 let mut d = days;
807 let mut year = 1970u64;
808 loop {
809 let dy = if is_leap(year) { 366 } else { 365 };
810 if d < dy {
811 break;
812 }
813 d -= dy;
814 year += 1;
815 }
816 let months = if is_leap(year) {
817 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
818 } else {
819 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
820 };
821 let mut month = 1u64;
822 for dm in months {
823 if d < dm {
824 break;
825 }
826 d -= dm;
827 month += 1;
828 }
829 (year, month, d + 1)
830}
831
832fn is_leap(y: u64) -> bool {
833 (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 use crate::attestation::{sign, Ed25519Signer, Verifier};
840
841 #[test]
842 fn payload_type_format() {
843 assert_eq!(
844 payload_type("action"),
845 "application/vnd.treeship.action.v1+json"
846 );
847 assert_eq!(
848 payload_type("approval"),
849 "application/vnd.treeship.approval.v1+json"
850 );
851 }
852
853 #[test]
854 fn action_statement_sign_verify() {
855 let signer = Ed25519Signer::generate("key_test").unwrap();
856 let verifier = Verifier::from_signer(&signer);
857
858 let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
859 stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
860
861 let pt = payload_type("action");
862 let result = sign(&pt, &stmt, &signer).unwrap();
863
864 assert!(result.artifact_id.starts_with("art_"));
865
866 let vr = verifier.verify(&result.envelope).unwrap();
867 assert_eq!(vr.artifact_id, result.artifact_id);
868
869 let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
871 assert_eq!(decoded.actor, "agent://researcher");
872 assert_eq!(decoded.action, "tool.call");
873 assert_eq!(decoded.type_, TYPE_ACTION);
874 }
875
876 #[test]
877 fn approval_statement_with_nonce() {
878 let signer = Ed25519Signer::generate("key_human").unwrap();
879
880 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
881 approval.description = Some("approve laptop purchase < $1500".into());
882 approval.scope = Some(ApprovalScope {
883 max_actions: Some(1),
884 allowed_actions: vec!["stripe.payment_intent.create".into()],
885 ..Default::default()
886 });
887
888 let pt = payload_type("approval");
889 let result = sign(&pt, &approval, &signer).unwrap();
890 assert!(result.artifact_id.starts_with("art_"));
891
892 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
893 assert_eq!(decoded.nonce, "nonce_abc123");
894 assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
895 }
896
897 #[test]
898 fn approval_without_irreversibility_keeps_canonical_bytes() {
899 let approval = ApprovalStatement::new("human://alice", "nonce_abc123");
903 let bytes = serde_json::to_string(&approval).unwrap();
904 assert!(!bytes.contains("irreversibility"));
905 assert!(!bytes.contains("quarantineReceipt"));
906 }
907
908 #[test]
909 fn handoff_without_custody_keeps_canonical_bytes() {
910 let handoff = HandoffStatement::new("agent://a", "agent://b", vec!["art_1".into()]);
913 let bytes = serde_json::to_string(&handoff).unwrap();
914 assert!(!bytes.contains("custody"));
915 assert!(!bytes.contains("closeLoop"));
916 }
917
918 #[test]
919 fn handoff_custody_and_close_loop_roundtrip_signed() {
920 let signer = Ed25519Signer::generate("key_receiver").unwrap();
921 let mut handoff =
922 HandoffStatement::new("agent://grok", "agent://claude", vec!["art_1".into()]);
923 handoff.custody = Some(HandoffCustody {
924 grade: CUSTODY_LIVE.into(),
925 reason: None,
926 presentation_digest: Some(format!("sha256:{}", "ab".repeat(32))),
927 challenge: Some("0123456789abcdef0123456789abcdef".into()),
928 card_id: Some("art_card".into()),
929 verifier: Some("agent://claude".into()),
930 verified_at: Some("2026-09-02T00:00:00Z".into()),
931 });
932 handoff.close_loop = Some(CloseLoopEvidence {
933 kind: CLOSE_LOOP_SESSION.into(),
934 session_id: "ssn_0011".into(),
935 receipt_digest: format!("sha256:{}", "cd".repeat(32)),
936 });
937 let pt = payload_type("handoff");
938 let result = sign(&pt, &handoff, &signer).unwrap();
939 let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
940 assert_eq!(decoded.custody, handoff.custody);
941 assert_eq!(decoded.close_loop, handoff.close_loop);
942 }
943
944 #[test]
945 fn custody_missing_block_is_asserted() {
946 let e = HandoffCustody::effective(None);
947 assert!(!e.live);
948 assert_eq!(e.detail, "asserted (no verification recorded)");
949 }
950
951 #[test]
952 fn custody_live_without_evidence_is_downgraded() {
953 let c = HandoffCustody {
957 grade: CUSTODY_LIVE.into(),
958 reason: None,
959 presentation_digest: None,
960 challenge: Some("0123456789abcdef0123456789abcdef".into()),
961 card_id: None,
962 verifier: None,
963 verified_at: None,
964 };
965 let e = HandoffCustody::effective(Some(&c));
966 assert!(!e.live);
967 assert!(
968 e.detail.starts_with("asserted (claims live"),
969 "{}",
970 e.detail
971 );
972 }
973
974 #[test]
975 fn custody_unknown_grade_is_asserted_and_named() {
976 let mut c = HandoffCustody::asserted("x");
977 c.grade = "verified".into();
978 let e = HandoffCustody::effective(Some(&c));
979 assert!(!e.live);
980 assert_eq!(e.detail, "asserted (unknown custody grade \"verified\")");
981 }
982
983 #[test]
984 fn custody_asserted_carries_its_reason() {
985 let c = HandoffCustody::asserted("same_computer");
986 let e = HandoffCustody::effective(Some(&c));
987 assert!(!e.live);
988 assert_eq!(e.detail, "asserted (same_computer)");
989 }
990
991 #[test]
992 fn custody_live_with_evidence_is_live() {
993 let c = HandoffCustody {
994 grade: CUSTODY_LIVE.into(),
995 reason: None,
996 presentation_digest: Some(format!("sha256:{}", "ab".repeat(32))),
997 challenge: Some("0123456789abcdef0123456789abcdef".into()),
998 card_id: Some("art_card".into()),
999 verifier: Some("agent://claude".into()),
1000 verified_at: Some("2026-09-02T00:00:00Z".into()),
1001 };
1002 let e = HandoffCustody::effective(Some(&c));
1003 assert!(e.live);
1004 assert_eq!(
1005 e.detail,
1006 "live -- card art_card, verified by agent://claude at 2026-09-02T00:00:00Z"
1007 );
1008 }
1009
1010 #[test]
1011 fn approval_irreversibility_fields_roundtrip_signed() {
1012 let signer = Ed25519Signer::generate("key_human").unwrap();
1013 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
1014 approval.irreversibility = Some("one_way_consequential".into());
1015 approval.quarantine_receipt = Some("art_deadbeef00112233".into());
1016
1017 let pt = payload_type("approval");
1018 let result = sign(&pt, &approval, &signer).unwrap();
1019 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
1020 assert_eq!(
1021 decoded.irreversibility.as_deref(),
1022 Some("one_way_consequential")
1023 );
1024 assert_eq!(
1025 decoded.quarantine_receipt.as_deref(),
1026 Some("art_deadbeef00112233")
1027 );
1028 }
1029
1030 #[test]
1031 fn irreversibility_vocabulary_is_closed_and_fails_strict() {
1032 for c in IRREVERSIBILITY_CLASSES {
1033 assert!(is_irreversibility_class(c));
1034 }
1035 assert!(!is_irreversibility_class("reversible"));
1036 assert!(!is_irreversibility_class(""));
1037 assert!(!irreversibility_requires_quarantine("two_way"));
1039 assert!(!irreversibility_requires_quarantine("one_way_recoverable"));
1040 assert!(irreversibility_requires_quarantine("one_way_consequential"));
1041 assert!(irreversibility_requires_quarantine("one_way_terminal"));
1042 assert!(irreversibility_requires_quarantine(
1044 "definitely_fine_trust_me"
1045 ));
1046 }
1047
1048 #[test]
1049 fn approval_scope_full_grant_roundtrips() {
1050 let signer = Ed25519Signer::generate("key_piyush").unwrap();
1054
1055 let mut approval = ApprovalStatement::new("human://piyush", "nonce_deadbeef");
1056 approval.description = Some("Deploy production after final review".into());
1057 approval.scope = Some(ApprovalScope {
1058 max_actions: Some(1),
1059 valid_until: None,
1060 allowed_actors: vec!["agent://deployer".into()],
1061 allowed_actions: vec!["deploy.production".into()],
1062 allowed_subjects: vec!["env://production".into()],
1063 extra: None,
1064 });
1065
1066 let pt = payload_type("approval");
1067 let result = sign(&pt, &approval, &signer).unwrap();
1068 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
1069 let scope = decoded.scope.expect("scope must round-trip");
1070
1071 assert_eq!(scope.allowed_actors, vec!["agent://deployer".to_string()]);
1072 assert_eq!(scope.allowed_actions, vec!["deploy.production".to_string()]);
1073 assert_eq!(scope.allowed_subjects, vec!["env://production".to_string()]);
1074 assert_eq!(scope.max_actions, Some(1));
1075 }
1076
1077 #[test]
1078 fn approval_scope_is_unscoped_predicate() {
1079 assert!(ApprovalScope::default().is_unscoped());
1081
1082 assert!(!ApprovalScope {
1084 max_actions: Some(1),
1085 ..Default::default()
1086 }
1087 .is_unscoped());
1088 assert!(!ApprovalScope {
1089 valid_until: Some("2030-01-01T00:00:00Z".into()),
1090 ..Default::default()
1091 }
1092 .is_unscoped());
1093 assert!(!ApprovalScope {
1094 allowed_actors: vec!["agent://x".into()],
1095 ..Default::default()
1096 }
1097 .is_unscoped());
1098 assert!(!ApprovalScope {
1099 allowed_actions: vec!["doit".into()],
1100 ..Default::default()
1101 }
1102 .is_unscoped());
1103 assert!(!ApprovalScope {
1104 allowed_subjects: vec!["env://prod".into()],
1105 ..Default::default()
1106 }
1107 .is_unscoped());
1108 }
1109
1110 #[test]
1111 fn approval_scope_legacy_payloads_decode_with_empty_new_fields() {
1112 let legacy = serde_json::json!({
1116 "maxActions": 1,
1117 "allowedActions": ["stripe.payment_intent.create"]
1118 });
1119 let scope: ApprovalScope = serde_json::from_value(legacy).unwrap();
1120 assert_eq!(scope.max_actions, Some(1));
1121 assert_eq!(
1122 scope.allowed_actions,
1123 vec!["stripe.payment_intent.create".to_string()]
1124 );
1125 assert!(scope.allowed_actors.is_empty());
1127 assert!(scope.allowed_subjects.is_empty());
1128 assert!(!scope.is_unscoped()); }
1130
1131 #[test]
1132 fn handoff_statement() {
1133 let signer = Ed25519Signer::generate("key_agent").unwrap();
1134
1135 let handoff = HandoffStatement::new(
1136 "agent://researcher",
1137 "agent://checkout",
1138 vec!["art_aabbccdd11223344aabbccdd11223344".into()],
1139 );
1140
1141 let pt = payload_type("handoff");
1142 let result = sign(&pt, &handoff, &signer).unwrap();
1143 let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
1144
1145 assert_eq!(decoded.from, "agent://researcher");
1146 assert_eq!(decoded.to, "agent://checkout");
1147 assert_eq!(decoded.artifacts.len(), 1);
1148 }
1149
1150 #[test]
1151 fn receipt_statement() {
1152 let signer = Ed25519Signer::generate("key_system").unwrap();
1153
1154 let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
1155 receipt.payload = Some(serde_json::json!({
1156 "eventId": "evt_abc123",
1157 "status": "succeeded"
1158 }));
1159
1160 let pt = payload_type("receipt");
1161 let result = sign(&pt, &receipt, &signer).unwrap();
1162 let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
1163
1164 assert_eq!(decoded.system, "system://stripe-webhook");
1165 assert_eq!(decoded.kind, "confirmation");
1166 }
1167
1168 #[test]
1169 fn nonce_binding_survives_serialization() {
1170 let signer = Ed25519Signer::generate("key_test").unwrap();
1171
1172 let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
1175 let pt = payload_type("approval");
1176 let signed = sign(&pt, &approval, &signer).unwrap();
1177
1178 let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
1179 assert_eq!(
1180 decoded.nonce, "secure_nonce_xyz",
1181 "nonce must survive serialization"
1182 );
1183 }
1184
1185 #[test]
1186 fn decision_statement_sign_verify() {
1187 let signer = Ed25519Signer::generate("key_test").unwrap();
1188 let verifier = Verifier::from_signer(&signer);
1189
1190 let mut stmt = DecisionStatement::new("agent://analyst");
1191 stmt.model = Some("claude-opus-4".into());
1192 stmt.tokens_in = Some(8432);
1193 stmt.tokens_out = Some(1247);
1194 stmt.summary = Some("Contract looks standard.".into());
1195 stmt.confidence = Some(0.91);
1196
1197 let pt = payload_type("decision");
1198 let result = sign(&pt, &stmt, &signer).unwrap();
1199
1200 assert!(result.artifact_id.starts_with("art_"));
1201
1202 let vr = verifier.verify(&result.envelope).unwrap();
1203 assert_eq!(vr.artifact_id, result.artifact_id);
1204
1205 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
1207 assert_eq!(decoded.actor, "agent://analyst");
1208 assert_eq!(decoded.model, Some("claude-opus-4".into()));
1209 assert_eq!(decoded.tokens_in, Some(8432));
1210 assert_eq!(decoded.tokens_out, Some(1247));
1211 assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
1212 assert_eq!(decoded.confidence, Some(0.91));
1213 assert_eq!(decoded.type_, TYPE_DECISION);
1214 }
1215
1216 #[test]
1217 fn decision_statement_provider_roundtrips() {
1218 let signer = Ed25519Signer::generate("key_test").unwrap();
1222 let verifier = Verifier::from_signer(&signer);
1223
1224 let mut stmt = DecisionStatement::new("agent://researcher");
1225 stmt.model = Some("kimi-k2".into());
1226 stmt.provider = Some("moonshot".into());
1227
1228 let pt = payload_type("decision");
1229 let result = sign(&pt, &stmt, &signer).unwrap();
1230 verifier.verify(&result.envelope).unwrap();
1231
1232 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
1233 assert_eq!(decoded.model, Some("kimi-k2".into()));
1234 assert_eq!(decoded.provider, Some("moonshot".into()));
1235 }
1236
1237 #[test]
1238 fn decision_statement_legacy_payload_without_provider_decodes() {
1239 let raw = serde_json::json!({
1245 "type": TYPE_DECISION,
1246 "timestamp": "2026-04-30T12:00:00Z",
1247 "actor": "agent://legacy",
1248 "model": "claude-opus-4",
1249 });
1250 let parsed: DecisionStatement = serde_json::from_value(raw).unwrap();
1251 assert_eq!(parsed.model, Some("claude-opus-4".into()));
1252 assert_eq!(parsed.provider, None);
1253 }
1254
1255 #[test]
1256 fn different_statement_types_different_ids() {
1257 let signer = Ed25519Signer::generate("key_test").unwrap();
1260
1261 let action = ActionStatement::new("agent://test", "do.thing");
1262 let approval = ApprovalStatement::new("human://test", "nonce_123");
1263
1264 let r_action = sign(&payload_type("action"), &action, &signer).unwrap();
1265 let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
1266
1267 assert_ne!(r_action.artifact_id, r_approval.artifact_id);
1268 }
1269
1270 #[test]
1271 fn timestamp_format() {
1272 let ts = unix_to_rfc3339(0);
1273 assert_eq!(ts, "1970-01-01T00:00:00Z");
1274
1275 let ts2 = unix_to_rfc3339(1_000_000_000);
1276 assert_eq!(ts2, "2001-09-09T01:46:40Z");
1277 }
1278}