pub fn payload_type(suffix: &str) -> String {
format!("application/vnd.treeship.{}.v1+json", suffix)
}
pub const TYPE_ACTION: &str = "treeship/action/v1";
pub const TYPE_APPROVAL: &str = "treeship/approval/v1";
pub const TYPE_HANDOFF: &str = "treeship/handoff/v1";
pub const TYPE_ENDORSEMENT: &str = "treeship/endorsement/v1";
pub const TYPE_RECEIPT: &str = "treeship/receipt/v1";
pub const TYPE_BUNDLE: &str = "treeship/bundle/v1";
pub const TYPE_DECISION: &str = "treeship/decision/v1";
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubjectRef {
#[serde(skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
#[serde(rename = "artifactId", skip_serializing_if = "Option::is_none")]
pub artifact_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ApprovalScope {
#[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
pub max_actions: Option<u32>,
#[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")]
pub valid_until: Option<String>,
#[serde(rename = "allowedActions", skip_serializing_if = "Vec::is_empty", default)]
pub allowed_actions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionStatement {
#[serde(rename = "type")]
pub type_: String,
pub timestamp: String,
pub actor: String,
pub action: String,
#[serde(default, skip_serializing_if = "is_empty_subject")]
pub subject: SubjectRef,
#[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(rename = "approvalNonce", skip_serializing_if = "Option::is_none")]
pub approval_nonce: Option<String>,
#[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
pub policy_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalStatement {
#[serde(rename = "type")]
pub type_: String,
pub timestamp: String,
pub approver: String,
#[serde(default, skip_serializing_if = "is_empty_subject")]
pub subject: SubjectRef,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
pub delegatable: bool,
pub nonce: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<ApprovalScope>,
#[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
pub policy_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandoffStatement {
#[serde(rename = "type")]
pub type_: String,
pub timestamp: String,
pub from: String,
pub to: String,
pub artifacts: Vec<String>,
#[serde(rename = "approvalIds", default, skip_serializing_if = "Vec::is_empty")]
pub approval_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub obligations: Vec<String>,
pub delegatable: bool,
#[serde(rename = "taskRef", skip_serializing_if = "Option::is_none")]
pub task_ref: Option<String>,
#[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
pub policy_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndorsementStatement {
#[serde(rename = "type")]
pub type_: String,
pub timestamp: String,
pub endorser: String,
pub subject: SubjectRef,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
#[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
pub policy_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
impl EndorsementStatement {
pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
Self {
type_: TYPE_ENDORSEMENT.into(),
timestamp: now_rfc3339(),
endorser: endorser.into(),
subject: SubjectRef::default(),
kind: kind.into(),
rationale: None,
expires_at: None,
policy_ref: None,
meta: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiptStatement {
#[serde(rename = "type")]
pub type_: String,
pub timestamp: String,
pub system: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<SubjectRef>,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<serde_json::Value>,
#[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
pub payload_digest: Option<String>,
#[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
pub policy_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactRef {
pub id: String,
pub digest: String,
#[serde(rename = "type")]
pub type_: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleStatement {
#[serde(rename = "type")]
pub type_: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub artifacts: Vec<ArtifactRef>,
#[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
pub policy_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionStatement {
#[serde(rename = "type")]
pub type_: String,
pub timestamp: String,
pub actor: String,
#[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
pub model_version: Option<String>,
#[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
pub tokens_in: Option<u64>,
#[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
pub tokens_out: Option<u64>,
#[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
pub prompt_digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alternatives: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
fn is_empty_subject(s: &SubjectRef) -> bool {
s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
}
impl ActionStatement {
pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
Self {
type_: TYPE_ACTION.into(),
timestamp: now_rfc3339(),
actor: actor.into(),
action: action.into(),
subject: SubjectRef::default(),
parent_id: None,
approval_nonce: None,
policy_ref: None,
meta: None,
}
}
}
impl ApprovalStatement {
pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
Self {
type_: TYPE_APPROVAL.into(),
timestamp: now_rfc3339(),
approver: approver.into(),
subject: SubjectRef::default(),
description: None,
expires_at: None,
delegatable: false,
nonce: nonce.into(),
scope: None,
policy_ref: None,
meta: None,
}
}
}
impl HandoffStatement {
pub fn new(
from: impl Into<String>,
to: impl Into<String>,
artifacts: Vec<String>,
) -> Self {
Self {
type_: TYPE_HANDOFF.into(),
timestamp: now_rfc3339(),
from: from.into(),
to: to.into(),
artifacts,
approval_ids: vec![],
obligations: vec![],
delegatable: false,
task_ref: None,
policy_ref: None,
meta: None,
}
}
}
impl ReceiptStatement {
pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
Self {
type_: TYPE_RECEIPT.into(),
timestamp: now_rfc3339(),
system: system.into(),
subject: None,
kind: kind.into(),
payload: None,
payload_digest: None,
policy_ref: None,
meta: None,
}
}
}
impl DecisionStatement {
pub fn new(actor: impl Into<String>) -> Self {
Self {
type_: TYPE_DECISION.into(),
timestamp: now_rfc3339(),
actor: actor.into(),
parent_id: None,
model: None,
model_version: None,
tokens_in: None,
tokens_out: None,
prompt_digest: None,
summary: None,
confidence: None,
alternatives: None,
meta: None,
}
}
}
fn now_rfc3339() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
unix_to_rfc3339(secs)
}
pub fn unix_to_rfc3339(secs: u64) -> String {
let s = secs;
let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
}
fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
let sec = s % 60;
let mins = s / 60;
let min = mins % 60;
let hrs = mins / 60;
let hour = hrs % 24;
let days = hrs / 24;
let (y, m, d) = days_to_ymd(days);
(y, m, d, hour, min, sec)
}
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
let mut d = days;
let mut year = 1970u64;
loop {
let dy = if is_leap(year) { 366 } else { 365 };
if d < dy { break; }
d -= dy;
year += 1;
}
let months = if is_leap(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u64;
for dm in months {
if d < dm { break; }
d -= dm;
month += 1;
}
(year, month, d + 1)
}
fn is_leap(y: u64) -> bool {
(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::{sign, Ed25519Signer, Verifier};
#[test]
fn payload_type_format() {
assert_eq!(
payload_type("action"),
"application/vnd.treeship.action.v1+json"
);
assert_eq!(
payload_type("approval"),
"application/vnd.treeship.approval.v1+json"
);
}
#[test]
fn action_statement_sign_verify() {
let signer = Ed25519Signer::generate("key_test").unwrap();
let verifier = Verifier::from_signer(&signer);
let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
let pt = payload_type("action");
let result = sign(&pt, &stmt, &signer).unwrap();
assert!(result.artifact_id.starts_with("art_"));
let vr = verifier.verify(&result.envelope).unwrap();
assert_eq!(vr.artifact_id, result.artifact_id);
let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
assert_eq!(decoded.actor, "agent://researcher");
assert_eq!(decoded.action, "tool.call");
assert_eq!(decoded.type_, TYPE_ACTION);
}
#[test]
fn approval_statement_with_nonce() {
let signer = Ed25519Signer::generate("key_human").unwrap();
let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
approval.description = Some("approve laptop purchase < $1500".into());
approval.scope = Some(ApprovalScope {
max_actions: Some(1),
allowed_actions: vec!["stripe.payment_intent.create".into()],
..Default::default()
});
let pt = payload_type("approval");
let result = sign(&pt, &approval, &signer).unwrap();
assert!(result.artifact_id.starts_with("art_"));
let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
assert_eq!(decoded.nonce, "nonce_abc123");
assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
}
#[test]
fn handoff_statement() {
let signer = Ed25519Signer::generate("key_agent").unwrap();
let handoff = HandoffStatement::new(
"agent://researcher",
"agent://checkout",
vec!["art_aabbccdd11223344aabbccdd11223344".into()],
);
let pt = payload_type("handoff");
let result = sign(&pt, &handoff, &signer).unwrap();
let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
assert_eq!(decoded.from, "agent://researcher");
assert_eq!(decoded.to, "agent://checkout");
assert_eq!(decoded.artifacts.len(), 1);
}
#[test]
fn receipt_statement() {
let signer = Ed25519Signer::generate("key_system").unwrap();
let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
receipt.payload = Some(serde_json::json!({
"eventId": "evt_abc123",
"status": "succeeded"
}));
let pt = payload_type("receipt");
let result = sign(&pt, &receipt, &signer).unwrap();
let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
assert_eq!(decoded.system, "system://stripe-webhook");
assert_eq!(decoded.kind, "confirmation");
}
#[test]
fn nonce_binding_survives_serialization() {
let signer = Ed25519Signer::generate("key_test").unwrap();
let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
let pt = payload_type("approval");
let signed = sign(&pt, &approval, &signer).unwrap();
let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
assert_eq!(decoded.nonce, "secure_nonce_xyz", "nonce must survive serialization");
}
#[test]
fn decision_statement_sign_verify() {
let signer = Ed25519Signer::generate("key_test").unwrap();
let verifier = Verifier::from_signer(&signer);
let mut stmt = DecisionStatement::new("agent://analyst");
stmt.model = Some("claude-opus-4".into());
stmt.tokens_in = Some(8432);
stmt.tokens_out = Some(1247);
stmt.summary = Some("Contract looks standard.".into());
stmt.confidence = Some(0.91);
let pt = payload_type("decision");
let result = sign(&pt, &stmt, &signer).unwrap();
assert!(result.artifact_id.starts_with("art_"));
let vr = verifier.verify(&result.envelope).unwrap();
assert_eq!(vr.artifact_id, result.artifact_id);
let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
assert_eq!(decoded.actor, "agent://analyst");
assert_eq!(decoded.model, Some("claude-opus-4".into()));
assert_eq!(decoded.tokens_in, Some(8432));
assert_eq!(decoded.tokens_out, Some(1247));
assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
assert_eq!(decoded.confidence, Some(0.91));
assert_eq!(decoded.type_, TYPE_DECISION);
}
#[test]
fn different_statement_types_different_ids() {
let signer = Ed25519Signer::generate("key_test").unwrap();
let action = ActionStatement::new("agent://test", "do.thing");
let approval = ApprovalStatement::new("human://test", "nonce_123");
let r_action = sign(&payload_type("action"), &action, &signer).unwrap();
let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
assert_ne!(r_action.artifact_id, r_approval.artifact_id);
}
#[test]
fn timestamp_format() {
let ts = unix_to_rfc3339(0);
assert_eq!(ts, "1970-01-01T00:00:00Z");
let ts2 = unix_to_rfc3339(1_000_000_000);
assert_eq!(ts2, "2001-09-09T01:46:40Z");
}
}