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 ApprovalRevocation, ApprovalUse, CheckpointKind, HubCheckpointVerification,
28 JournalCheckpoint, ReplayCheck, ReplayCheckLevel,
29 TYPE_APPROVAL_REVOCATION, TYPE_APPROVAL_USE, TYPE_JOURNAL_CHECKPOINT,
30 approval_revocation_record_digest, approval_use_record_digest,
31 journal_checkpoint_record_digest, nonce_digest, verify_hub_checkpoint_signature,
32};
33
34use serde::{Deserialize, Serialize};
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct SubjectRef {
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub digest: Option<String>,
43
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub uri: Option<String>,
47
48 #[serde(rename = "artifactId", skip_serializing_if = "Option::is_none")]
50 pub artifact_id: Option<String>,
51}
52
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct ApprovalScope {
69 #[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
73 pub max_actions: Option<u32>,
74
75 #[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")]
80 pub valid_until: Option<String>,
81
82 #[serde(rename = "allowedActors", skip_serializing_if = "Vec::is_empty", default)]
85 pub allowed_actors: Vec<String>,
86
87 #[serde(rename = "allowedActions", skip_serializing_if = "Vec::is_empty", default)]
90 pub allowed_actions: Vec<String>,
91
92 #[serde(rename = "allowedSubjects", skip_serializing_if = "Vec::is_empty", default)]
97 pub allowed_subjects: Vec<String>,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub extra: Option<serde_json::Value>,
102}
103
104impl ApprovalScope {
105 pub fn is_unscoped(&self) -> bool {
110 self.max_actions.is_none()
111 && self.valid_until.is_none()
112 && self.allowed_actors.is_empty()
113 && self.allowed_actions.is_empty()
114 && self.allowed_subjects.is_empty()
115 && self.extra.is_none()
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ActionStatement {
125 #[serde(rename = "type")]
127 pub type_: String,
128
129 pub timestamp: String,
131
132 pub actor: String,
134
135 pub action: String,
137
138 #[serde(default, skip_serializing_if = "is_empty_subject")]
139 pub subject: SubjectRef,
140
141 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
143 pub parent_id: Option<String>,
144
145 #[serde(rename = "approvalNonce", skip_serializing_if = "Option::is_none")]
149 pub approval_nonce: Option<String>,
150
151 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
152 pub policy_ref: Option<String>,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub meta: Option<serde_json::Value>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct ApprovalStatement {
166 #[serde(rename = "type")]
167 pub type_: String,
168 pub timestamp: String,
169
170 pub approver: String,
172
173 #[serde(default, skip_serializing_if = "is_empty_subject")]
174 pub subject: SubjectRef,
175
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub description: Option<String>,
178
179 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
181 pub expires_at: Option<String>,
182
183 pub delegatable: bool,
185
186 pub nonce: String,
190
191 #[serde(skip_serializing_if = "Option::is_none")]
192 pub scope: Option<ApprovalScope>,
193
194 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
195 pub policy_ref: Option<String>,
196
197 #[serde(skip_serializing_if = "Option::is_none")]
198 pub meta: Option<serde_json::Value>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct HandoffStatement {
207 #[serde(rename = "type")]
208 pub type_: String,
209 pub timestamp: String,
210
211 pub from: String,
213 pub to: String,
215
216 pub artifacts: Vec<String>,
218
219 #[serde(rename = "approvalIds", default, skip_serializing_if = "Vec::is_empty")]
221 pub approval_ids: Vec<String>,
222
223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub obligations: Vec<String>,
226
227 pub delegatable: bool,
228
229 #[serde(rename = "taskRef", skip_serializing_if = "Option::is_none")]
230 pub task_ref: Option<String>,
231
232 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
233 pub policy_ref: Option<String>,
234
235 #[serde(skip_serializing_if = "Option::is_none")]
236 pub meta: Option<serde_json::Value>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct EndorsementStatement {
244 #[serde(rename = "type")]
245 pub type_: String,
246 pub timestamp: String,
247
248 pub endorser: String,
250 pub subject: SubjectRef,
251
252 pub kind: String,
255
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub rationale: Option<String>,
258
259 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
260 pub expires_at: Option<String>,
261
262 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
263 pub policy_ref: Option<String>,
264
265 #[serde(skip_serializing_if = "Option::is_none")]
266 pub meta: Option<serde_json::Value>,
267}
268
269impl EndorsementStatement {
270 pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
271 Self {
272 type_: TYPE_ENDORSEMENT.into(),
273 timestamp: now_rfc3339(),
274 endorser: endorser.into(),
275 subject: SubjectRef::default(),
276 kind: kind.into(),
277 rationale: None,
278 expires_at: None,
279 policy_ref: None,
280 meta: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct ReceiptStatement {
290 #[serde(rename = "type")]
291 pub type_: String,
292 pub timestamp: String,
293
294 pub system: String,
297
298 #[serde(skip_serializing_if = "Option::is_none")]
299 pub subject: Option<SubjectRef>,
300
301 pub kind: String,
303
304 #[serde(skip_serializing_if = "Option::is_none")]
305 pub payload: Option<serde_json::Value>,
306
307 #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
308 pub payload_digest: Option<String>,
309
310 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
311 pub policy_ref: Option<String>,
312
313 #[serde(skip_serializing_if = "Option::is_none")]
314 pub meta: Option<serde_json::Value>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct ArtifactRef {
320 pub id: String,
321 pub digest: String,
322 #[serde(rename = "type")]
323 pub type_: String,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct BundleStatement {
329 #[serde(rename = "type")]
330 pub type_: String,
331 pub timestamp: String,
332
333 #[serde(skip_serializing_if = "Option::is_none")]
334 pub tag: Option<String>,
335
336 #[serde(skip_serializing_if = "Option::is_none")]
337 pub description: Option<String>,
338
339 pub artifacts: Vec<ArtifactRef>,
340
341 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
342 pub policy_ref: Option<String>,
343
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub meta: Option<serde_json::Value>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct DecisionStatement {
354 #[serde(rename = "type")]
356 pub type_: String,
357
358 pub timestamp: String,
360
361 pub actor: String,
363
364 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
366 pub parent_id: Option<String>,
367
368 #[serde(skip_serializing_if = "Option::is_none")]
370 pub model: Option<String>,
371
372 #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
374 pub model_version: Option<String>,
375
376 #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
378 pub tokens_in: Option<u64>,
379
380 #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
382 pub tokens_out: Option<u64>,
383
384 #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
386 pub prompt_digest: Option<String>,
387
388 #[serde(skip_serializing_if = "Option::is_none")]
390 pub summary: Option<String>,
391
392 #[serde(skip_serializing_if = "Option::is_none")]
394 pub confidence: Option<f64>,
395
396 #[serde(skip_serializing_if = "Option::is_none")]
398 pub alternatives: Option<Vec<String>>,
399
400 #[serde(skip_serializing_if = "Option::is_none")]
402 pub meta: Option<serde_json::Value>,
403}
404
405fn is_empty_subject(s: &SubjectRef) -> bool {
407 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
408}
409
410impl ActionStatement {
413 pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
414 Self {
415 type_: TYPE_ACTION.into(),
416 timestamp: now_rfc3339(),
417 actor: actor.into(),
418 action: action.into(),
419 subject: SubjectRef::default(),
420 parent_id: None,
421 approval_nonce: None,
422 policy_ref: None,
423 meta: None,
424 }
425 }
426}
427
428impl ApprovalStatement {
429 pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
430 Self {
431 type_: TYPE_APPROVAL.into(),
432 timestamp: now_rfc3339(),
433 approver: approver.into(),
434 subject: SubjectRef::default(),
435 description: None,
436 expires_at: None,
437 delegatable: false,
438 nonce: nonce.into(),
439 scope: None,
440 policy_ref: None,
441 meta: None,
442 }
443 }
444}
445
446impl HandoffStatement {
447 pub fn new(
448 from: impl Into<String>,
449 to: impl Into<String>,
450 artifacts: Vec<String>,
451 ) -> Self {
452 Self {
453 type_: TYPE_HANDOFF.into(),
454 timestamp: now_rfc3339(),
455 from: from.into(),
456 to: to.into(),
457 artifacts,
458 approval_ids: vec![],
459 obligations: vec![],
460 delegatable: false,
461 task_ref: None,
462 policy_ref: None,
463 meta: None,
464 }
465 }
466}
467
468impl ReceiptStatement {
469 pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
470 Self {
471 type_: TYPE_RECEIPT.into(),
472 timestamp: now_rfc3339(),
473 system: system.into(),
474 subject: None,
475 kind: kind.into(),
476 payload: None,
477 payload_digest: None,
478 policy_ref: None,
479 meta: None,
480 }
481 }
482}
483
484impl DecisionStatement {
485 pub fn new(actor: impl Into<String>) -> Self {
486 Self {
487 type_: TYPE_DECISION.into(),
488 timestamp: now_rfc3339(),
489 actor: actor.into(),
490 parent_id: None,
491 model: None,
492 model_version: None,
493 tokens_in: None,
494 tokens_out: None,
495 prompt_digest: None,
496 summary: None,
497 confidence: None,
498 alternatives: None,
499 meta: None,
500 }
501 }
502}
503
504fn now_rfc3339() -> String {
505 use std::time::{SystemTime, UNIX_EPOCH};
508 let secs = SystemTime::now()
509 .duration_since(UNIX_EPOCH)
510 .unwrap_or_default()
511 .as_secs();
512 unix_to_rfc3339(secs)
513}
514
515pub fn unix_to_rfc3339(secs: u64) -> String {
516 let s = secs;
519 let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
520 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
521}
522
523fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
524 let sec = s % 60;
525 let mins = s / 60;
526 let min = mins % 60;
527 let hrs = mins / 60;
528 let hour = hrs % 24;
529 let days = hrs / 24;
530
531 let (y, m, d) = days_to_ymd(days);
533 (y, m, d, hour, min, sec)
534}
535
536fn days_to_ymd(days: u64) -> (u64, u64, u64) {
537 let mut d = days;
539 let mut year = 1970u64;
540 loop {
541 let dy = if is_leap(year) { 366 } else { 365 };
542 if d < dy { break; }
543 d -= dy;
544 year += 1;
545 }
546 let months = if is_leap(year) {
547 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
548 } else {
549 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
550 };
551 let mut month = 1u64;
552 for dm in months {
553 if d < dm { break; }
554 d -= dm;
555 month += 1;
556 }
557 (year, month, d + 1)
558}
559
560fn is_leap(y: u64) -> bool {
561 (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use crate::attestation::{sign, Ed25519Signer, Verifier};
568
569 #[test]
570 fn payload_type_format() {
571 assert_eq!(
572 payload_type("action"),
573 "application/vnd.treeship.action.v1+json"
574 );
575 assert_eq!(
576 payload_type("approval"),
577 "application/vnd.treeship.approval.v1+json"
578 );
579 }
580
581 #[test]
582 fn action_statement_sign_verify() {
583 let signer = Ed25519Signer::generate("key_test").unwrap();
584 let verifier = Verifier::from_signer(&signer);
585
586 let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
587 stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
588
589 let pt = payload_type("action");
590 let result = sign(&pt, &stmt, &signer).unwrap();
591
592 assert!(result.artifact_id.starts_with("art_"));
593
594 let vr = verifier.verify(&result.envelope).unwrap();
595 assert_eq!(vr.artifact_id, result.artifact_id);
596
597 let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
599 assert_eq!(decoded.actor, "agent://researcher");
600 assert_eq!(decoded.action, "tool.call");
601 assert_eq!(decoded.type_, TYPE_ACTION);
602 }
603
604 #[test]
605 fn approval_statement_with_nonce() {
606 let signer = Ed25519Signer::generate("key_human").unwrap();
607
608 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
609 approval.description = Some("approve laptop purchase < $1500".into());
610 approval.scope = Some(ApprovalScope {
611 max_actions: Some(1),
612 allowed_actions: vec!["stripe.payment_intent.create".into()],
613 ..Default::default()
614 });
615
616 let pt = payload_type("approval");
617 let result = sign(&pt, &approval, &signer).unwrap();
618 assert!(result.artifact_id.starts_with("art_"));
619
620 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
621 assert_eq!(decoded.nonce, "nonce_abc123");
622 assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
623 }
624
625 #[test]
626 fn approval_scope_full_grant_roundtrips() {
627 let signer = Ed25519Signer::generate("key_piyush").unwrap();
631
632 let mut approval = ApprovalStatement::new("human://piyush", "nonce_deadbeef");
633 approval.description = Some("Deploy production after final review".into());
634 approval.scope = Some(ApprovalScope {
635 max_actions: Some(1),
636 valid_until: None,
637 allowed_actors: vec!["agent://deployer".into()],
638 allowed_actions: vec!["deploy.production".into()],
639 allowed_subjects: vec!["env://production".into()],
640 extra: None,
641 });
642
643 let pt = payload_type("approval");
644 let result = sign(&pt, &approval, &signer).unwrap();
645 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
646 let scope = decoded.scope.expect("scope must round-trip");
647
648 assert_eq!(scope.allowed_actors, vec!["agent://deployer".to_string()]);
649 assert_eq!(scope.allowed_actions, vec!["deploy.production".to_string()]);
650 assert_eq!(scope.allowed_subjects, vec!["env://production".to_string()]);
651 assert_eq!(scope.max_actions, Some(1));
652 }
653
654 #[test]
655 fn approval_scope_is_unscoped_predicate() {
656 assert!(ApprovalScope::default().is_unscoped());
658
659 assert!(!ApprovalScope { max_actions: Some(1), ..Default::default() }.is_unscoped());
661 assert!(!ApprovalScope { valid_until: Some("2030-01-01T00:00:00Z".into()), ..Default::default() }.is_unscoped());
662 assert!(!ApprovalScope { allowed_actors: vec!["agent://x".into()], ..Default::default() }.is_unscoped());
663 assert!(!ApprovalScope { allowed_actions: vec!["doit".into()], ..Default::default() }.is_unscoped());
664 assert!(!ApprovalScope { allowed_subjects: vec!["env://prod".into()], ..Default::default() }.is_unscoped());
665 }
666
667 #[test]
668 fn approval_scope_legacy_payloads_decode_with_empty_new_fields() {
669 let legacy = serde_json::json!({
673 "maxActions": 1,
674 "allowedActions": ["stripe.payment_intent.create"]
675 });
676 let scope: ApprovalScope = serde_json::from_value(legacy).unwrap();
677 assert_eq!(scope.max_actions, Some(1));
678 assert_eq!(scope.allowed_actions, vec!["stripe.payment_intent.create".to_string()]);
679 assert!(scope.allowed_actors.is_empty());
681 assert!(scope.allowed_subjects.is_empty());
682 assert!(!scope.is_unscoped()); }
684
685 #[test]
686 fn handoff_statement() {
687 let signer = Ed25519Signer::generate("key_agent").unwrap();
688
689 let handoff = HandoffStatement::new(
690 "agent://researcher",
691 "agent://checkout",
692 vec!["art_aabbccdd11223344aabbccdd11223344".into()],
693 );
694
695 let pt = payload_type("handoff");
696 let result = sign(&pt, &handoff, &signer).unwrap();
697 let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
698
699 assert_eq!(decoded.from, "agent://researcher");
700 assert_eq!(decoded.to, "agent://checkout");
701 assert_eq!(decoded.artifacts.len(), 1);
702 }
703
704 #[test]
705 fn receipt_statement() {
706 let signer = Ed25519Signer::generate("key_system").unwrap();
707
708 let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
709 receipt.payload = Some(serde_json::json!({
710 "eventId": "evt_abc123",
711 "status": "succeeded"
712 }));
713
714 let pt = payload_type("receipt");
715 let result = sign(&pt, &receipt, &signer).unwrap();
716 let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
717
718 assert_eq!(decoded.system, "system://stripe-webhook");
719 assert_eq!(decoded.kind, "confirmation");
720 }
721
722 #[test]
723 fn nonce_binding_survives_serialization() {
724 let signer = Ed25519Signer::generate("key_test").unwrap();
725
726 let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
729 let pt = payload_type("approval");
730 let signed = sign(&pt, &approval, &signer).unwrap();
731
732 let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
733 assert_eq!(decoded.nonce, "secure_nonce_xyz", "nonce must survive serialization");
734 }
735
736 #[test]
737 fn decision_statement_sign_verify() {
738 let signer = Ed25519Signer::generate("key_test").unwrap();
739 let verifier = Verifier::from_signer(&signer);
740
741 let mut stmt = DecisionStatement::new("agent://analyst");
742 stmt.model = Some("claude-opus-4".into());
743 stmt.tokens_in = Some(8432);
744 stmt.tokens_out = Some(1247);
745 stmt.summary = Some("Contract looks standard.".into());
746 stmt.confidence = Some(0.91);
747
748 let pt = payload_type("decision");
749 let result = sign(&pt, &stmt, &signer).unwrap();
750
751 assert!(result.artifact_id.starts_with("art_"));
752
753 let vr = verifier.verify(&result.envelope).unwrap();
754 assert_eq!(vr.artifact_id, result.artifact_id);
755
756 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
758 assert_eq!(decoded.actor, "agent://analyst");
759 assert_eq!(decoded.model, Some("claude-opus-4".into()));
760 assert_eq!(decoded.tokens_in, Some(8432));
761 assert_eq!(decoded.tokens_out, Some(1247));
762 assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
763 assert_eq!(decoded.confidence, Some(0.91));
764 assert_eq!(decoded.type_, TYPE_DECISION);
765 }
766
767 #[test]
768 fn different_statement_types_different_ids() {
769 let signer = Ed25519Signer::generate("key_test").unwrap();
772
773 let action = ActionStatement::new("agent://test", "do.thing");
774 let approval = ApprovalStatement::new("human://test", "nonce_123");
775
776 let r_action = sign(&payload_type("action"), &action, &signer).unwrap();
777 let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
778
779 assert_ne!(r_action.artifact_id, r_approval.artifact_id);
780 }
781
782 #[test]
783 fn timestamp_format() {
784 let ts = unix_to_rfc3339(0);
785 assert_eq!(ts, "1970-01-01T00:00:00Z");
786
787 let ts2 = unix_to_rfc3339(1_000_000_000);
788 assert_eq!(ts2, "2001-09-09T01:46:40Z");
789 }
790}