bascule-workspace/ceremony-engine/src/resolution.rs
Tyler King b1865a0627 initial: bascule v0.1.0
Bascule shell runtime workspace — governed shell access layer
for Substrate/Guildhouse FFC deployments.

Crates:
- bascule-agent: node agent with SSH server + command filtering
- bascule-core: audit, grant engine, ceremony types, session
- bascule-filter-core: log line filtering (stdio protocol)
- bascule-gateway: OIDC auth, session management, SAT validation
- bascule-node-agent: k8s DaemonSet agent (pod watcher, BPF manager)
- bascule-proto: protobuf definitions
- bascule-shell: governed SSH shell (commands, elevation, REPL)
- bascule-tail: chronicle log tail + fanout
- ceremony-engine: ceremony lifecycle (6 types + request/resolution)

172 tests passing.
Implements SBS-SPEC-0001 shell model.
Reference impl for SPEC-SHELLOPS-0001 Layer 1 (root shell).
2026-03-18 16:40:48 -04:00

214 lines
7 KiB
Rust

//! 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<CeremonyApproval>,
pub resolved_at: DateTime<Utc>,
pub proof_hash: String,
}
/// Canonical form for hashing (alphabetical field order via JCS).
#[derive(Debug, Serialize, Deserialize)]
struct CanonicalResolution {
pub approvals: Vec<CanonicalApproval>,
pub ceremony_id: String,
pub resolved_at: DateTime<Utc>,
pub status: GovernanceCeremonyStatus,
pub subject: CeremonySubject,
}
#[derive(Debug, Serialize, Deserialize)]
struct CanonicalApproval {
pub approver_identity: String,
pub approver_role: String,
pub decided_at: DateTime<Utc>,
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<CeremonyApproval> {
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);
}
}