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