refactor(ceremony-engine): bind git commit hash into canonical_bytes

PipelineMerge ceremony resolutions now include the git commit
SHA in their canonical form, binding the Quartermaster merkle
leaf to git's merkle tree. SchematicPublish includes tree_hash,
GitOpsSync includes target_revision.

Non-git ceremony types (MutationIntent, Custom) unchanged —
canonical_bytes still returns proof_hash alone.

See cid-reconciliation-audit.md Site 8.

Signed-off-by: Tyler King <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-04-12 07:54:01 -04:00
parent b1865a0627
commit 3d5e5485ec

View file

@ -7,6 +7,7 @@
use registry_protocol::{RegistryArtifact, MutationVerb}; use registry_protocol::{RegistryArtifact, MutationVerb};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::request::CeremonySubject;
use crate::resolution::CeremonyResolution; use crate::resolution::CeremonyResolution;
/// Operations on governance ceremonies. /// Operations on governance ceremonies.
@ -54,11 +55,26 @@ impl RegistryArtifact for CeremonyResolution {
} }
fn canonical_bytes(&self) -> Vec<u8> { fn canonical_bytes(&self) -> Vec<u8> {
// Re-derive the canonical form from the resolution's fields. // For git-originated ceremonies (PipelineMerge, SchematicPublish,
// The proof_hash was computed from JCS of the canonical form, // GitOpsSync), bind the git ref into the canonical form so the
// so we can use it as the canonical bytes for merkle anchoring. // Quartermaster merkle leaf transitively includes git's hash.
// This avoids re-canonicalizing and guarantees consistency. // Format: proof_hash bytes || git ref bytes.
self.proof_hash.as_bytes().to_vec() 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); 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] #[test]
fn verb_serialization() { fn verb_serialization() {
let verb = CeremonyVerb::Approve; let verb = CeremonyVerb::Approve;