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
211#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ReceiptStatement {
216 #[serde(rename = "type")]
217 pub type_: String,
218 pub timestamp: String,
219
220 pub system: String,
223
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub subject: Option<SubjectRef>,
226
227 pub kind: String,
229
230 #[serde(skip_serializing_if = "Option::is_none")]
231 pub payload: Option<serde_json::Value>,
232
233 #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
234 pub payload_digest: Option<String>,
235
236 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
237 pub policy_ref: Option<String>,
238
239 #[serde(skip_serializing_if = "Option::is_none")]
240 pub meta: Option<serde_json::Value>,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct ArtifactRef {
246 pub id: String,
247 pub digest: String,
248 #[serde(rename = "type")]
249 pub type_: String,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct BundleStatement {
255 #[serde(rename = "type")]
256 pub type_: String,
257 pub timestamp: String,
258
259 #[serde(skip_serializing_if = "Option::is_none")]
260 pub tag: Option<String>,
261
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub description: Option<String>,
264
265 pub artifacts: Vec<ArtifactRef>,
266
267 #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
268 pub policy_ref: Option<String>,
269
270 #[serde(skip_serializing_if = "Option::is_none")]
271 pub meta: Option<serde_json::Value>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct DecisionStatement {
280 #[serde(rename = "type")]
282 pub type_: String,
283
284 pub timestamp: String,
286
287 pub actor: String,
289
290 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
292 pub parent_id: Option<String>,
293
294 #[serde(skip_serializing_if = "Option::is_none")]
296 pub model: Option<String>,
297
298 #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
300 pub model_version: Option<String>,
301
302 #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
304 pub tokens_in: Option<u64>,
305
306 #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
308 pub tokens_out: Option<u64>,
309
310 #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
312 pub prompt_digest: Option<String>,
313
314 #[serde(skip_serializing_if = "Option::is_none")]
316 pub summary: Option<String>,
317
318 #[serde(skip_serializing_if = "Option::is_none")]
320 pub confidence: Option<f64>,
321
322 #[serde(skip_serializing_if = "Option::is_none")]
324 pub alternatives: Option<Vec<String>>,
325
326 #[serde(skip_serializing_if = "Option::is_none")]
328 pub meta: Option<serde_json::Value>,
329}
330
331fn is_empty_subject(s: &SubjectRef) -> bool {
333 s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
334}
335
336impl ActionStatement {
339 pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
340 Self {
341 type_: TYPE_ACTION.into(),
342 timestamp: now_rfc3339(),
343 actor: actor.into(),
344 action: action.into(),
345 subject: SubjectRef::default(),
346 parent_id: None,
347 approval_nonce: None,
348 policy_ref: None,
349 meta: None,
350 }
351 }
352}
353
354impl ApprovalStatement {
355 pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
356 Self {
357 type_: TYPE_APPROVAL.into(),
358 timestamp: now_rfc3339(),
359 approver: approver.into(),
360 subject: SubjectRef::default(),
361 description: None,
362 expires_at: None,
363 delegatable: false,
364 nonce: nonce.into(),
365 scope: None,
366 policy_ref: None,
367 meta: None,
368 }
369 }
370}
371
372impl HandoffStatement {
373 pub fn new(
374 from: impl Into<String>,
375 to: impl Into<String>,
376 artifacts: Vec<String>,
377 ) -> Self {
378 Self {
379 type_: TYPE_HANDOFF.into(),
380 timestamp: now_rfc3339(),
381 from: from.into(),
382 to: to.into(),
383 artifacts,
384 approval_ids: vec![],
385 obligations: vec![],
386 delegatable: false,
387 task_ref: None,
388 policy_ref: None,
389 meta: None,
390 }
391 }
392}
393
394impl ReceiptStatement {
395 pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
396 Self {
397 type_: TYPE_RECEIPT.into(),
398 timestamp: now_rfc3339(),
399 system: system.into(),
400 subject: None,
401 kind: kind.into(),
402 payload: None,
403 payload_digest: None,
404 policy_ref: None,
405 meta: None,
406 }
407 }
408}
409
410impl DecisionStatement {
411 pub fn new(actor: impl Into<String>) -> Self {
412 Self {
413 type_: TYPE_DECISION.into(),
414 timestamp: now_rfc3339(),
415 actor: actor.into(),
416 parent_id: None,
417 model: None,
418 model_version: None,
419 tokens_in: None,
420 tokens_out: None,
421 prompt_digest: None,
422 summary: None,
423 confidence: None,
424 alternatives: None,
425 meta: None,
426 }
427 }
428}
429
430fn now_rfc3339() -> String {
431 use std::time::{SystemTime, UNIX_EPOCH};
434 let secs = SystemTime::now()
435 .duration_since(UNIX_EPOCH)
436 .unwrap_or_default()
437 .as_secs();
438 unix_to_rfc3339(secs)
439}
440
441pub fn unix_to_rfc3339(secs: u64) -> String {
442 let s = secs;
445 let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
446 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
447}
448
449fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
450 let sec = s % 60;
451 let mins = s / 60;
452 let min = mins % 60;
453 let hrs = mins / 60;
454 let hour = hrs % 24;
455 let days = hrs / 24;
456
457 let (y, m, d) = days_to_ymd(days);
459 (y, m, d, hour, min, sec)
460}
461
462fn days_to_ymd(days: u64) -> (u64, u64, u64) {
463 let mut d = days;
465 let mut year = 1970u64;
466 loop {
467 let dy = if is_leap(year) { 366 } else { 365 };
468 if d < dy { break; }
469 d -= dy;
470 year += 1;
471 }
472 let months = if is_leap(year) {
473 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
474 } else {
475 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
476 };
477 let mut month = 1u64;
478 for dm in months {
479 if d < dm { break; }
480 d -= dm;
481 month += 1;
482 }
483 (year, month, d + 1)
484}
485
486fn is_leap(y: u64) -> bool {
487 (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::attestation::{sign, Ed25519Signer, Verifier};
494
495 #[test]
496 fn payload_type_format() {
497 assert_eq!(
498 payload_type("action"),
499 "application/vnd.treeship.action.v1+json"
500 );
501 assert_eq!(
502 payload_type("approval"),
503 "application/vnd.treeship.approval.v1+json"
504 );
505 }
506
507 #[test]
508 fn action_statement_sign_verify() {
509 let signer = Ed25519Signer::generate("key_test").unwrap();
510 let verifier = Verifier::from_signer(&signer);
511
512 let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
513 stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
514
515 let pt = payload_type("action");
516 let result = sign(&pt, &stmt, &signer).unwrap();
517
518 assert!(result.artifact_id.starts_with("art_"));
519
520 let vr = verifier.verify(&result.envelope).unwrap();
521 assert_eq!(vr.artifact_id, result.artifact_id);
522
523 let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
525 assert_eq!(decoded.actor, "agent://researcher");
526 assert_eq!(decoded.action, "tool.call");
527 assert_eq!(decoded.type_, TYPE_ACTION);
528 }
529
530 #[test]
531 fn approval_statement_with_nonce() {
532 let signer = Ed25519Signer::generate("key_human").unwrap();
533
534 let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
535 approval.description = Some("approve laptop purchase < $1500".into());
536 approval.scope = Some(ApprovalScope {
537 max_actions: Some(1),
538 allowed_actions: vec!["stripe.payment_intent.create".into()],
539 ..Default::default()
540 });
541
542 let pt = payload_type("approval");
543 let result = sign(&pt, &approval, &signer).unwrap();
544 assert!(result.artifact_id.starts_with("art_"));
545
546 let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
547 assert_eq!(decoded.nonce, "nonce_abc123");
548 assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
549 }
550
551 #[test]
552 fn handoff_statement() {
553 let signer = Ed25519Signer::generate("key_agent").unwrap();
554
555 let handoff = HandoffStatement::new(
556 "agent://researcher",
557 "agent://checkout",
558 vec!["art_aabbccdd11223344aabbccdd11223344".into()],
559 );
560
561 let pt = payload_type("handoff");
562 let result = sign(&pt, &handoff, &signer).unwrap();
563 let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
564
565 assert_eq!(decoded.from, "agent://researcher");
566 assert_eq!(decoded.to, "agent://checkout");
567 assert_eq!(decoded.artifacts.len(), 1);
568 }
569
570 #[test]
571 fn receipt_statement() {
572 let signer = Ed25519Signer::generate("key_system").unwrap();
573
574 let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
575 receipt.payload = Some(serde_json::json!({
576 "eventId": "evt_abc123",
577 "status": "succeeded"
578 }));
579
580 let pt = payload_type("receipt");
581 let result = sign(&pt, &receipt, &signer).unwrap();
582 let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
583
584 assert_eq!(decoded.system, "system://stripe-webhook");
585 assert_eq!(decoded.kind, "confirmation");
586 }
587
588 #[test]
589 fn nonce_binding_survives_serialization() {
590 let signer = Ed25519Signer::generate("key_test").unwrap();
591
592 let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
595 let pt = payload_type("approval");
596 let signed = sign(&pt, &approval, &signer).unwrap();
597
598 let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
599 assert_eq!(decoded.nonce, "secure_nonce_xyz", "nonce must survive serialization");
600 }
601
602 #[test]
603 fn decision_statement_sign_verify() {
604 let signer = Ed25519Signer::generate("key_test").unwrap();
605 let verifier = Verifier::from_signer(&signer);
606
607 let mut stmt = DecisionStatement::new("agent://analyst");
608 stmt.model = Some("claude-opus-4".into());
609 stmt.tokens_in = Some(8432);
610 stmt.tokens_out = Some(1247);
611 stmt.summary = Some("Contract looks standard.".into());
612 stmt.confidence = Some(0.91);
613
614 let pt = payload_type("decision");
615 let result = sign(&pt, &stmt, &signer).unwrap();
616
617 assert!(result.artifact_id.starts_with("art_"));
618
619 let vr = verifier.verify(&result.envelope).unwrap();
620 assert_eq!(vr.artifact_id, result.artifact_id);
621
622 let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
624 assert_eq!(decoded.actor, "agent://analyst");
625 assert_eq!(decoded.model, Some("claude-opus-4".into()));
626 assert_eq!(decoded.tokens_in, Some(8432));
627 assert_eq!(decoded.tokens_out, Some(1247));
628 assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
629 assert_eq!(decoded.confidence, Some(0.91));
630 assert_eq!(decoded.type_, TYPE_DECISION);
631 }
632
633 #[test]
634 fn different_statement_types_different_ids() {
635 let signer = Ed25519Signer::generate("key_test").unwrap();
638
639 let action = ActionStatement::new("agent://test", "do.thing");
640 let approval = ApprovalStatement::new("human://test", "nonce_123");
641
642 let r_action = sign(&payload_type("action"), &action, &signer).unwrap();
643 let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
644
645 assert_ne!(r_action.artifact_id, r_approval.artifact_id);
646 }
647
648 #[test]
649 fn timestamp_format() {
650 let ts = unix_to_rfc3339(0);
651 assert_eq!(ts, "1970-01-01T00:00:00Z");
652
653 let ts2 = unix_to_rfc3339(1_000_000_000);
654 assert_eq!(ts2, "2001-09-09T01:46:40Z");
655 }
656}