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)]
43pub struct ApprovalScope {
44 #[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
46 pub max_actions: Option<u32>,
47
48 #[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")]
50 pub valid_until: Option<String>,
51
52 #[serde(rename = "allowedActions", skip_serializing_if = "Vec::is_empty", default)]
54 pub allowed_actions: Vec<String>,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub extra: Option<serde_json::Value>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ActionStatement {
67 #[serde(rename = "type")]
69 pub type_: String,
70
71 pub timestamp: String,
73
74 pub actor: String,
76
77 pub action: String,
79
80 #[serde(default, skip_serializing_if = "is_empty_subject")]
81 pub subject: SubjectRef,
82
83 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
85 pub parent_id: Option<String>,
86
87 #[serde(rename = "approvalNonce", skip_serializing_if = "Option::is_none")]
91 pub approval_nonce: Option<String>,
92
93 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
94 pub policy_ref: Option<String>,
95
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub meta: Option<serde_json::Value>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ApprovalStatement {
108 #[serde(rename = "type")]
109 pub type_: String,
110 pub timestamp: String,
111
112 pub approver: String,
114
115 #[serde(default, skip_serializing_if = "is_empty_subject")]
116 pub subject: SubjectRef,
117
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub description: Option<String>,
120
121 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
123 pub expires_at: Option<String>,
124
125 pub delegatable: bool,
127
128 pub nonce: String,
132
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub scope: Option<ApprovalScope>,
135
136 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
137 pub policy_ref: Option<String>,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub meta: Option<serde_json::Value>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct HandoffStatement {
149 #[serde(rename = "type")]
150 pub type_: String,
151 pub timestamp: String,
152
153 pub from: String,
155 pub to: String,
157
158 pub artifacts: Vec<String>,
160
161 #[serde(rename = "approvalIds", default, skip_serializing_if = "Vec::is_empty")]
163 pub approval_ids: Vec<String>,
164
165 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub obligations: Vec<String>,
168
169 pub delegatable: bool,
170
171 #[serde(rename = "taskRef", skip_serializing_if = "Option::is_none")]
172 pub task_ref: Option<String>,
173
174 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
175 pub policy_ref: Option<String>,
176
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub meta: Option<serde_json::Value>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct EndorsementStatement {
186 #[serde(rename = "type")]
187 pub type_: String,
188 pub timestamp: String,
189
190 pub endorser: String,
192 pub subject: SubjectRef,
193
194 pub kind: String,
197
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub rationale: Option<String>,
200
201 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
202 pub expires_at: Option<String>,
203
204 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
205 pub policy_ref: Option<String>,
206
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub meta: Option<serde_json::Value>,
209}
210
211impl EndorsementStatement {
212 pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
213 Self {
214 type_: TYPE_ENDORSEMENT.into(),
215 timestamp: now_rfc3339(),
216 endorser: endorser.into(),
217 subject: SubjectRef::default(),
218 kind: kind.into(),
219 rationale: None,
220 expires_at: None,
221 policy_ref: None,
222 meta: None,
223 }
224 }
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ReceiptStatement {
232 #[serde(rename = "type")]
233 pub type_: String,
234 pub timestamp: String,
235
236 pub system: String,
239
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub subject: Option<SubjectRef>,
242
243 pub kind: String,
245
246 #[serde(skip_serializing_if = "Option::is_none")]
247 pub payload: Option<serde_json::Value>,
248
249 #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
250 pub payload_digest: Option<String>,
251
252 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
253 pub policy_ref: Option<String>,
254
255 #[serde(skip_serializing_if = "Option::is_none")]
256 pub meta: Option<serde_json::Value>,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct ArtifactRef {
262 pub id: String,
263 pub digest: String,
264 #[serde(rename = "type")]
265 pub type_: String,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct BundleStatement {
271 #[serde(rename = "type")]
272 pub type_: String,
273 pub timestamp: String,
274
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub tag: Option<String>,
277
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub description: Option<String>,
280
281 pub artifacts: Vec<ArtifactRef>,
282
283 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
284 pub policy_ref: Option<String>,
285
286 #[serde(skip_serializing_if = "Option::is_none")]
287 pub meta: Option<serde_json::Value>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct DecisionStatement {
296 #[serde(rename = "type")]
298 pub type_: String,
299
300 pub timestamp: String,
302
303 pub actor: String,
305
306 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
308 pub parent_id: Option<String>,
309
310 #[serde(skip_serializing_if = "Option::is_none")]
312 pub model: Option<String>,
313
314 #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
316 pub model_version: Option<String>,
317
318 #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
320 pub tokens_in: Option<u64>,
321
322 #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
324 pub tokens_out: Option<u64>,
325
326 #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
328 pub prompt_digest: Option<String>,
329
330 #[serde(skip_serializing_if = "Option::is_none")]
332 pub summary: Option<String>,
333
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub confidence: Option<f64>,
337
338 #[serde(skip_serializing_if = "Option::is_none")]
340 pub alternatives: Option<Vec<String>>,
341
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub meta: Option<serde_json::Value>,
345}
346
347fn is_empty_subject(s: &SubjectRef) -> bool {
349 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
350}
351
352impl ActionStatement {
355 pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
356 Self {
357 type_: TYPE_ACTION.into(),
358 timestamp: now_rfc3339(),
359 actor: actor.into(),
360 action: action.into(),
361 subject: SubjectRef::default(),
362 parent_id: None,
363 approval_nonce: None,
364 policy_ref: None,
365 meta: None,
366 }
367 }
368}
369
370impl ApprovalStatement {
371 pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
372 Self {
373 type_: TYPE_APPROVAL.into(),
374 timestamp: now_rfc3339(),
375 approver: approver.into(),
376 subject: SubjectRef::default(),
377 description: None,
378 expires_at: None,
379 delegatable: false,
380 nonce: nonce.into(),
381 scope: None,
382 policy_ref: None,
383 meta: None,
384 }
385 }
386}
387
388impl HandoffStatement {
389 pub fn new(
390 from: impl Into<String>,
391 to: impl Into<String>,
392 artifacts: Vec<String>,
393 ) -> Self {
394 Self {
395 type_: TYPE_HANDOFF.into(),
396 timestamp: now_rfc3339(),
397 from: from.into(),
398 to: to.into(),
399 artifacts,
400 approval_ids: vec![],
401 obligations: vec![],
402 delegatable: false,
403 task_ref: None,
404 policy_ref: None,
405 meta: None,
406 }
407 }
408}
409
410impl ReceiptStatement {
411 pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
412 Self {
413 type_: TYPE_RECEIPT.into(),
414 timestamp: now_rfc3339(),
415 system: system.into(),
416 subject: None,
417 kind: kind.into(),
418 payload: None,
419 payload_digest: None,
420 policy_ref: None,
421 meta: None,
422 }
423 }
424}
425
426impl DecisionStatement {
427 pub fn new(actor: impl Into<String>) -> Self {
428 Self {
429 type_: TYPE_DECISION.into(),
430 timestamp: now_rfc3339(),
431 actor: actor.into(),
432 parent_id: None,
433 model: None,
434 model_version: None,
435 tokens_in: None,
436 tokens_out: None,
437 prompt_digest: None,
438 summary: None,
439 confidence: None,
440 alternatives: None,
441 meta: None,
442 }
443 }
444}
445
446fn now_rfc3339() -> String {
447 use std::time::{SystemTime, UNIX_EPOCH};
450 let secs = SystemTime::now()
451 .duration_since(UNIX_EPOCH)
452 .unwrap_or_default()
453 .as_secs();
454 unix_to_rfc3339(secs)
455}
456
457pub fn unix_to_rfc3339(secs: u64) -> String {
458 let s = secs;
461 let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
462 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
463}
464
465fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
466 let sec = s % 60;
467 let mins = s / 60;
468 let min = mins % 60;
469 let hrs = mins / 60;
470 let hour = hrs % 24;
471 let days = hrs / 24;
472
473 let (y, m, d) = days_to_ymd(days);
475 (y, m, d, hour, min, sec)
476}
477
478fn days_to_ymd(days: u64) -> (u64, u64, u64) {
479 let mut d = days;
481 let mut year = 1970u64;
482 loop {
483 let dy = if is_leap(year) { 366 } else { 365 };
484 if d < dy { break; }
485 d -= dy;
486 year += 1;
487 }
488 let months = if is_leap(year) {
489 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
490 } else {
491 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
492 };
493 let mut month = 1u64;
494 for dm in months {
495 if d < dm { break; }
496 d -= dm;
497 month += 1;
498 }
499 (year, month, d + 1)
500}
501
502fn is_leap(y: u64) -> bool {
503 (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::attestation::{sign, Ed25519Signer, Verifier};
510
511 #[test]
512 fn payload_type_format() {
513 assert_eq!(
514 payload_type("action"),
515 "application/vnd.treeship.action.v1+json"
516 );
517 assert_eq!(
518 payload_type("approval"),
519 "application/vnd.treeship.approval.v1+json"
520 );
521 }
522
523 #[test]
524 fn action_statement_sign_verify() {
525 let signer = Ed25519Signer::generate("key_test").unwrap();
526 let verifier = Verifier::from_signer(&signer);
527
528 let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
529 stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
530
531 let pt = payload_type("action");
532 let result = sign(&pt, &stmt, &signer).unwrap();
533
534 assert!(result.artifact_id.starts_with("art_"));
535
536 let vr = verifier.verify(&result.envelope).unwrap();
537 assert_eq!(vr.artifact_id, result.artifact_id);
538
539 let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
541 assert_eq!(decoded.actor, "agent://researcher");
542 assert_eq!(decoded.action, "tool.call");
543 assert_eq!(decoded.type_, TYPE_ACTION);
544 }
545
546 #[test]
547 fn approval_statement_with_nonce() {
548 let signer = Ed25519Signer::generate("key_human").unwrap();
549
550 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
551 approval.description = Some("approve laptop purchase < $1500".into());
552 approval.scope = Some(ApprovalScope {
553 max_actions: Some(1),
554 allowed_actions: vec!["stripe.payment_intent.create".into()],
555 ..Default::default()
556 });
557
558 let pt = payload_type("approval");
559 let result = sign(&pt, &approval, &signer).unwrap();
560 assert!(result.artifact_id.starts_with("art_"));
561
562 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
563 assert_eq!(decoded.nonce, "nonce_abc123");
564 assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
565 }
566
567 #[test]
568 fn handoff_statement() {
569 let signer = Ed25519Signer::generate("key_agent").unwrap();
570
571 let handoff = HandoffStatement::new(
572 "agent://researcher",
573 "agent://checkout",
574 vec!["art_aabbccdd11223344aabbccdd11223344".into()],
575 );
576
577 let pt = payload_type("handoff");
578 let result = sign(&pt, &handoff, &signer).unwrap();
579 let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
580
581 assert_eq!(decoded.from, "agent://researcher");
582 assert_eq!(decoded.to, "agent://checkout");
583 assert_eq!(decoded.artifacts.len(), 1);
584 }
585
586 #[test]
587 fn receipt_statement() {
588 let signer = Ed25519Signer::generate("key_system").unwrap();
589
590 let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
591 receipt.payload = Some(serde_json::json!({
592 "eventId": "evt_abc123",
593 "status": "succeeded"
594 }));
595
596 let pt = payload_type("receipt");
597 let result = sign(&pt, &receipt, &signer).unwrap();
598 let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
599
600 assert_eq!(decoded.system, "system://stripe-webhook");
601 assert_eq!(decoded.kind, "confirmation");
602 }
603
604 #[test]
605 fn nonce_binding_survives_serialization() {
606 let signer = Ed25519Signer::generate("key_test").unwrap();
607
608 let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
611 let pt = payload_type("approval");
612 let signed = sign(&pt, &approval, &signer).unwrap();
613
614 let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
615 assert_eq!(decoded.nonce, "secure_nonce_xyz", "nonce must survive serialization");
616 }
617
618 #[test]
619 fn decision_statement_sign_verify() {
620 let signer = Ed25519Signer::generate("key_test").unwrap();
621 let verifier = Verifier::from_signer(&signer);
622
623 let mut stmt = DecisionStatement::new("agent://analyst");
624 stmt.model = Some("claude-opus-4".into());
625 stmt.tokens_in = Some(8432);
626 stmt.tokens_out = Some(1247);
627 stmt.summary = Some("Contract looks standard.".into());
628 stmt.confidence = Some(0.91);
629
630 let pt = payload_type("decision");
631 let result = sign(&pt, &stmt, &signer).unwrap();
632
633 assert!(result.artifact_id.starts_with("art_"));
634
635 let vr = verifier.verify(&result.envelope).unwrap();
636 assert_eq!(vr.artifact_id, result.artifact_id);
637
638 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
640 assert_eq!(decoded.actor, "agent://analyst");
641 assert_eq!(decoded.model, Some("claude-opus-4".into()));
642 assert_eq!(decoded.tokens_in, Some(8432));
643 assert_eq!(decoded.tokens_out, Some(1247));
644 assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
645 assert_eq!(decoded.confidence, Some(0.91));
646 assert_eq!(decoded.type_, TYPE_DECISION);
647 }
648
649 #[test]
650 fn different_statement_types_different_ids() {
651 let signer = Ed25519Signer::generate("key_test").unwrap();
654
655 let action = ActionStatement::new("agent://test", "do.thing");
656 let approval = ApprovalStatement::new("human://test", "nonce_123");
657
658 let r_action = sign(&payload_type("action"), &action, &signer).unwrap();
659 let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
660
661 assert_ne!(r_action.artifact_id, r_approval.artifact_id);
662 }
663
664 #[test]
665 fn timestamp_format() {
666 let ts = unix_to_rfc3339(0);
667 assert_eq!(ts, "1970-01-01T00:00:00Z");
668
669 let ts2 = unix_to_rfc3339(1_000_000_000);
670 assert_eq!(ts2, "2001-09-09T01:46:40Z");
671 }
672}