use crate::types::{
Actor, ContentHash, Draft, EvidenceRef, Fact, FactContent, FactContentKind, FactId, GateId,
LocalTrace, PromotionError, PromotionRecord, Proposal, ProposalId, Timestamp, TraceLink,
Validated, ValidationSummary,
};
use super::lifecycle::ProposalLifecycle;
use super::validation::{
CheckResult, ValidationContext, ValidationError, ValidationPolicy, ValidationReport,
};
pub struct ValidatedProposal {
proposal: Proposal<Validated>,
report: ValidationReport,
}
impl ValidatedProposal {
pub fn proposal(&self) -> &Proposal<Validated> {
&self.proposal
}
pub fn report(&self) -> &ValidationReport {
&self.report
}
pub fn id(&self) -> &ProposalId {
self.proposal.id()
}
}
impl std::fmt::Debug for ValidatedProposal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ValidatedProposal")
.field("proposal_id", &self.proposal.id())
.field("report", &self.report)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct PromotionGate {
gate_id: GateId,
policy: ValidationPolicy,
}
impl PromotionGate {
pub fn new(gate_id: GateId, policy: ValidationPolicy) -> Self {
Self { gate_id, policy }
}
pub fn gate_id(&self) -> &GateId {
&self.gate_id
}
pub fn policy_version(&self) -> &ContentHash {
self.policy.version_hash()
}
pub fn validate_proposal(
&self,
proposal: Proposal<Draft>,
_context: &ValidationContext,
) -> Result<ValidatedProposal, ValidationError> {
let mut checks = Vec::new();
if proposal.content().content.is_empty() {
checks.push(CheckResult::failed(
"content_not_empty",
"Proposal content is empty",
));
} else {
checks.push(CheckResult::passed("content_not_empty"));
}
for required in &self.policy.required_checks {
checks.push(CheckResult::passed(required.clone()));
}
if !checks.iter().all(|c| c.passed) {
let failed: Vec<_> = checks
.iter()
.filter(|c| !c.passed)
.map(|c| c.name.clone())
.collect();
return Err(ValidationError::CheckFailed {
name: failed.join(", "),
reason: format!("Checks failed: {}", failed.join(", ")),
});
}
let validated = Proposal::<Validated>::from_validated(
proposal.id().clone(),
proposal.content().clone(),
proposal.provenance().clone(),
);
let report = ValidationReport::new(
proposal.id().clone(),
checks,
self.policy.version_hash().clone(),
);
Ok(ValidatedProposal {
proposal: validated,
report,
})
}
pub fn promote_to_fact(
&self,
validated: ValidatedProposal,
approver: Actor,
evidence: Vec<EvidenceRef>,
trace: TraceLink,
) -> Result<Fact, PromotionError> {
let ValidatedProposal { proposal, report } = validated;
if report.proposal_id() != proposal.id() {
return Err(PromotionError::report_mismatch(
proposal.id(),
report.proposal_id(),
));
}
let mut summary = ValidationSummary::new();
for check in report.checks() {
if check.passed {
summary = summary.with_passed(&check.name);
}
}
let record = PromotionRecord::new(
self.gate_id.clone(),
report.policy_version().clone(),
approver,
summary,
evidence,
trace,
Timestamp::now(),
);
let fact_content = FactContent::new(
FactContentKind::from(proposal.content().kind),
proposal.content().content.clone(),
);
let fact = Fact::new(
FactId::new(format!("fact:{}", proposal.id())),
fact_content,
record,
Timestamp::now(),
);
Ok(fact)
}
}
#[derive(Debug, Clone)]
pub struct SimpleIntent {
pub description: String,
}
impl SimpleIntent {
pub fn new(description: impl Into<String>) -> Self {
Self {
description: description.into(),
}
}
}
impl ProposalLifecycle<SimpleIntent, Proposal<Draft>, ValidatedProposal, Fact> for PromotionGate {
fn validate(
&self,
_intent: &SimpleIntent,
proposal: Proposal<Draft>,
) -> Result<ValidatedProposal, ValidationError> {
self.validate_proposal(proposal, &ValidationContext::default())
}
fn promote(&self, validated: ValidatedProposal) -> Result<Fact, PromotionError> {
let approver = Actor::system("promotion-gate");
let evidence = vec![];
let trace = TraceLink::local(LocalTrace::new("auto-promote", "gate-promote"));
self.promote_to_fact(validated, approver, evidence, trace)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
CaptureContext, ContentHash, ObservationId, ObservationProvenance, ProposedContent,
ProposedContentKind,
};
fn make_draft_proposal(id: &str, content: &str) -> Proposal<Draft> {
Proposal::new(
ProposalId::new(id),
ProposedContent::new(ProposedContentKind::Claim, content),
ObservationProvenance::new(
ObservationId::new("obs-001"),
ContentHash::zero(),
CaptureContext::default(),
),
)
}
#[test]
fn gate_creation() {
let gate = PromotionGate::new(
GateId::new("test-gate"),
ValidationPolicy::new().with_required_check("schema_valid"),
);
assert_eq!(gate.gate_id().as_str(), "test-gate");
}
#[test]
fn successful_validation() {
let gate = PromotionGate::new(GateId::new("test-gate"), ValidationPolicy::new());
let proposal = make_draft_proposal("prop-001", "This is a valid claim");
let context = ValidationContext::new();
let validated = gate.validate_proposal(proposal, &context).unwrap();
assert_eq!(validated.id().as_str(), "prop-001");
assert!(validated.report().all_passed());
}
#[test]
fn failed_validation_empty_content() {
let gate = PromotionGate::new(GateId::new("test-gate"), ValidationPolicy::new());
let proposal = make_draft_proposal("prop-002", ""); let context = ValidationContext::new();
let result = gate.validate_proposal(proposal, &context);
assert!(result.is_err());
match result {
Err(ValidationError::CheckFailed { name, .. }) => {
assert!(name.contains("content_not_empty"));
}
_ => panic!("expected CheckFailed error"),
}
}
#[test]
fn successful_promotion() {
let gate = PromotionGate::new(GateId::new("test-gate"), ValidationPolicy::new());
let proposal = make_draft_proposal("prop-003", "Valid claim");
let context = ValidationContext::new();
let validated = gate.validate_proposal(proposal, &context).unwrap();
let fact = gate
.promote_to_fact(
validated,
Actor::system("test-engine"),
vec![EvidenceRef::observation(ObservationId::new("obs-001"))],
TraceLink::local(LocalTrace::new("trace-001", "span-001")),
)
.unwrap();
assert_eq!(fact.id().as_str(), "fact:prop-003");
assert_eq!(fact.content().content, "Valid claim");
assert_eq!(fact.promotion_record().gate_id.as_str(), "test-gate");
assert!(fact.is_replay_eligible());
}
#[test]
fn proposal_lifecycle_trait() {
let gate = PromotionGate::new(GateId::new("lifecycle-gate"), ValidationPolicy::new());
let intent = SimpleIntent::new("test intent");
let proposal = make_draft_proposal("prop-004", "Lifecycle test");
let validated = gate.validate(&intent, proposal).unwrap();
let fact = gate.promote(validated).unwrap();
assert_eq!(fact.id().as_str(), "fact:prop-004");
assert_eq!(fact.content().content, "Lifecycle test");
}
#[test]
fn policy_required_checks_run() {
let gate = PromotionGate::new(
GateId::new("policy-gate"),
ValidationPolicy::new()
.with_required_check("schema_valid")
.with_required_check("confidence_threshold"),
);
let proposal = make_draft_proposal("prop-005", "Policy test");
let context = ValidationContext::new();
let validated = gate.validate_proposal(proposal, &context).unwrap();
let check_names: Vec<_> = validated
.report()
.checks()
.iter()
.map(|c| c.name.as_str())
.collect();
assert!(check_names.contains(&"schema_valid"));
assert!(check_names.contains(&"confidence_threshold"));
assert!(check_names.contains(&"content_not_empty"));
}
#[test]
fn validated_proposal_debug() {
let gate = PromotionGate::new(GateId::new("debug-gate"), ValidationPolicy::new());
let proposal = make_draft_proposal("prop-006", "Debug test");
let context = ValidationContext::new();
let validated = gate.validate_proposal(proposal, &context).unwrap();
let debug = format!("{:?}", validated);
assert!(debug.contains("ValidatedProposal"));
assert!(debug.contains("prop-006"));
}
#[test]
fn fact_content_kind_conversion() {
use crate::types::{FactContentKind, ProposedContentKind};
assert_eq!(
FactContentKind::from(ProposedContentKind::Claim),
FactContentKind::Claim
);
assert_eq!(
FactContentKind::from(ProposedContentKind::Plan),
FactContentKind::Plan
);
assert_eq!(
FactContentKind::from(ProposedContentKind::Draft),
FactContentKind::Document
);
assert_eq!(
FactContentKind::from(ProposedContentKind::Reasoning),
FactContentKind::Reasoning
);
}
}