//! Ceremony resolution proof. //! //! When a governance ceremony resolves (approved or denied), a //! [`CeremonyResolution`] is produced as an immutable record suitable //! for merkle anchoring. This is the artifact that gets notarized. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::request::{ CeremonyApproval, CeremonySubject, GovernanceCeremonyStatus, }; /// An immutable resolution record for a governance ceremony. /// /// Produced when the ceremony exits the `Pending` state. Contains /// enough information to audit who approved/denied and what subject /// was at stake, without reference to mutable ceremony state. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CeremonyResolution { pub ceremony_id: String, pub status: GovernanceCeremonyStatus, pub subject: CeremonySubject, pub approvals: Vec, pub resolved_at: DateTime, pub proof_hash: String, } /// Canonical form for hashing (alphabetical field order via JCS). #[derive(Debug, Serialize, Deserialize)] struct CanonicalResolution { pub approvals: Vec, pub ceremony_id: String, pub resolved_at: DateTime, pub status: GovernanceCeremonyStatus, pub subject: CeremonySubject, } #[derive(Debug, Serialize, Deserialize)] struct CanonicalApproval { pub approver_identity: String, pub approver_role: String, pub decided_at: DateTime, pub decision: String, } impl CeremonyResolution { /// Build a resolution from a resolved ceremony. pub fn from_ceremony( ceremony_id: &str, status: GovernanceCeremonyStatus, subject: &CeremonySubject, approvals: &[CeremonyApproval], ) -> Self { let now = Utc::now(); let canonical = CanonicalResolution { approvals: approvals .iter() .map(|a| CanonicalApproval { approver_identity: a.approver_identity.clone(), approver_role: a.approver_role.clone(), decided_at: a.decided_at, decision: format!("{:?}", a.decision).to_lowercase(), }) .collect(), ceremony_id: ceremony_id.to_string(), resolved_at: now, status, subject: subject.clone(), }; let value = serde_json::to_value(&canonical) .expect("CanonicalResolution is always JSON-serializable"); let mut buf = Vec::new(); serde_json_canonicalizer::to_writer(&value, &mut buf) .expect("JCS canonicalization should not fail"); let hash = Sha256::digest(&buf); CeremonyResolution { ceremony_id: ceremony_id.to_string(), status, subject: subject.clone(), approvals: approvals.to_vec(), resolved_at: now, proof_hash: hex::encode(hash), } } /// Verify the proof hash matches the resolution content. pub fn verify_proof(&self) -> bool { let canonical = CanonicalResolution { approvals: self .approvals .iter() .map(|a| CanonicalApproval { approver_identity: a.approver_identity.clone(), approver_role: a.approver_role.clone(), decided_at: a.decided_at, decision: format!("{:?}", a.decision).to_lowercase(), }) .collect(), ceremony_id: self.ceremony_id.clone(), resolved_at: self.resolved_at, status: self.status, subject: self.subject.clone(), }; let value = serde_json::to_value(&canonical).unwrap_or_default(); let mut buf = Vec::new(); if serde_json_canonicalizer::to_writer(&value, &mut buf).is_err() { return false; } let hash = Sha256::digest(&buf); hex::encode(hash) == self.proof_hash } } #[cfg(test)] mod tests { use super::*; use crate::request::{ApprovalDecision, CeremonyApproval, CeremonySubject}; fn sample_approvals() -> Vec { vec![CeremonyApproval { approver_identity: "alice@ops".to_string(), approver_role: "msp-ops".to_string(), decision: ApprovalDecision::Approve, comment: Some("LGTM".to_string()), decided_at: chrono::DateTime::parse_from_rfc3339("2025-06-01T12:00:00Z") .unwrap() .with_timezone(&Utc), }] } fn sample_subject() -> CeremonySubject { CeremonySubject::PipelineMerge { run_id: "run-1".to_string(), pipeline_name: "deploy".to_string(), branch: "main".to_string(), commit_hash: "abc123".to_string(), remote_name: "origin".to_string(), } } #[test] fn resolution_has_proof_hash() { let res = CeremonyResolution::from_ceremony( "cer-001", GovernanceCeremonyStatus::Approved, &sample_subject(), &sample_approvals(), ); assert!(!res.proof_hash.is_empty()); assert_eq!(res.proof_hash.len(), 64); // SHA-256 hex } #[test] fn proof_hash_verifies() { let res = CeremonyResolution::from_ceremony( "cer-001", GovernanceCeremonyStatus::Approved, &sample_subject(), &sample_approvals(), ); assert!(res.verify_proof()); } #[test] fn tampered_resolution_fails_verification() { let mut res = CeremonyResolution::from_ceremony( "cer-001", GovernanceCeremonyStatus::Approved, &sample_subject(), &sample_approvals(), ); res.ceremony_id = "cer-999".to_string(); // tamper assert!(!res.verify_proof()); } #[test] fn denied_resolution() { let denials = vec![CeremonyApproval { approver_identity: "bob@ops".to_string(), approver_role: "msp-ops".to_string(), decision: ApprovalDecision::Deny, comment: Some("unacceptable risk".to_string()), decided_at: Utc::now(), }]; let res = CeremonyResolution::from_ceremony( "cer-002", GovernanceCeremonyStatus::Denied, &sample_subject(), &denials, ); assert_eq!(res.status, GovernanceCeremonyStatus::Denied); assert!(res.verify_proof()); } #[test] fn serialization_round_trip() { let res = CeremonyResolution::from_ceremony( "cer-003", GovernanceCeremonyStatus::Approved, &sample_subject(), &sample_approvals(), ); let json = serde_json::to_string(&res).unwrap(); let parsed: CeremonyResolution = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.ceremony_id, res.ceremony_id); assert_eq!(parsed.proof_hash, res.proof_hash); } }