From e3fb2a9a58fb4cfa4e65c73f2574c508d8c3c56ddaf6020f230a4aa7bc7d0131 Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Sun, 12 Apr 2026 12:13:53 -0400 Subject: [PATCH] refactor(ceremony-engine): use GovernanceEnvelope for merkle leaves PipelineMerge, SchematicPublish, and GitOpsSync ceremony merkle leaves are now the canonical_hash() of a GovernanceEnvelope, binding git ref + governance metadata into a single auditable 32-byte hash. Uses the resolution's resolved_at timestamp for deterministic envelope construction. Non-git ceremony types (MutationIntent, Custom) unchanged. Signed-off-by: Tyler King --- ceremony-engine/Cargo.toml | 1 + ceremony-engine/src/artifact.rs | 67 +++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/ceremony-engine/Cargo.toml b/ceremony-engine/Cargo.toml index fd1f081..618f908 100644 --- a/ceremony-engine/Cargo.toml +++ b/ceremony-engine/Cargo.toml @@ -10,6 +10,7 @@ description = "Governed state machine for multi-party approval workflows" # When ceremony-engine is published to crates.io, # this becomes a version dependency. accord-core = { path = "../../guildhouse/services/accord-core" } +governance-types = { path = "../../substrate/crates/governance-types" } registry-protocol = { path = "../../guildhouse/services/registry-protocol" } serde = { workspace = true } diff --git a/ceremony-engine/src/artifact.rs b/ceremony-engine/src/artifact.rs index bc7b1fb..a14792a 100644 --- a/ceremony-engine/src/artifact.rs +++ b/ceremony-engine/src/artifact.rs @@ -4,6 +4,7 @@ //! resolved ceremonies can be merkle-anchored in the notary chain, //! and [`MutationVerb`] on [`CeremonyVerb`] for SAT scope encoding. +use governance_types::GovernanceEnvelope; use registry_protocol::{RegistryArtifact, MutationVerb}; use serde::{Deserialize, Serialize}; @@ -55,29 +56,65 @@ impl RegistryArtifact for CeremonyResolution { } fn canonical_bytes(&self) -> 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(); + // For git-originated ceremonies, construct a GovernanceEnvelope + // and use its canonical_hash as the merkle leaf. This binds + // git ref + governance metadata into one auditable hash. + // Use resolved_at as a fixed timestamp so canonical_bytes is + // deterministic for the same resolution (not time-dependent). + let resolved_ns = self.resolved_at.timestamp_nanos_opt().unwrap_or(0) as u64; + match &self.subject { CeremonySubject::PipelineMerge { commit_hash, .. } => { - bytes.extend_from_slice(commit_hash.as_bytes()); + let mut envelope = GovernanceEnvelope::for_commit( + parse_sha1_hex(commit_hash), + None, + "", + [0; 32], + "", + ); + envelope.timestamp_ns = resolved_ns; + envelope.canonical_hash().to_vec() } CeremonySubject::SchematicPublish { tree_hash, .. } => { - bytes.extend_from_slice(tree_hash.as_bytes()); + let mut envelope = GovernanceEnvelope::for_blob( + parse_sha1_hex(tree_hash), + "", + [0; 32], + "", + ); + envelope.timestamp_ns = resolved_ns; + envelope.canonical_hash().to_vec() } CeremonySubject::GitOpsSync { target_revision, .. } => { - bytes.extend_from_slice(target_revision.as_bytes()); + let mut envelope = GovernanceEnvelope::for_commit( + parse_sha1_hex(target_revision), + None, + "", + [0; 32], + "", + ); + envelope.timestamp_ns = resolved_ns; + envelope.canonical_hash().to_vec() } // Non-git ceremonies: proof_hash alone is the canonical form. CeremonySubject::MutationIntent { .. } - | CeremonySubject::Custom { .. } => {} + | CeremonySubject::Custom { .. } => { + self.proof_hash.as_bytes().to_vec() + } } - bytes } } +/// Parse a hex SHA-1 string into [u8; 20]. Returns zeros on failure. +fn parse_sha1_hex(hex_str: &str) -> [u8; 20] { + let mut sha = [0u8; 20]; + if let Ok(bytes) = hex::decode(hex_str.trim()) { + let len = bytes.len().min(20); + sha[..len].copy_from_slice(&bytes[..len]); + } + sha +} + #[cfg(test)] mod tests { use super::*; @@ -144,14 +181,12 @@ mod tests { } #[test] - fn canonical_bytes_pipeline_merge_includes_commit_hash() { + fn canonical_bytes_pipeline_merge_is_envelope_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"); + // PipelineMerge: canonical_bytes is the 32-byte canonical_hash + // of a GovernanceEnvelope (not the old proof_hash || commit_hash). + assert_eq!(bytes.len(), 32); } #[test]