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, PartialEq, Eq, Serialize, Deserialize)]
178pub struct Retry {
179 pub of: String,
181 pub attempt: u32,
183 pub cause: RetryCause,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub backoff_ms: Option<u64>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub idempotency_key: Option<String>,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub enum RetryCause {
199 Timeout,
200 Error,
201 RateLimited,
202 Operator,
203 Unknown,
204}
205
206impl RetryCause {
207 pub fn parse(s: &str) -> Option<Self> {
208 match s {
209 "timeout" => Some(Self::Timeout),
210 "error" => Some(Self::Error),
211 "rate_limited" | "rate-limited" => Some(Self::RateLimited),
212 "operator" => Some(Self::Operator),
213 "unknown" => Some(Self::Unknown),
214 _ => None,
215 }
216 }
217 pub fn label(self) -> &'static str {
218 match self {
219 Self::Timeout => "timeout",
220 Self::Error => "error",
221 Self::RateLimited => "rate_limited",
222 Self::Operator => "operator",
223 Self::Unknown => "unknown",
224 }
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ActionStatement {
232 #[serde(rename = "type")]
234 pub type_: String,
235
236 pub timestamp: String,
238
239 pub actor: String,
241
242 pub action: String,
244
245 #[serde(default, skip_serializing_if = "is_empty_subject")]
246 pub subject: SubjectRef,
247
248 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
250 pub parent_id: Option<String>,
251
252 #[serde(rename = "approvalNonce", skip_serializing_if = "Option::is_none")]
256 pub approval_nonce: Option<String>,
257
258 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
259 pub policy_ref: Option<String>,
260
261 #[serde(skip_serializing_if = "Option::is_none")]
262 pub meta: Option<serde_json::Value>,
263
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub retry: Option<Retry>,
267
268 #[serde(
272 rename = "idempotencyKey",
273 default,
274 skip_serializing_if = "Option::is_none"
275 )]
276 pub idempotency_key: Option<String>,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ApprovalStatement {
287 #[serde(rename = "type")]
288 pub type_: String,
289 pub timestamp: String,
290
291 pub approver: String,
293
294 #[serde(default, skip_serializing_if = "is_empty_subject")]
295 pub subject: SubjectRef,
296
297 #[serde(skip_serializing_if = "Option::is_none")]
298 pub description: Option<String>,
299
300 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
302 pub expires_at: Option<String>,
303
304 pub delegatable: bool,
306
307 pub nonce: String,
311
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub scope: Option<ApprovalScope>,
314
315 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
316 pub policy_ref: Option<String>,
317
318 #[serde(skip_serializing_if = "Option::is_none")]
325 pub irreversibility: Option<String>,
326
327 #[serde(rename = "quarantineReceipt", skip_serializing_if = "Option::is_none")]
332 pub quarantine_receipt: Option<String>,
333
334 #[serde(skip_serializing_if = "Option::is_none")]
335 pub meta: Option<serde_json::Value>,
336}
337
338pub const IRREVERSIBILITY_CLASSES: &[&str] = &[
344 "two_way",
345 "one_way_recoverable",
346 "one_way_consequential",
347 "one_way_terminal",
348];
349
350pub fn is_irreversibility_class(class: &str) -> bool {
352 IRREVERSIBILITY_CLASSES.contains(&class)
353}
354
355pub fn irreversibility_requires_quarantine(class: &str) -> bool {
359 !matches!(class, "two_way" | "one_way_recoverable")
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct HandoffStatement {
368 #[serde(rename = "type")]
369 pub type_: String,
370 pub timestamp: String,
371
372 pub from: String,
374 pub to: String,
376
377 pub artifacts: Vec<String>,
379
380 #[serde(rename = "approvalIds", default, skip_serializing_if = "Vec::is_empty")]
382 pub approval_ids: Vec<String>,
383
384 #[serde(default, skip_serializing_if = "Vec::is_empty")]
386 pub obligations: Vec<String>,
387
388 pub delegatable: bool,
389
390 #[serde(rename = "taskRef", skip_serializing_if = "Option::is_none")]
391 pub task_ref: Option<String>,
392
393 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
394 pub policy_ref: Option<String>,
395
396 #[serde(skip_serializing_if = "Option::is_none")]
397 pub meta: Option<serde_json::Value>,
398
399 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub custody: Option<HandoffCustody>,
408
409 #[serde(rename = "closeLoop", default, skip_serializing_if = "Option::is_none")]
416 pub close_loop: Option<CloseLoopEvidence>,
417}
418
419pub const CUSTODY_LIVE: &str = "live";
423pub const CUSTODY_ASSERTED: &str = "asserted";
424
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428pub struct HandoffCustody {
429 pub grade: String,
431
432 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub reason: Option<String>,
437
438 #[serde(
442 rename = "presentationDigest",
443 default,
444 skip_serializing_if = "Option::is_none"
445 )]
446 pub presentation_digest: Option<String>,
447
448 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub challenge: Option<String>,
452
453 #[serde(rename = "cardId", default, skip_serializing_if = "Option::is_none")]
455 pub card_id: Option<String>,
456
457 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub verifier: Option<String>,
461
462 #[serde(
464 rename = "verifiedAt",
465 default,
466 skip_serializing_if = "Option::is_none"
467 )]
468 pub verified_at: Option<String>,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct EffectiveCustody {
475 pub live: bool,
476 pub detail: String,
477}
478
479impl HandoffCustody {
480 pub fn asserted(reason: impl Into<String>) -> Self {
482 Self {
483 grade: CUSTODY_ASSERTED.into(),
484 reason: Some(reason.into()),
485 presentation_digest: None,
486 challenge: None,
487 card_id: None,
488 verifier: None,
489 verified_at: None,
490 }
491 }
492
493 pub fn effective(custody: Option<&HandoffCustody>) -> EffectiveCustody {
503 let Some(c) = custody else {
504 return EffectiveCustody {
505 live: false,
506 detail: "asserted (no verification recorded)".into(),
507 };
508 };
509 match c.grade.as_str() {
510 CUSTODY_LIVE => {
511 if c.presentation_digest.is_none() || c.challenge.is_none() {
512 return EffectiveCustody {
513 live: false,
514 detail: "asserted (claims live without presentation digest and challenge)"
515 .into(),
516 };
517 }
518 let mut detail = String::from("live");
519 if let Some(card) = &c.card_id {
520 detail.push_str(&format!(" -- card {card}"));
521 }
522 if let Some(v) = &c.verifier {
523 detail.push_str(&format!(", verified by {v}"));
524 }
525 if let Some(t) = &c.verified_at {
526 detail.push_str(&format!(" at {t}"));
527 }
528 EffectiveCustody { live: true, detail }
529 }
530 CUSTODY_ASSERTED => EffectiveCustody {
531 live: false,
532 detail: match &c.reason {
533 Some(r) => format!("asserted ({r})"),
534 None => "asserted".into(),
535 },
536 },
537 other => EffectiveCustody {
538 live: false,
539 detail: format!("asserted (unknown custody grade {other:?})"),
540 },
541 }
542 }
543}
544
545#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548pub struct CloseLoopEvidence {
549 pub kind: String,
551
552 #[serde(rename = "sessionId")]
554 pub session_id: String,
555
556 #[serde(rename = "receiptDigest")]
559 pub receipt_digest: String,
560}
561
562pub const CLOSE_LOOP_SESSION: &str = "session";
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct EndorsementStatement {
569 #[serde(rename = "type")]
570 pub type_: String,
571 pub timestamp: String,
572
573 pub endorser: String,
575 pub subject: SubjectRef,
576
577 pub kind: String,
580
581 #[serde(skip_serializing_if = "Option::is_none")]
582 pub rationale: Option<String>,
583
584 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
585 pub expires_at: Option<String>,
586
587 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
588 pub policy_ref: Option<String>,
589
590 #[serde(skip_serializing_if = "Option::is_none")]
591 pub meta: Option<serde_json::Value>,
592}
593
594impl EndorsementStatement {
595 pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
596 Self {
597 type_: TYPE_ENDORSEMENT.into(),
598 timestamp: now_rfc3339(),
599 endorser: endorser.into(),
600 subject: SubjectRef::default(),
601 kind: kind.into(),
602 rationale: None,
603 expires_at: None,
604 policy_ref: None,
605 meta: None,
606 }
607 }
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct ReceiptStatement {
615 #[serde(rename = "type")]
616 pub type_: String,
617 pub timestamp: String,
618
619 pub system: String,
622
623 #[serde(skip_serializing_if = "Option::is_none")]
624 pub subject: Option<SubjectRef>,
625
626 pub kind: String,
628
629 #[serde(skip_serializing_if = "Option::is_none")]
630 pub payload: Option<serde_json::Value>,
631
632 #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
633 pub payload_digest: Option<String>,
634
635 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
636 pub policy_ref: Option<String>,
637
638 #[serde(skip_serializing_if = "Option::is_none")]
639 pub meta: Option<serde_json::Value>,
640
641 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
648 pub parent_id: Option<String>,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize)]
653pub struct ArtifactRef {
654 pub id: String,
655 pub digest: String,
656 #[serde(rename = "type")]
657 pub type_: String,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct BundleStatement {
663 #[serde(rename = "type")]
664 pub type_: String,
665 pub timestamp: String,
666
667 #[serde(skip_serializing_if = "Option::is_none")]
668 pub tag: Option<String>,
669
670 #[serde(skip_serializing_if = "Option::is_none")]
671 pub description: Option<String>,
672
673 pub artifacts: Vec<ArtifactRef>,
674
675 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
676 pub policy_ref: Option<String>,
677
678 #[serde(skip_serializing_if = "Option::is_none")]
679 pub meta: Option<serde_json::Value>,
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct DecisionStatement {
688 #[serde(rename = "type")]
690 pub type_: String,
691
692 pub timestamp: String,
694
695 pub actor: String,
697
698 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
700 pub parent_id: Option<String>,
701
702 #[serde(skip_serializing_if = "Option::is_none")]
704 pub model: Option<String>,
705
706 #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
708 pub model_version: Option<String>,
709
710 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub provider: Option<String>,
725
726 #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
728 pub tokens_in: Option<u64>,
729
730 #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
732 pub tokens_out: Option<u64>,
733
734 #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
736 pub prompt_digest: Option<String>,
737
738 #[serde(skip_serializing_if = "Option::is_none")]
740 pub summary: Option<String>,
741
742 #[serde(skip_serializing_if = "Option::is_none")]
744 pub confidence: Option<f64>,
745
746 #[serde(skip_serializing_if = "Option::is_none")]
748 pub alternatives: Option<Vec<String>>,
749
750 #[serde(skip_serializing_if = "Option::is_none")]
752 pub meta: Option<serde_json::Value>,
753}
754
755fn is_empty_subject(s: &SubjectRef) -> bool {
757 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
758}
759
760impl ActionStatement {
763 pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
764 Self {
765 type_: TYPE_ACTION.into(),
766 timestamp: now_rfc3339(),
767 actor: actor.into(),
768 action: action.into(),
769 subject: SubjectRef::default(),
770 parent_id: None,
771 approval_nonce: None,
772 policy_ref: None,
773 meta: None,
774 retry: None,
775 idempotency_key: None,
776 }
777 }
778}
779
780impl ApprovalStatement {
781 pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
782 Self {
783 type_: TYPE_APPROVAL.into(),
784 timestamp: now_rfc3339(),
785 approver: approver.into(),
786 subject: SubjectRef::default(),
787 description: None,
788 expires_at: None,
789 delegatable: false,
790 nonce: nonce.into(),
791 scope: None,
792 policy_ref: None,
793 irreversibility: None,
794 quarantine_receipt: None,
795 meta: None,
796 }
797 }
798}
799
800impl HandoffStatement {
801 pub fn new(from: impl Into<String>, to: impl Into<String>, artifacts: Vec<String>) -> Self {
802 Self {
803 type_: TYPE_HANDOFF.into(),
804 timestamp: now_rfc3339(),
805 from: from.into(),
806 to: to.into(),
807 artifacts,
808 approval_ids: vec![],
809 obligations: vec![],
810 delegatable: false,
811 task_ref: None,
812 policy_ref: None,
813 meta: None,
814 custody: None,
815 close_loop: None,
816 }
817 }
818}
819
820impl ReceiptStatement {
821 pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
822 Self {
823 type_: TYPE_RECEIPT.into(),
824 timestamp: now_rfc3339(),
825 system: system.into(),
826 subject: None,
827 kind: kind.into(),
828 payload: None,
829 payload_digest: None,
830 policy_ref: None,
831 meta: None,
832 parent_id: None,
833 }
834 }
835}
836
837impl DecisionStatement {
838 pub fn new(actor: impl Into<String>) -> Self {
839 Self {
840 type_: TYPE_DECISION.into(),
841 timestamp: now_rfc3339(),
842 actor: actor.into(),
843 parent_id: None,
844 model: None,
845 model_version: None,
846 provider: None,
847 tokens_in: None,
848 tokens_out: None,
849 prompt_digest: None,
850 summary: None,
851 confidence: None,
852 alternatives: None,
853 meta: None,
854 }
855 }
856}
857
858fn now_rfc3339() -> String {
859 use std::time::{SystemTime, UNIX_EPOCH};
862 let secs = SystemTime::now()
863 .duration_since(UNIX_EPOCH)
864 .unwrap_or_default()
865 .as_secs();
866 unix_to_rfc3339(secs)
867}
868
869pub fn unix_to_rfc3339(secs: u64) -> String {
870 let s = secs;
873 let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
874 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
875}
876
877fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
878 let sec = s % 60;
879 let mins = s / 60;
880 let min = mins % 60;
881 let hrs = mins / 60;
882 let hour = hrs % 24;
883 let days = hrs / 24;
884
885 let (y, m, d) = days_to_ymd(days);
887 (y, m, d, hour, min, sec)
888}
889
890fn days_to_ymd(days: u64) -> (u64, u64, u64) {
891 let mut d = days;
893 let mut year = 1970u64;
894 loop {
895 let dy = if is_leap(year) { 366 } else { 365 };
896 if d < dy {
897 break;
898 }
899 d -= dy;
900 year += 1;
901 }
902 let months = if is_leap(year) {
903 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
904 } else {
905 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
906 };
907 let mut month = 1u64;
908 for dm in months {
909 if d < dm {
910 break;
911 }
912 d -= dm;
913 month += 1;
914 }
915 (year, month, d + 1)
916}
917
918fn is_leap(y: u64) -> bool {
919 (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925 use crate::attestation::{sign, Ed25519Signer, Verifier};
926
927 #[test]
928 fn payload_type_format() {
929 assert_eq!(
930 payload_type("action"),
931 "application/vnd.treeship.action.v1+json"
932 );
933 assert_eq!(
934 payload_type("approval"),
935 "application/vnd.treeship.approval.v1+json"
936 );
937 }
938
939 #[test]
940 fn action_statement_sign_verify() {
941 let signer = Ed25519Signer::generate("key_test").unwrap();
942 let verifier = Verifier::from_signer(&signer);
943
944 let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
945 stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
946
947 let pt = payload_type("action");
948 let result = sign(&pt, &stmt, &signer).unwrap();
949
950 assert!(result.artifact_id.starts_with("art_"));
951
952 let vr = verifier.verify(&result.envelope).unwrap();
953 assert_eq!(vr.artifact_id, result.artifact_id);
954
955 let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
957 assert_eq!(decoded.actor, "agent://researcher");
958 assert_eq!(decoded.action, "tool.call");
959 assert_eq!(decoded.type_, TYPE_ACTION);
960 }
961
962 #[test]
963 fn approval_statement_with_nonce() {
964 let signer = Ed25519Signer::generate("key_human").unwrap();
965
966 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
967 approval.description = Some("approve laptop purchase < $1500".into());
968 approval.scope = Some(ApprovalScope {
969 max_actions: Some(1),
970 allowed_actions: vec!["stripe.payment_intent.create".into()],
971 ..Default::default()
972 });
973
974 let pt = payload_type("approval");
975 let result = sign(&pt, &approval, &signer).unwrap();
976 assert!(result.artifact_id.starts_with("art_"));
977
978 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
979 assert_eq!(decoded.nonce, "nonce_abc123");
980 assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
981 }
982
983 #[test]
984 fn approval_without_irreversibility_keeps_canonical_bytes() {
985 let approval = ApprovalStatement::new("human://alice", "nonce_abc123");
989 let bytes = serde_json::to_string(&approval).unwrap();
990 assert!(!bytes.contains("irreversibility"));
991 assert!(!bytes.contains("quarantineReceipt"));
992 }
993
994 #[test]
995 fn handoff_without_custody_keeps_canonical_bytes() {
996 let handoff = HandoffStatement::new("agent://a", "agent://b", vec!["art_1".into()]);
999 let bytes = serde_json::to_string(&handoff).unwrap();
1000 assert!(!bytes.contains("custody"));
1001 assert!(!bytes.contains("closeLoop"));
1002 }
1003
1004 #[test]
1005 fn handoff_custody_and_close_loop_roundtrip_signed() {
1006 let signer = Ed25519Signer::generate("key_receiver").unwrap();
1007 let mut handoff =
1008 HandoffStatement::new("agent://grok", "agent://claude", vec!["art_1".into()]);
1009 handoff.custody = Some(HandoffCustody {
1010 grade: CUSTODY_LIVE.into(),
1011 reason: None,
1012 presentation_digest: Some(format!("sha256:{}", "ab".repeat(32))),
1013 challenge: Some("0123456789abcdef0123456789abcdef".into()),
1014 card_id: Some("art_card".into()),
1015 verifier: Some("agent://claude".into()),
1016 verified_at: Some("2026-09-02T00:00:00Z".into()),
1017 });
1018 handoff.close_loop = Some(CloseLoopEvidence {
1019 kind: CLOSE_LOOP_SESSION.into(),
1020 session_id: "ssn_0011".into(),
1021 receipt_digest: format!("sha256:{}", "cd".repeat(32)),
1022 });
1023 let pt = payload_type("handoff");
1024 let result = sign(&pt, &handoff, &signer).unwrap();
1025 let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
1026 assert_eq!(decoded.custody, handoff.custody);
1027 assert_eq!(decoded.close_loop, handoff.close_loop);
1028 }
1029
1030 #[test]
1031 fn custody_missing_block_is_asserted() {
1032 let e = HandoffCustody::effective(None);
1033 assert!(!e.live);
1034 assert_eq!(e.detail, "asserted (no verification recorded)");
1035 }
1036
1037 #[test]
1038 fn custody_live_without_evidence_is_downgraded() {
1039 let c = HandoffCustody {
1043 grade: CUSTODY_LIVE.into(),
1044 reason: None,
1045 presentation_digest: None,
1046 challenge: Some("0123456789abcdef0123456789abcdef".into()),
1047 card_id: None,
1048 verifier: None,
1049 verified_at: None,
1050 };
1051 let e = HandoffCustody::effective(Some(&c));
1052 assert!(!e.live);
1053 assert!(
1054 e.detail.starts_with("asserted (claims live"),
1055 "{}",
1056 e.detail
1057 );
1058 }
1059
1060 #[test]
1061 fn custody_unknown_grade_is_asserted_and_named() {
1062 let mut c = HandoffCustody::asserted("x");
1063 c.grade = "verified".into();
1064 let e = HandoffCustody::effective(Some(&c));
1065 assert!(!e.live);
1066 assert_eq!(e.detail, "asserted (unknown custody grade \"verified\")");
1067 }
1068
1069 #[test]
1070 fn custody_asserted_carries_its_reason() {
1071 let c = HandoffCustody::asserted("same_computer");
1072 let e = HandoffCustody::effective(Some(&c));
1073 assert!(!e.live);
1074 assert_eq!(e.detail, "asserted (same_computer)");
1075 }
1076
1077 #[test]
1078 fn custody_live_with_evidence_is_live() {
1079 let c = HandoffCustody {
1080 grade: CUSTODY_LIVE.into(),
1081 reason: None,
1082 presentation_digest: Some(format!("sha256:{}", "ab".repeat(32))),
1083 challenge: Some("0123456789abcdef0123456789abcdef".into()),
1084 card_id: Some("art_card".into()),
1085 verifier: Some("agent://claude".into()),
1086 verified_at: Some("2026-09-02T00:00:00Z".into()),
1087 };
1088 let e = HandoffCustody::effective(Some(&c));
1089 assert!(e.live);
1090 assert_eq!(
1091 e.detail,
1092 "live -- card art_card, verified by agent://claude at 2026-09-02T00:00:00Z"
1093 );
1094 }
1095
1096 #[test]
1097 fn approval_irreversibility_fields_roundtrip_signed() {
1098 let signer = Ed25519Signer::generate("key_human").unwrap();
1099 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
1100 approval.irreversibility = Some("one_way_consequential".into());
1101 approval.quarantine_receipt = Some("art_deadbeef00112233".into());
1102
1103 let pt = payload_type("approval");
1104 let result = sign(&pt, &approval, &signer).unwrap();
1105 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
1106 assert_eq!(
1107 decoded.irreversibility.as_deref(),
1108 Some("one_way_consequential")
1109 );
1110 assert_eq!(
1111 decoded.quarantine_receipt.as_deref(),
1112 Some("art_deadbeef00112233")
1113 );
1114 }
1115
1116 #[test]
1117 fn irreversibility_vocabulary_is_closed_and_fails_strict() {
1118 for c in IRREVERSIBILITY_CLASSES {
1119 assert!(is_irreversibility_class(c));
1120 }
1121 assert!(!is_irreversibility_class("reversible"));
1122 assert!(!is_irreversibility_class(""));
1123 assert!(!irreversibility_requires_quarantine("two_way"));
1125 assert!(!irreversibility_requires_quarantine("one_way_recoverable"));
1126 assert!(irreversibility_requires_quarantine("one_way_consequential"));
1127 assert!(irreversibility_requires_quarantine("one_way_terminal"));
1128 assert!(irreversibility_requires_quarantine(
1130 "definitely_fine_trust_me"
1131 ));
1132 }
1133
1134 #[test]
1135 fn approval_scope_full_grant_roundtrips() {
1136 let signer = Ed25519Signer::generate("key_piyush").unwrap();
1140
1141 let mut approval = ApprovalStatement::new("human://piyush", "nonce_deadbeef");
1142 approval.description = Some("Deploy production after final review".into());
1143 approval.scope = Some(ApprovalScope {
1144 max_actions: Some(1),
1145 valid_until: None,
1146 allowed_actors: vec!["agent://deployer".into()],
1147 allowed_actions: vec!["deploy.production".into()],
1148 allowed_subjects: vec!["env://production".into()],
1149 extra: None,
1150 });
1151
1152 let pt = payload_type("approval");
1153 let result = sign(&pt, &approval, &signer).unwrap();
1154 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
1155 let scope = decoded.scope.expect("scope must round-trip");
1156
1157 assert_eq!(scope.allowed_actors, vec!["agent://deployer".to_string()]);
1158 assert_eq!(scope.allowed_actions, vec!["deploy.production".to_string()]);
1159 assert_eq!(scope.allowed_subjects, vec!["env://production".to_string()]);
1160 assert_eq!(scope.max_actions, Some(1));
1161 }
1162
1163 #[test]
1164 fn approval_scope_is_unscoped_predicate() {
1165 assert!(ApprovalScope::default().is_unscoped());
1167
1168 assert!(!ApprovalScope {
1170 max_actions: Some(1),
1171 ..Default::default()
1172 }
1173 .is_unscoped());
1174 assert!(!ApprovalScope {
1175 valid_until: Some("2030-01-01T00:00:00Z".into()),
1176 ..Default::default()
1177 }
1178 .is_unscoped());
1179 assert!(!ApprovalScope {
1180 allowed_actors: vec!["agent://x".into()],
1181 ..Default::default()
1182 }
1183 .is_unscoped());
1184 assert!(!ApprovalScope {
1185 allowed_actions: vec!["doit".into()],
1186 ..Default::default()
1187 }
1188 .is_unscoped());
1189 assert!(!ApprovalScope {
1190 allowed_subjects: vec!["env://prod".into()],
1191 ..Default::default()
1192 }
1193 .is_unscoped());
1194 }
1195
1196 #[test]
1197 fn approval_scope_legacy_payloads_decode_with_empty_new_fields() {
1198 let legacy = serde_json::json!({
1202 "maxActions": 1,
1203 "allowedActions": ["stripe.payment_intent.create"]
1204 });
1205 let scope: ApprovalScope = serde_json::from_value(legacy).unwrap();
1206 assert_eq!(scope.max_actions, Some(1));
1207 assert_eq!(
1208 scope.allowed_actions,
1209 vec!["stripe.payment_intent.create".to_string()]
1210 );
1211 assert!(scope.allowed_actors.is_empty());
1213 assert!(scope.allowed_subjects.is_empty());
1214 assert!(!scope.is_unscoped()); }
1216
1217 #[test]
1218 fn handoff_statement() {
1219 let signer = Ed25519Signer::generate("key_agent").unwrap();
1220
1221 let handoff = HandoffStatement::new(
1222 "agent://researcher",
1223 "agent://checkout",
1224 vec!["art_aabbccdd11223344aabbccdd11223344".into()],
1225 );
1226
1227 let pt = payload_type("handoff");
1228 let result = sign(&pt, &handoff, &signer).unwrap();
1229 let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
1230
1231 assert_eq!(decoded.from, "agent://researcher");
1232 assert_eq!(decoded.to, "agent://checkout");
1233 assert_eq!(decoded.artifacts.len(), 1);
1234 }
1235
1236 #[test]
1237 fn receipt_statement() {
1238 let signer = Ed25519Signer::generate("key_system").unwrap();
1239
1240 let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
1241 receipt.payload = Some(serde_json::json!({
1242 "eventId": "evt_abc123",
1243 "status": "succeeded"
1244 }));
1245
1246 let pt = payload_type("receipt");
1247 let result = sign(&pt, &receipt, &signer).unwrap();
1248 let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
1249
1250 assert_eq!(decoded.system, "system://stripe-webhook");
1251 assert_eq!(decoded.kind, "confirmation");
1252 }
1253
1254 #[test]
1255 fn nonce_binding_survives_serialization() {
1256 let signer = Ed25519Signer::generate("key_test").unwrap();
1257
1258 let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
1261 let pt = payload_type("approval");
1262 let signed = sign(&pt, &approval, &signer).unwrap();
1263
1264 let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
1265 assert_eq!(
1266 decoded.nonce, "secure_nonce_xyz",
1267 "nonce must survive serialization"
1268 );
1269 }
1270
1271 #[test]
1272 fn decision_statement_sign_verify() {
1273 let signer = Ed25519Signer::generate("key_test").unwrap();
1274 let verifier = Verifier::from_signer(&signer);
1275
1276 let mut stmt = DecisionStatement::new("agent://analyst");
1277 stmt.model = Some("claude-opus-4".into());
1278 stmt.tokens_in = Some(8432);
1279 stmt.tokens_out = Some(1247);
1280 stmt.summary = Some("Contract looks standard.".into());
1281 stmt.confidence = Some(0.91);
1282
1283 let pt = payload_type("decision");
1284 let result = sign(&pt, &stmt, &signer).unwrap();
1285
1286 assert!(result.artifact_id.starts_with("art_"));
1287
1288 let vr = verifier.verify(&result.envelope).unwrap();
1289 assert_eq!(vr.artifact_id, result.artifact_id);
1290
1291 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
1293 assert_eq!(decoded.actor, "agent://analyst");
1294 assert_eq!(decoded.model, Some("claude-opus-4".into()));
1295 assert_eq!(decoded.tokens_in, Some(8432));
1296 assert_eq!(decoded.tokens_out, Some(1247));
1297 assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
1298 assert_eq!(decoded.confidence, Some(0.91));
1299 assert_eq!(decoded.type_, TYPE_DECISION);
1300 }
1301
1302 #[test]
1303 fn decision_statement_provider_roundtrips() {
1304 let signer = Ed25519Signer::generate("key_test").unwrap();
1308 let verifier = Verifier::from_signer(&signer);
1309
1310 let mut stmt = DecisionStatement::new("agent://researcher");
1311 stmt.model = Some("kimi-k2".into());
1312 stmt.provider = Some("moonshot".into());
1313
1314 let pt = payload_type("decision");
1315 let result = sign(&pt, &stmt, &signer).unwrap();
1316 verifier.verify(&result.envelope).unwrap();
1317
1318 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
1319 assert_eq!(decoded.model, Some("kimi-k2".into()));
1320 assert_eq!(decoded.provider, Some("moonshot".into()));
1321 }
1322
1323 #[test]
1324 fn decision_statement_legacy_payload_without_provider_decodes() {
1325 let raw = serde_json::json!({
1331 "type": TYPE_DECISION,
1332 "timestamp": "2026-04-30T12:00:00Z",
1333 "actor": "agent://legacy",
1334 "model": "claude-opus-4",
1335 });
1336 let parsed: DecisionStatement = serde_json::from_value(raw).unwrap();
1337 assert_eq!(parsed.model, Some("claude-opus-4".into()));
1338 assert_eq!(parsed.provider, None);
1339 }
1340
1341 #[test]
1342 fn different_statement_types_different_ids() {
1343 let signer = Ed25519Signer::generate("key_test").unwrap();
1346
1347 let action = ActionStatement::new("agent://test", "do.thing");
1348 let approval = ApprovalStatement::new("human://test", "nonce_123");
1349
1350 let r_action = sign(&payload_type("action"), &action, &signer).unwrap();
1351 let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
1352
1353 assert_ne!(r_action.artifact_id, r_approval.artifact_id);
1354 }
1355
1356 #[test]
1357 fn timestamp_format() {
1358 let ts = unix_to_rfc3339(0);
1359 assert_eq!(ts, "1970-01-01T00:00:00Z");
1360
1361 let ts2 = unix_to_rfc3339(1_000_000_000);
1362 assert_eq!(ts2, "2001-09-09T01:46:40Z");
1363 }
1364}