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 <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-04-12 12:13:53 -04:00
parent 3d5e5485ec
commit e3fb2a9a58
2 changed files with 52 additions and 16 deletions

View file

@ -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 }

View file

@ -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,28 +56,64 @@ impl RegistryArtifact for CeremonyResolution {
}
fn canonical_bytes(&self) -> Vec<u8> {
// 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 {
@ -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]