use crate::contract::events::{
BoundaryDispositionEvent, BoundaryRecoveryEvent, BoundaryReportEvent, BoundaryStartedEvent,
DispositionAction, DispositionPhase,
};
use crate::contract::ids::{ArtifactId, AttemptId, BoundaryPlanHash};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum RecoveryClassification {
Completed,
RolledBack,
CanonicalRefusal,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct QuarantineRecord {
pub kind: String,
pub reference: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DispositionState {
pub decided: Option<DispositionAction>,
pub applied: bool,
pub failed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunView {
pub attempt: AttemptId,
pub plan_id: BoundaryPlanHash,
pub started: bool,
pub reported: bool,
pub recovery_recorded: bool,
pub dispositions: BTreeMap<ArtifactId, DispositionState>,
}
impl RunView {
#[must_use]
pub fn new(attempt: AttemptId, plan_id: BoundaryPlanHash) -> Self {
Self {
attempt,
plan_id,
started: false,
reported: false,
recovery_recorded: false,
dispositions: BTreeMap::new(),
}
}
pub fn observe_started(&mut self, _event: &BoundaryStartedEvent) {
self.started = true;
}
pub fn observe_report(&mut self, _event: &BoundaryReportEvent) {
self.reported = true;
}
pub fn observe_recovery(&mut self, _event: &BoundaryRecoveryEvent) {
self.recovery_recorded = true;
}
pub fn observe_disposition(&mut self, event: &BoundaryDispositionEvent) {
let state = self.dispositions.entry(event.artifact).or_default();
match &event.phase {
DispositionPhase::Decided { action } => state.decided = Some(*action),
DispositionPhase::Applied { .. } => state.applied = true,
DispositionPhase::Failed { .. } => state.failed = true,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ArtifactReality {
pub quarantined_bytes_present: bool,
pub promoted_bytes_present: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RecoveryProbe {
pub orphans: Vec<QuarantineRecord>,
pub torn_report: bool,
pub artifacts: BTreeMap<ArtifactId, ArtifactReality>,
}
impl RecoveryProbe {
#[must_use]
pub fn any_promoted(&self) -> bool {
self.artifacts.values().any(|a| a.promoted_bytes_present)
}
#[must_use]
fn for_artifact(&self, artifact: ArtifactId) -> ArtifactReality {
self.artifacts.get(&artifact).copied().unwrap_or_default()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ArtifactFix {
pub artifact: ArtifactId,
pub action: DispositionAction,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum RecoveryAction {
Absent,
Completed,
RollBack {
orphans: Vec<QuarantineRecord>,
},
FinishDisposition {
artifacts: Vec<ArtifactFix>,
},
CanonicalRefusal {
reason: String,
},
}
impl RecoveryAction {
#[must_use]
pub fn classification(&self) -> Option<RecoveryClassification> {
match self {
Self::Absent => None,
Self::Completed | Self::FinishDisposition { .. } => {
Some(RecoveryClassification::Completed)
}
Self::RollBack { .. } => Some(RecoveryClassification::RolledBack),
Self::CanonicalRefusal { .. } => Some(RecoveryClassification::CanonicalRefusal),
}
}
}
#[must_use]
pub fn reconcile(view: &RunView, probe: &RecoveryProbe) -> RecoveryAction {
if !view.started {
return RecoveryAction::Absent;
}
if probe.torn_report {
return RecoveryAction::CanonicalRefusal {
reason: "torn report frame on disk".to_string(),
};
}
if !view.reported {
if probe.any_promoted() {
return RecoveryAction::CanonicalRefusal {
reason: "promoted bytes with no sealed report (sacred window)".to_string(),
};
}
return RecoveryAction::RollBack {
orphans: probe.orphans.clone(),
};
}
let mut fixes = Vec::new();
for (artifact, state) in &view.dispositions {
match reconcile_disposition(state, probe.for_artifact(*artifact)) {
DispositionOutcome::Settled => {}
DispositionOutcome::Finish(action) => fixes.push(ArtifactFix {
artifact: *artifact,
action,
}),
DispositionOutcome::Refuse(reason) => {
return RecoveryAction::CanonicalRefusal { reason }
}
}
}
if fixes.is_empty() {
RecoveryAction::Completed
} else {
RecoveryAction::FinishDisposition { artifacts: fixes }
}
}
enum DispositionOutcome {
Settled,
Finish(DispositionAction),
Refuse(String),
}
fn reconcile_disposition(state: &DispositionState, reality: ArtifactReality) -> DispositionOutcome {
let Some(action) = state.decided else {
if reality.promoted_bytes_present {
return DispositionOutcome::Refuse(
"artifact promoted with no durable disposition decision".to_string(),
);
}
return DispositionOutcome::Settled;
};
if state.failed {
return DispositionOutcome::Settled;
}
match action {
DispositionAction::Promote => {
if state.applied && reality.promoted_bytes_present {
DispositionOutcome::Settled
} else {
DispositionOutcome::Finish(DispositionAction::Promote)
}
}
DispositionAction::Discard => {
if reality.promoted_bytes_present {
DispositionOutcome::Refuse(
"artifact promoted but disposition decided Discard".to_string(),
)
} else if state.applied {
DispositionOutcome::Settled
} else {
DispositionOutcome::Finish(DispositionAction::Discard)
}
}
DispositionAction::Retain | DispositionAction::Refuse => {
if reality.promoted_bytes_present {
DispositionOutcome::Refuse(
"artifact promoted but disposition decided Retain/Refuse".to_string(),
)
} else {
DispositionOutcome::Settled
}
}
}
}
#[cfg(test)]
mod reconcile_tests {
use super::{
reconcile, ArtifactFix, ArtifactReality, DispositionState, RecoveryAction,
RecoveryClassification, RunView,
};
use crate::contract::events::{BoundaryDispositionEvent, DispositionAction, DispositionPhase};
use crate::contract::host_control::CommitDurability;
use crate::contract::ids::{ArtifactId, AttemptId, BoundaryPlanHash};
use crate::contract::recovery::QuarantineRecord;
fn att() -> AttemptId {
AttemptId([1u8; 32])
}
fn pid() -> BoundaryPlanHash {
BoundaryPlanHash([2u8; 32])
}
fn aid(n: u8) -> ArtifactId {
ArtifactId([n; 32])
}
fn reported_view() -> RunView {
let mut view = RunView::new(att(), pid());
view.started = true;
view.reported = true;
view
}
fn orphan() -> QuarantineRecord {
QuarantineRecord {
kind: "process".to_string(),
reference: "pid:4242".to_string(),
}
}
#[test]
fn absent_when_never_started() {
let view = RunView::new(att(), pid());
let action = reconcile(&view, &super::RecoveryProbe::default());
assert_eq!(action, RecoveryAction::Absent);
assert_eq!(action.classification(), None);
}
#[test]
fn rollback_when_started_without_report() {
let mut view = RunView::new(att(), pid());
view.started = true;
let probe = super::RecoveryProbe {
orphans: vec![orphan()],
..Default::default()
};
let action = reconcile(&view, &probe);
assert_eq!(
action,
RecoveryAction::RollBack {
orphans: vec![orphan()]
}
);
assert_eq!(
action.classification(),
Some(RecoveryClassification::RolledBack)
);
}
#[test]
fn canonical_refusal_on_torn_report() {
let mut view = RunView::new(att(), pid());
view.started = true;
let probe = super::RecoveryProbe {
torn_report: true,
orphans: vec![orphan()],
..Default::default()
};
assert!(matches!(
reconcile(&view, &probe),
RecoveryAction::CanonicalRefusal { .. }
));
}
#[test]
fn sacred_window_refuses_promoted_bytes_without_report() {
let mut view = RunView::new(att(), pid());
view.started = true;
let mut probe = super::RecoveryProbe::default();
probe.artifacts.insert(
aid(9),
ArtifactReality {
promoted_bytes_present: true,
..Default::default()
},
);
let action = reconcile(&view, &probe);
assert!(matches!(action, RecoveryAction::CanonicalRefusal { .. }));
assert_eq!(
action.classification(),
Some(RecoveryClassification::CanonicalRefusal)
);
}
#[test]
fn completed_when_reported_with_no_dispositions() {
let action = reconcile(&reported_view(), &super::RecoveryProbe::default());
assert_eq!(action, RecoveryAction::Completed);
assert_eq!(
action.classification(),
Some(RecoveryClassification::Completed)
);
}
#[test]
fn finish_disposition_when_promote_decided_not_applied() {
let mut view = reported_view();
view.dispositions.insert(
aid(3),
DispositionState {
decided: Some(DispositionAction::Promote),
applied: false,
failed: false,
},
);
let action = reconcile(&view, &super::RecoveryProbe::default());
assert_eq!(
action,
RecoveryAction::FinishDisposition {
artifacts: vec![ArtifactFix {
artifact: aid(3),
action: DispositionAction::Promote,
}]
}
);
assert_eq!(
action.classification(),
Some(RecoveryClassification::Completed)
);
}
#[test]
fn settled_when_promote_applied_and_bytes_present() {
let mut view = reported_view();
view.dispositions.insert(
aid(3),
DispositionState {
decided: Some(DispositionAction::Promote),
applied: true,
failed: false,
},
);
let mut probe = super::RecoveryProbe::default();
probe.artifacts.insert(
aid(3),
ArtifactReality {
promoted_bytes_present: true,
..Default::default()
},
);
assert_eq!(reconcile(&view, &probe), RecoveryAction::Completed);
}
#[test]
fn finish_when_applied_but_promoted_bytes_missing() {
let mut view = reported_view();
view.dispositions.insert(
aid(3),
DispositionState {
decided: Some(DispositionAction::Promote),
applied: true,
failed: false,
},
);
assert!(matches!(
reconcile(&view, &super::RecoveryProbe::default()),
RecoveryAction::FinishDisposition { .. }
));
}
#[test]
fn refuse_discard_decision_contradicted_by_promoted_bytes() {
let mut view = reported_view();
view.dispositions.insert(
aid(3),
DispositionState {
decided: Some(DispositionAction::Discard),
applied: false,
failed: false,
},
);
let mut probe = super::RecoveryProbe::default();
probe.artifacts.insert(
aid(3),
ArtifactReality {
promoted_bytes_present: true,
..Default::default()
},
);
assert!(matches!(
reconcile(&view, &probe),
RecoveryAction::CanonicalRefusal { .. }
));
}
#[test]
fn refuse_promoted_bytes_with_no_decision() {
let mut view = reported_view();
view.dispositions
.insert(aid(3), DispositionState::default());
let mut probe = super::RecoveryProbe::default();
probe.artifacts.insert(
aid(3),
ArtifactReality {
promoted_bytes_present: true,
..Default::default()
},
);
assert!(matches!(
reconcile(&view, &probe),
RecoveryAction::CanonicalRefusal { .. }
));
}
#[test]
fn failed_disposition_settles() {
let mut view = reported_view();
view.dispositions.insert(
aid(3),
DispositionState {
decided: Some(DispositionAction::Promote),
applied: false,
failed: true,
},
);
assert_eq!(
reconcile(&view, &super::RecoveryProbe::default()),
RecoveryAction::Completed
);
}
#[test]
fn disposition_fold_accumulates_phases() {
let mut view = RunView::new(att(), pid());
view.observe_disposition(&BoundaryDispositionEvent {
plan_id: pid(),
attempt: att(),
artifact: aid(5),
phase: DispositionPhase::Decided {
action: DispositionAction::Promote,
},
});
view.observe_disposition(&BoundaryDispositionEvent {
plan_id: pid(),
attempt: att(),
artifact: aid(5),
phase: DispositionPhase::Applied {
durability: CommitDurability::Durable,
},
});
let state = view.dispositions.get(&aid(5)).expect("artifact folded");
assert_eq!(state.decided, Some(DispositionAction::Promote));
assert!(state.applied);
assert!(!state.failed);
}
}