use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::attestation::ProcessAttestation;
pub const RECEIPT_VERSION: &str = "tatara-receipt/v1";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_lisp::DeriveClosedSet)]
#[closed_set(via = "as_str", display, generate_unknown)]
pub enum ReceiptKind {
ClosedLoopAuth,
DbMigration,
TestSuite,
NixBuild,
}
impl ReceiptKind {
pub const ALL: [Self; 4] = [
Self::ClosedLoopAuth,
Self::DbMigration,
Self::TestSuite,
Self::NixBuild,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ClosedLoopAuth => "closed-loop-auth",
Self::DbMigration => "db-migration",
Self::TestSuite => "test-suite",
Self::NixBuild => "nix-build",
}
}
}
impl From<ReceiptKind> for String {
fn from(k: ReceiptKind) -> Self {
k.as_str().to_owned()
}
}
impl From<ReceiptKind> for &'static str {
fn from(k: ReceiptKind) -> Self {
k.as_str()
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct ReceiptEnvelope {
pub version: String,
pub kind: String,
pub composed_root: String,
pub intent_hash: String,
pub artifact_hash: String,
pub control_hash: String,
pub generated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub process_ref: Option<String>,
#[serde(default, skip_serializing_if = "is_null")]
pub evidence: serde_json::Value,
}
fn is_null(v: &serde_json::Value) -> bool {
v.is_null()
}
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
pub enum ReceiptError {
#[error("invalid JSON: {0}")]
InvalidJson(String),
#[error("invalid YAML: {0}")]
InvalidYaml(String),
#[error("version != {RECEIPT_VERSION} (got {0:?})")]
WrongVersion(String),
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("kind is empty")]
EmptyKind,
#[error("composed_root mismatch (got {got}, want {want})")]
RootMismatch { got: String, want: String },
}
impl ReceiptEnvelope {
pub fn build(
kind: impl Into<String>,
intent_hash: impl Into<String>,
artifact_hash: impl Into<String>,
control_hash: impl Into<String>,
previous_root: Option<&str>,
) -> Self {
let intent_hash = intent_hash.into();
let artifact_hash = artifact_hash.into();
let control_hash = control_hash.into();
let composed_root = compose_root(
&artifact_hash,
if control_hash.is_empty() {
None
} else {
Some(control_hash.as_str())
},
&intent_hash,
previous_root,
);
Self {
version: RECEIPT_VERSION.into(),
kind: kind.into(),
composed_root,
intent_hash,
artifact_hash,
control_hash,
generated_at: Utc::now(),
process_ref: None,
evidence: serde_json::Value::Null,
}
}
pub fn parse_json(payload: &str) -> Result<Self, ReceiptError> {
let env: Self =
serde_json::from_str(payload).map_err(|e| ReceiptError::InvalidJson(e.to_string()))?;
env.verify_shape()?;
Ok(env)
}
pub fn parse_yaml(payload: &str) -> Result<Self, ReceiptError> {
let env: Self =
serde_yaml::from_str(payload).map_err(|e| ReceiptError::InvalidYaml(e.to_string()))?;
env.verify_shape()?;
Ok(env)
}
pub fn parse_either(payload: &str) -> Result<Self, ReceiptError> {
match Self::parse_json(payload) {
Ok(env) => Ok(env),
Err(_) => Self::parse_yaml(payload),
}
}
pub fn verify_shape(&self) -> Result<(), ReceiptError> {
if self.version != RECEIPT_VERSION {
return Err(ReceiptError::WrongVersion(self.version.clone()));
}
if self.kind.is_empty() {
return Err(ReceiptError::EmptyKind);
}
if self.composed_root.is_empty() {
return Err(ReceiptError::MissingField("composed_root"));
}
if self.intent_hash.is_empty() {
return Err(ReceiptError::MissingField("intent_hash"));
}
if self.artifact_hash.is_empty() {
return Err(ReceiptError::MissingField("artifact_hash"));
}
Ok(())
}
pub fn verify_root(&self, expected_previous_root: Option<&str>) -> bool {
let want = compose_root(
&self.artifact_hash,
if self.control_hash.is_empty() {
None
} else {
Some(self.control_hash.as_str())
},
&self.intent_hash,
expected_previous_root,
);
constant_time_eq(want.as_bytes(), self.composed_root.as_bytes())
}
pub fn expect_root(&self, expected: Option<&str>) -> Result<&str, ReceiptError> {
if let Some(want) = expected {
if want != self.composed_root {
return Err(ReceiptError::RootMismatch {
got: self.composed_root.clone(),
want: want.to_string(),
});
}
}
Ok(&self.composed_root)
}
#[must_use]
pub fn known_kind(&self) -> Option<ReceiptKind> {
self.kind.parse().ok()
}
pub fn to_attestation(
&self,
generation: u64,
previous_root: Option<&str>,
) -> ProcessAttestation {
ProcessAttestation::compose(
self.artifact_hash.clone(),
if self.control_hash.is_empty() {
None
} else {
Some(self.control_hash.clone())
},
self.intent_hash.clone(),
previous_root.map(String::from),
generation,
)
}
}
const DOMAIN_TAG: &[u8] = b"tatara-process/v1alpha1\n";
fn compose_root(
artifact: &str,
control: Option<&str>,
intent: &str,
previous: Option<&str>,
) -> String {
let mut h = blake3::Hasher::new();
h.update(DOMAIN_TAG);
h.update(artifact.as_bytes());
h.update(b"\n");
h.update(control.unwrap_or("").as_bytes());
h.update(b"\n");
h.update(intent.as_bytes());
h.update(b"\n");
h.update(previous.unwrap_or("").as_bytes());
hex::encode(h.finalize().as_bytes())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut acc: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
acc |= x ^ y;
}
acc == 0
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_payload() -> &'static str {
r#"{
"version": "tatara-receipt/v1",
"kind": "closed-loop-auth",
"composed_root": "RECOMPUTE",
"intent_hash": "aaaa",
"artifact_hash": "bbbb",
"control_hash": "cccc",
"generated_at": "2026-05-19T12:00:00Z"
}"#
}
fn canonical_payload_json() -> String {
let root = compose_root("bbbb", Some("cccc"), "aaaa", None);
sample_payload().replace("RECOMPUTE", &root)
}
#[test]
fn build_produces_valid_envelope() {
let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
assert_eq!(r.version, RECEIPT_VERSION);
assert_eq!(r.kind, "test-suite");
assert!(r.verify_shape().is_ok());
assert!(r.verify_root(None));
}
#[test]
fn build_empty_control_omits_from_root() {
let with_empty = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
let with_explicit_none = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
assert_eq!(with_empty.composed_root, with_explicit_none.composed_root);
let with_control = ReceiptEnvelope::build("nix-build", "i", "a", "c", None);
assert_ne!(with_empty.composed_root, with_control.composed_root);
}
#[test]
fn parse_json_round_trip() {
let r = ReceiptEnvelope::parse_json(&canonical_payload_json()).expect("parse");
assert_eq!(r.kind, "closed-loop-auth");
assert!(r.verify_root(None));
}
#[test]
fn parse_yaml_round_trip() {
let yaml = r#"
version: tatara-receipt/v1
kind: db-migration
composed_root: ROOT
intent_hash: aaaa
artifact_hash: bbbb
control_hash: cccc
generated_at: 2026-05-19T12:00:00Z
"#
.replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
let r = ReceiptEnvelope::parse_yaml(&yaml).expect("yaml parse");
assert_eq!(r.kind, "db-migration");
assert!(r.verify_root(None));
}
#[test]
fn parse_either_falls_back_to_yaml() {
let yaml = r#"
version: tatara-receipt/v1
kind: test-suite
composed_root: ROOT
intent_hash: aaaa
artifact_hash: bbbb
control_hash: cccc
generated_at: 2026-05-19T12:00:00Z
"#
.replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
assert!(ReceiptEnvelope::parse_either(&yaml).is_ok());
}
#[test]
fn wrong_version_rejected() {
let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
env["version"] = "tatara-receipt/v2".into();
let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
assert!(matches!(err, ReceiptError::WrongVersion(ref s) if s == "tatara-receipt/v2"));
}
#[test]
fn missing_field_rejected() {
let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
env.as_object_mut().unwrap().remove("intent_hash");
let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
assert!(matches!(err, ReceiptError::InvalidJson(_)));
}
#[test]
fn unknown_field_rejected() {
let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
env["forged_extra"] = "should-fail".into();
let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
assert!(matches!(err, ReceiptError::InvalidJson(_)));
}
#[test]
fn empty_kind_rejected_in_verify_shape() {
let mut r = ReceiptEnvelope::build("k", "i", "a", "c", None);
r.kind = String::new();
assert!(matches!(r.verify_shape(), Err(ReceiptError::EmptyKind)));
}
#[test]
fn expect_root_matches_or_mismatches() {
let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
let root = r.composed_root.clone();
assert!(r.expect_root(Some(&root)).is_ok());
let err = r.expect_root(Some("nope")).unwrap_err();
assert!(matches!(err, ReceiptError::RootMismatch { .. }));
assert!(r.expect_root(None).is_ok());
}
#[test]
fn lower_to_attestation_chains_pillars() {
let r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
let a = r.to_attestation(0, None);
assert_eq!(a.intent_hash, "i");
assert_eq!(a.artifact_hash, "a");
assert_eq!(a.control_hash.as_deref(), Some("c"));
assert_eq!(a.composed_root, r.composed_root);
assert!(a.verify());
let next = r.to_attestation(1, Some(&a.composed_root));
assert_eq!(next.generation, 1);
assert_eq!(
next.previous_root.as_deref(),
Some(a.composed_root.as_str())
);
assert_ne!(next.composed_root, a.composed_root);
}
#[test]
fn verify_root_detects_tamper() {
let mut r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
assert!(r.verify_root(None));
r.intent_hash = "tampered".into();
assert!(!r.verify_root(None));
}
#[test]
fn process_ref_optional_and_round_trips() {
let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
r.process_ref = Some("akeyless-test/ephemeral".into());
let s = serde_json::to_string(&r).unwrap();
let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
assert_eq!(back.process_ref.as_deref(), Some("akeyless-test/ephemeral"));
}
#[test]
fn evidence_round_trips() {
let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
r.evidence = serde_json::json!({ "passed": 12, "failed": 0, "duration_ms": 4200 });
let s = serde_json::to_string(&r).unwrap();
let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
assert_eq!(back.evidence["passed"], 12);
}
#[test]
fn receipt_kind_is_well_formed_closed_set() {
tatara_lisp::assert_closed_set_well_formed::<ReceiptKind>();
}
#[test]
fn receipt_kind_canonical_names_pinned() {
assert_eq!(ReceiptKind::ClosedLoopAuth.as_str(), "closed-loop-auth");
assert_eq!(ReceiptKind::DbMigration.as_str(), "db-migration");
assert_eq!(ReceiptKind::TestSuite.as_str(), "test-suite");
assert_eq!(ReceiptKind::NixBuild.as_str(), "nix-build");
}
#[test]
fn receipt_kind_from_str_rejects_open_kinds() {
for bad in ["closed_loop_auth", "ClosedLoopAuth", "operator-custom-kind"] {
let err = bad.parse::<ReceiptKind>().unwrap_err();
assert_eq!(err, UnknownReceiptKind(bad.to_string()));
}
}
#[test]
fn receipt_kind_display_delegates_to_as_str() {
for k in ReceiptKind::ALL {
assert_eq!(format!("{k}"), k.as_str());
}
}
#[test]
fn receipt_kind_into_string_matches_as_str() {
for k in ReceiptKind::ALL {
let s: String = k.into();
assert_eq!(s, k.as_str());
}
}
#[test]
fn build_accepts_typed_receipt_kind() {
for k in ReceiptKind::ALL {
let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
assert_eq!(env.kind, k.as_str());
assert!(env.verify_shape().is_ok());
assert!(env.verify_root(None));
}
}
#[test]
fn known_kind_decodes_built_receipts() {
for k in ReceiptKind::ALL {
let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
assert_eq!(env.known_kind(), Some(k));
}
}
#[test]
fn known_kind_returns_none_for_open_kinds() {
let env = ReceiptEnvelope::build("operator-custom-kind", "i", "a", "c", None);
assert_eq!(env.known_kind(), None);
assert!(
env.verify_shape().is_ok(),
"open kind must remain a valid receipt"
);
}
}