diff --git a/ceremony-engine/src/artifact.rs b/ceremony-engine/src/artifact.rs index f5a8b35..bc7b1fb 100644 --- a/ceremony-engine/src/artifact.rs +++ b/ceremony-engine/src/artifact.rs @@ -7,6 +7,7 @@ use registry_protocol::{RegistryArtifact, MutationVerb}; use serde::{Deserialize, Serialize}; +use crate::request::CeremonySubject; use crate::resolution::CeremonyResolution; /// Operations on governance ceremonies. @@ -54,11 +55,26 @@ impl RegistryArtifact for CeremonyResolution { } fn canonical_bytes(&self) -> Vec { - // Re-derive the canonical form from the resolution's fields. - // The proof_hash was computed from JCS of the canonical form, - // so we can use it as the canonical bytes for merkle anchoring. - // This avoids re-canonicalizing and guarantees consistency. - self.proof_hash.as_bytes().to_vec() + // For git-originated ceremonies (PipelineMerge, SchematicPublish, + // GitOpsSync), bind the git ref into the canonical form so the + // Quartermaster merkle leaf transitively includes git's hash. + // Format: proof_hash bytes || git ref bytes. + let mut bytes = self.proof_hash.as_bytes().to_vec(); + match &self.subject { + CeremonySubject::PipelineMerge { commit_hash, .. } => { + bytes.extend_from_slice(commit_hash.as_bytes()); + } + CeremonySubject::SchematicPublish { tree_hash, .. } => { + bytes.extend_from_slice(tree_hash.as_bytes()); + } + CeremonySubject::GitOpsSync { target_revision, .. } => { + bytes.extend_from_slice(target_revision.as_bytes()); + } + // Non-git ceremonies: proof_hash alone is the canonical form. + CeremonySubject::MutationIntent { .. } + | CeremonySubject::Custom { .. } => {} + } + bytes } } @@ -127,6 +143,41 @@ mod tests { assert_eq!(b1, b2); } + #[test] + fn canonical_bytes_pipeline_merge_includes_commit_hash() { + let res = sample_resolution(); // PipelineMerge with commit_hash "abc" + let bytes = res.canonical_bytes(); + let proof_bytes = res.proof_hash.as_bytes(); + // Must contain proof_hash + commit_hash + assert!(bytes.len() > proof_bytes.len()); + assert_eq!(&bytes[..proof_bytes.len()], proof_bytes); + assert_eq!(&bytes[proof_bytes.len()..], b"abc"); + } + + #[test] + fn canonical_bytes_non_git_ceremony_excludes_git_ref() { + let approvals = vec![CeremonyApproval { + approver_identity: "alice@ops".to_string(), + approver_role: "msp-ops".to_string(), + decision: ApprovalDecision::Approve, + comment: None, + decided_at: Utc::now(), + }]; + let res = CeremonyResolution::from_ceremony( + "cer-custom", + GovernanceCeremonyStatus::Approved, + &CeremonySubject::Custom { + subject_type: "test".to_string(), + reference_id: "ref-1".to_string(), + description: "test ceremony".to_string(), + }, + &approvals, + ); + let bytes = res.canonical_bytes(); + // Non-git: canonical_bytes == proof_hash bytes only + assert_eq!(bytes, res.proof_hash.as_bytes()); + } + #[test] fn verb_serialization() { let verb = CeremonyVerb::Approve;