use cortex_core::{
compose_policy_outcomes, BreakGlassAuthorization, BreakGlassReasonCode, BreakGlassScope,
PolicyContribution, PolicyDecision, PolicyOutcome,
};
use cortex_store::semantic_diff::{
RestoreDecision, SemanticChangeKind, SemanticDiff, SemanticSeverity,
};
use serde_json::json;
pub const SEMANTIC_DIFF_RECOVERY_DRIFT_RULE_ID: &str = "restore.semantic_diff.recovery_drift";
pub const SEMANTIC_DIFF_SALIENCE_DRIFT_RULE_ID: &str = "restore.semantic_diff.salience_drift";
pub const SEMANTIC_DIFF_CONTRADICTION_DRIFT_RULE_ID: &str =
"restore.semantic_diff.contradiction_drift";
pub const PREFLIGHT_MANIFEST_DIGEST_RULE_ID: &str = "restore.preflight.manifest_digest";
pub const PREFLIGHT_SEMANTIC_DIFF_RULE_ID: &str = "restore.preflight.semantic_diff";
pub const PREFLIGHT_PRODUCTION_ACTIVE_STORE_PLAN_RULE_ID: &str =
"restore.preflight.production_active_store_plan";
pub const STAGE_DESTRUCTIVE_INTENT_RULE_ID: &str = "restore.stage.destructive_intent";
pub const STAGE_MANIFEST_DIGEST_RULE_ID: &str = "restore.stage.manifest_digest";
pub const STAGE_SEMANTIC_DIFF_RULE_ID: &str = "restore.stage.semantic_diff";
pub const APPLY_STAGE_POST_RESTORE_ANCHOR_RULE_ID: &str = "restore.apply_stage.post_restore_anchor";
pub const APPLY_STAGE_OPERATOR_TEMPORAL_AUTHORITY_RULE_ID: &str =
"restore.apply_stage.operator_temporal_authority";
pub const RECOVER_APPLY_RECOVERY_MANIFEST_DIGEST_RULE_ID: &str =
"restore.recover_apply.recovery_manifest_digest";
pub const RECOVER_APPLY_CURRENT_BACKUP_DIGEST_RULE_ID: &str =
"restore.recover_apply.current_backup_digest";
pub const RECOVER_APPLY_OPERATOR_TEMPORAL_AUTHORITY_RULE_ID: &str =
"restore.recover_apply.operator_temporal_authority";
#[derive(Debug)]
pub struct SemanticDiffPolicy {
pub decision: PolicyDecision,
pub legacy: RestoreDecision,
}
#[must_use]
pub fn compose_semantic_diff_decision(
diff: &SemanticDiff,
acknowledge_recovery_risk: bool,
operation_type: &str,
artifact_ref: &str,
) -> SemanticDiffPolicy {
let recovery_drift =
contribution_for_recovery_drift(diff, SEMANTIC_DIFF_RECOVERY_DRIFT_RULE_ID);
let salience_drift =
contribution_for_salience_drift(diff, SEMANTIC_DIFF_SALIENCE_DRIFT_RULE_ID);
let contradiction_drift =
contribution_for_contradiction_drift(diff, SEMANTIC_DIFF_CONTRADICTION_DRIFT_RULE_ID);
let break_glass = if acknowledge_recovery_risk {
Some(restore_recovery_break_glass(operation_type, artifact_ref))
} else {
None
};
let decision = compose_policy_outcomes(
vec![recovery_drift, salience_drift, contradiction_drift],
break_glass,
);
let legacy = diff.restore_decision(acknowledge_recovery_risk);
SemanticDiffPolicy { decision, legacy }
}
#[must_use]
pub fn compose_preflight_decision(
semantic_diff_decision: Option<&PolicyDecision>,
production_active_store_plan_requested: bool,
) -> PolicyDecision {
let mut contributions = vec![contribution_allow(
PREFLIGHT_MANIFEST_DIGEST_RULE_ID,
"structural manifest digest verified",
)];
if let Some(semantic) = semantic_diff_decision {
contributions.push(contribution(
PREFLIGHT_SEMANTIC_DIFF_RULE_ID,
semantic.final_outcome,
semantic_diff_reason(semantic),
));
} else {
contributions.push(contribution_allow(
PREFLIGHT_SEMANTIC_DIFF_RULE_ID,
"semantic diff not executed (no candidate store)",
));
}
if production_active_store_plan_requested {
contributions.push(contribution_reject(
PREFLIGHT_PRODUCTION_ACTIVE_STORE_PLAN_RULE_ID,
"production active-store restore plan is fail-closed until production gates land",
));
}
compose_policy_outcomes(contributions, None)
}
#[must_use]
pub fn compose_stage_decision(
acknowledge_destructive_restore: bool,
semantic_diff_decision: Option<&PolicyDecision>,
acknowledge_recovery_risk: bool,
artifact_ref: &str,
) -> PolicyDecision {
let destructive_intent = if acknowledge_destructive_restore {
contribution_allow(
STAGE_DESTRUCTIVE_INTENT_RULE_ID,
"operator acknowledged destructive restore intent",
)
} else {
contribution_reject(
STAGE_DESTRUCTIVE_INTENT_RULE_ID,
"--acknowledge-destructive-restore is required",
)
};
let manifest_digest = contribution_allow(
STAGE_MANIFEST_DIGEST_RULE_ID,
"backup manifest digest verified",
);
let semantic_diff = match semantic_diff_decision {
Some(decision) => contribution(
STAGE_SEMANTIC_DIFF_RULE_ID,
decision.final_outcome,
semantic_diff_reason(decision),
),
None => contribution_allow(
STAGE_SEMANTIC_DIFF_RULE_ID,
"semantic diff not yet executed at this stage",
),
};
let break_glass = if acknowledge_recovery_risk {
Some(restore_recovery_break_glass("restore.stage", artifact_ref))
} else {
None
};
compose_policy_outcomes(
vec![destructive_intent, manifest_digest, semantic_diff],
break_glass,
)
}
#[must_use]
pub fn compose_apply_decision(
post_restore_anchor: PolicyOutcome,
post_restore_anchor_reason: &str,
operator_temporal_authority: PolicyOutcome,
operator_temporal_authority_reason: &str,
acknowledge_recovery_risk: bool,
operation_type: &str,
artifact_ref: &str,
) -> PolicyDecision {
let anchor = contribution(
APPLY_STAGE_POST_RESTORE_ANCHOR_RULE_ID,
post_restore_anchor,
post_restore_anchor_reason,
);
let authority = contribution(
APPLY_STAGE_OPERATOR_TEMPORAL_AUTHORITY_RULE_ID,
operator_temporal_authority,
operator_temporal_authority_reason,
);
let break_glass = if acknowledge_recovery_risk {
Some(restore_recovery_break_glass(operation_type, artifact_ref))
} else {
None
};
compose_policy_outcomes(vec![anchor, authority], break_glass)
}
#[must_use]
pub fn compose_recover_apply_decision(
recovery_manifest_digest_ok: bool,
current_backup_digest_ok: bool,
operator_temporal_authority_ok: bool,
acknowledge_recovery_risk: bool,
artifact_ref: &str,
) -> PolicyDecision {
let manifest_digest = if recovery_manifest_digest_ok {
contribution_allow(
RECOVER_APPLY_RECOVERY_MANIFEST_DIGEST_RULE_ID,
"recovery manifest digest verified",
)
} else {
contribution_reject(
RECOVER_APPLY_RECOVERY_MANIFEST_DIGEST_RULE_ID,
"recovery manifest digest verification failed",
)
};
let backup_digest = if current_backup_digest_ok {
contribution_allow(
RECOVER_APPLY_CURRENT_BACKUP_DIGEST_RULE_ID,
"current backup artifact digests verified",
)
} else {
contribution_reject(
RECOVER_APPLY_CURRENT_BACKUP_DIGEST_RULE_ID,
"current backup artifact digest verification failed",
)
};
let authority = if operator_temporal_authority_ok {
contribution_allow(
RECOVER_APPLY_OPERATOR_TEMPORAL_AUTHORITY_RULE_ID,
"operator temporal authority within validity",
)
} else {
contribution_reject(
RECOVER_APPLY_OPERATOR_TEMPORAL_AUTHORITY_RULE_ID,
"operator temporal authority unverified",
)
};
let break_glass = if acknowledge_recovery_risk {
Some(restore_recovery_break_glass(
"restore.recover_apply",
artifact_ref,
))
} else {
None
};
compose_policy_outcomes(vec![manifest_digest, backup_digest, authority], break_glass)
}
#[must_use]
pub fn restore_recovery_break_glass(
operation_type: &str,
artifact_ref: &str,
) -> BreakGlassAuthorization {
BreakGlassAuthorization {
permitted: true,
attested: true,
scope: BreakGlassScope {
operation_type: operation_type.to_string(),
artifact_refs: vec![artifact_ref.to_string()],
not_before: None,
not_after: None,
},
reason_code: BreakGlassReasonCode::RestoreRecovery,
}
}
#[must_use]
pub fn policy_decision_report(decision: &PolicyDecision) -> serde_json::Value {
let contributing: Vec<_> = decision
.contributing
.iter()
.map(|c| {
json!({
"rule_id": c.rule_id.as_str(),
"outcome": c.outcome,
"reason": c.reason,
})
})
.collect();
let discarded: Vec<_> = decision
.discarded
.iter()
.map(|c| {
json!({
"rule_id": c.rule_id.as_str(),
"outcome": c.outcome,
"reason": c.reason,
})
})
.collect();
json!({
"final_outcome": decision.final_outcome,
"contributing": contributing,
"discarded": discarded,
"break_glass": decision.break_glass.as_ref().map(|bg| json!({
"permitted": bg.permitted,
"attested": bg.attested,
"reason_code": bg.reason_code,
"operation_type": bg.scope.operation_type,
"artifact_refs": bg.scope.artifact_refs,
"not_before": bg.scope.not_before,
"not_after": bg.scope.not_after,
})),
})
}
fn contribution(
rule_id: &str,
outcome: PolicyOutcome,
reason: impl Into<String>,
) -> PolicyContribution {
let reason = reason.into();
let overridable = !matches!(outcome, PolicyOutcome::Allow);
let mut c = PolicyContribution::new(rule_id, outcome, reason)
.expect("static restore policy contribution");
if overridable {
c = c.allow_break_glass_override();
}
c
}
fn contribution_allow(rule_id: &str, reason: &str) -> PolicyContribution {
PolicyContribution::new(rule_id, PolicyOutcome::Allow, reason)
.expect("static restore policy contribution")
}
fn contribution_reject(rule_id: &str, reason: &str) -> PolicyContribution {
PolicyContribution::new(rule_id, PolicyOutcome::Reject, reason)
.expect("static restore policy contribution")
}
fn contribution_drift_warn(rule_id: &str, reason: &str) -> PolicyContribution {
contribution(rule_id, PolicyOutcome::Warn, reason)
}
fn contribution_for_salience_drift(diff: &SemanticDiff, rule_id: &str) -> PolicyContribution {
let salience_changed = diff.changes.iter().any(|change| {
matches!(
change.kind,
SemanticChangeKind::SalienceDistributionChanged { .. }
)
});
if salience_changed {
contribution_drift_warn(rule_id, "salience distribution drift observed")
} else {
contribution_allow(rule_id, "no salience distribution drift observed")
}
}
fn contribution_for_contradiction_drift(diff: &SemanticDiff, rule_id: &str) -> PolicyContribution {
let contradiction_changed = diff.changes.iter().any(|change| {
matches!(
change.kind,
SemanticChangeKind::UnresolvedContradictionMissing { .. }
| SemanticChangeKind::UnresolvedContradictionAdded { .. }
| SemanticChangeKind::UnresolvedContradictionChanged { .. }
)
});
if contradiction_changed {
contribution_drift_warn(rule_id, "contradiction snapshot drift observed")
} else {
contribution_allow(rule_id, "no contradiction snapshot drift observed")
}
}
fn contribution_for_recovery_drift(diff: &SemanticDiff, rule_id: &str) -> PolicyContribution {
let severity = diff.severity();
let (outcome, reason): (PolicyOutcome, String) = match severity {
SemanticSeverity::Clean => (
PolicyOutcome::Allow,
"no recovery drift observed".to_string(),
),
SemanticSeverity::Warning => (
PolicyOutcome::Warn,
format!(
"recovery drift warning across {} change(s)",
diff.changes.len()
),
),
SemanticSeverity::PreconditionUnmet => (
PolicyOutcome::Reject,
"recovery drift precondition unmet; ADR 0033 §5 mandates Reject".to_string(),
),
};
contribution(rule_id, outcome, reason)
}
fn semantic_diff_reason(decision: &PolicyDecision) -> String {
match decision.final_outcome {
PolicyOutcome::Allow => "semantic diff clean".to_string(),
PolicyOutcome::Warn => "semantic diff warning (drift acknowledged)".to_string(),
PolicyOutcome::BreakGlass => {
"semantic diff warning elevated to BreakGlass via --acknowledge-recovery-risk"
.to_string()
}
PolicyOutcome::Quarantine => "semantic diff quarantine".to_string(),
PolicyOutcome::Reject => "semantic diff rejected drift".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use cortex_store::semantic_diff::SemanticSnapshot;
fn clean_diff() -> SemanticDiff {
let snap = SemanticSnapshot::default();
snap.diff_against_restore(&snap)
}
#[test]
fn clean_semantic_diff_yields_allow() {
let diff = clean_diff();
let policy = compose_semantic_diff_decision(&diff, false, "restore.semantic_diff", "test");
assert_eq!(policy.decision.final_outcome, PolicyOutcome::Allow);
assert!(policy.decision.break_glass.is_none());
assert!(matches!(policy.legacy, RestoreDecision::Clean));
}
#[test]
fn preflight_with_production_plan_is_reject() {
let semantic = compose_semantic_diff_decision(&clean_diff(), false, "x", "y").decision;
let decision = compose_preflight_decision(Some(&semantic), true);
assert_eq!(decision.final_outcome, PolicyOutcome::Reject);
}
#[test]
fn preflight_without_production_plan_is_allow_when_clean() {
let semantic = compose_semantic_diff_decision(&clean_diff(), false, "x", "y").decision;
let decision = compose_preflight_decision(Some(&semantic), false);
assert_eq!(decision.final_outcome, PolicyOutcome::Allow);
}
#[test]
fn stage_without_destructive_intent_is_reject() {
let semantic = compose_semantic_diff_decision(&clean_diff(), false, "x", "y").decision;
let decision = compose_stage_decision(false, Some(&semantic), false, "stage_dir:test");
assert_eq!(decision.final_outcome, PolicyOutcome::Reject);
}
#[test]
fn recover_apply_break_glass_elevates_warn_only() {
let decision = compose_recover_apply_decision(true, true, true, true, "manifest:test");
assert_eq!(decision.final_outcome, PolicyOutcome::Allow);
}
}