//! Explicit maintenance outcomes, objective dispositions, and attempt lifecycle.
//!
//! Separates attempt execution outcome from durable objective disposition.
//! Objectives are only retired when supported by verifiable receipts;
//! missing evidence or unrelated activations leave work unresolved and resumable.
use anyhow::{Context, Result};
use klbr_runtime::{
protocol::OperationId,
store::Store,
work::{RetireResult, WorkItem},
};
use serde::{Deserialize, Serialize};
/// Explicit domain assessment outcome for a maintenance attempt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "kebab-case")]
pub enum MaintenanceDisposition {
/// Work cannot proceed without missing evidence; stays unresolved and resumable.
NeedsEvidence {
missing_refs: Vec<String>,
revisit_condition: String,
},
/// Explicit supported assessment that no change is warranted.
NoChange {
reason: String,
evidence_refs: Vec<String>,
},
/// Objective retained but deferred until a checkpoint or next trigger.
Deferred {
reason: String,
trigger_ms: Option<i64>,
},
/// Attempt ended without establishing resolution; objective remains open.
Unresolved { reason: String },
/// Candidate failed validation or checks; failure recorded and follow-up enabled.
CandidateFailed {
error: String,
candidate_revision: Option<String>,
},
/// Candidate was activated for this specific work item; remains under observation.
Activated {
work_item_id: String,
candidate_revision: String,
epoch: i64,
check_receipts: Vec<String>,
},
}
impl MaintenanceDisposition {
pub fn is_activated(&self) -> bool {
matches!(self, Self::Activated { .. })
}
pub fn is_needs_evidence(&self) -> bool {
matches!(self, Self::NeedsEvidence { .. })
}
pub fn is_no_change(&self) -> bool {
matches!(self, Self::NoChange { .. })
}
pub fn is_candidate_failed(&self) -> bool {
matches!(self, Self::CandidateFailed { .. })
}
pub fn is_deferred(&self) -> bool {
matches!(self, Self::Deferred { .. })
}
pub fn is_unresolved(&self) -> bool {
matches!(self, Self::Unresolved { .. })
}
/// Canonical serialized string representation for storage in work_items.disposition.
pub fn canonical_disposition_string(&self) -> String {
match self {
Self::Activated {
candidate_revision,
epoch,
check_receipts,
..
} => {
if check_receipts.is_empty() {
format!("activated:revision={candidate_revision}:epoch={epoch}")
} else {
format!(
"activated:revision={candidate_revision}:epoch={epoch}:checks={}",
check_receipts.join(",")
)
}
}
Self::NoChange { reason, .. } => format!("no-change: {reason}"),
Self::Unresolved { reason } => format!("unresolved: {reason}"),
Self::NeedsEvidence {
missing_refs,
revisit_condition,
} => {
format!(
"needs-evidence: missing={}; condition={}",
missing_refs.join(","),
revisit_condition
)
}
Self::CandidateFailed { error, .. } => format!("candidate-failed: {error}"),
Self::Deferred { reason, .. } => format!("deferred: {reason}"),
}
}
}
/// Facts observed for one maintenance attempt.
pub struct AttemptFacts {
pub initial_epoch: i64,
pub final_epoch: i64,
pub final_revision: String,
pub activated_candidate_for_work: bool,
pub check_receipts: Vec<String>,
pub missing_evidence: Vec<String>,
pub explicit_assessment: Option<MaintenanceDisposition>,
pub last_error: Option<String>,
}
/// An epoch increase alone is not proof of benefit: only a linked candidate
/// activation or explicit assessment can resolve the objective.
pub fn evaluate_attempt_disposition(
work_item: &WorkItem,
facts: AttemptFacts,
) -> MaintenanceDisposition {
if let Some(explicit) = facts.explicit_assessment {
if explicit.is_activated()
&& (!facts.activated_candidate_for_work || facts.final_epoch <= facts.initial_epoch)
{
return MaintenanceDisposition::CandidateFailed {
error: "assessment claimed activated, but candidate was not activated for this work item"
.to_string(),
candidate_revision: None,
};
}
return explicit;
}
if !facts.missing_evidence.is_empty() {
return MaintenanceDisposition::NeedsEvidence {
missing_refs: facts.missing_evidence,
revisit_condition: "awaiting unresolvable source evidence".to_string(),
};
}
if facts.activated_candidate_for_work && facts.final_epoch > facts.initial_epoch {
return MaintenanceDisposition::Activated {
work_item_id: work_item.id.clone(),
candidate_revision: facts.final_revision,
epoch: facts.final_epoch,
check_receipts: facts.check_receipts,
};
}
if let Some(error) = facts.last_error {
return MaintenanceDisposition::CandidateFailed {
error,
candidate_revision: None,
};
}
let reason = if facts.final_epoch > facts.initial_epoch {
"unrelated hook activation observed during attempt; objective unchanged and unresolved"
} else {
"maintenance attempt finished without candidate activation or explicit assessment"
};
MaintenanceDisposition::Unresolved {
reason: reason.to_string(),
}
}
/// Applies the evaluated disposition to the persistent Store.
///
/// NeedsEvidence and Unresolved retire the attempt while leaving the objective open
/// and not immediately runnable without evidence or a due revisit.
/// Activated/NoChange complete the work item with an explicit receipt.
/// CandidateFailed marks it failed with failure diagnosis retained.
pub async fn apply_maintenance_disposition(
store: &Store,
work_item: &WorkItem,
attempt_id: &OperationId,
disposition: &MaintenanceDisposition,
now_ms: i64,
) -> Result<RetireResult> {
match disposition {
MaintenanceDisposition::Activated { .. } | MaintenanceDisposition::NoChange { .. } => {
let disp_str = disposition.canonical_disposition_string();
store
.complete_work_item(&work_item.id, attempt_id, &disp_str, now_ms)
.await
.context("failed to complete work item with disposition")
}
MaintenanceDisposition::NeedsEvidence {
missing_refs,
revisit_condition,
} => {
// Objective stays unresolved and not immediately runnable without new evidence:
// Defer the claim so the current attempt is retired with the blocked revisit gate.
// Do NOT immediately call reopen_work_item back to ready.
let reason = format!(
"needs-evidence: missing={}; condition={}",
missing_refs.join(","),
revisit_condition
);
let gate = klbr_runtime::store::RevisitGate::blocked_on(revisit_condition);
store
.defer_work_item_with_revisit(&work_item.id, attempt_id, &reason, &gate)
.await
.context("failed to defer work item needing evidence")
}
MaintenanceDisposition::Unresolved { reason } => {
let unres_str = format!("unresolved: {reason}");
let gate = klbr_runtime::store::RevisitGate::blocked_on(reason);
store
.defer_work_item_with_revisit(&work_item.id, attempt_id, &unres_str, &gate)
.await
.context("failed to defer unresolved work item")
}
MaintenanceDisposition::CandidateFailed { error, .. } => {
let err_str = format!("candidate-failed: {error}");
store
.fail_work_item(&work_item.id, attempt_id, &err_str, now_ms)
.await
.context("failed to retire work item as failed candidate")
}
MaintenanceDisposition::Deferred { reason, trigger_ms } => {
let def_str = format!("deferred: {reason}");
let gate = match trigger_ms {
Some(ms) => klbr_runtime::store::RevisitGate::at(*ms),
None => klbr_runtime::store::RevisitGate::blocked_on(reason),
};
store
.defer_work_item_with_revisit(&work_item.id, attempt_id, &def_str, &gate)
.await
.context("failed to defer work item")
}
}
}
/// Traverses session event history through proper pagination to discover the true
/// latest committed event ID (high-watermark) at turn start, avoiding the first-page cap.
pub async fn session_head_event_id(
store: &Store,
session_id: &klbr_runtime::protocol::SessionId,
) -> i64 {
let mut cursor = 0i64;
loop {
match store.history(session_id, cursor, 1024).await {
Ok(events) if !events.is_empty() => {
let count = events.len();
if let Some(last) = events.last() {
cursor = last.id;
}
if count < 1024 {
break;
}
}
_ => break,
}
}
cursor
}
/// Reads all committed events after the high-watermark, paginating through all available
/// pages so attempts with large event volume or history never drop events.
pub async fn read_events_after(
store: &Store,
session_id: &klbr_runtime::protocol::SessionId,
after: i64,
) -> Vec<klbr_runtime::store::Event> {
let mut all_events = Vec::new();
let mut cursor = after;
loop {
match store.history(session_id, cursor, 1024).await {
Ok(events) if !events.is_empty() => {
let count = events.len();
if let Some(last) = events.last() {
cursor = last.id;
}
all_events.extend(events);
if count < 1024 {
break;
}
}
_ => break,
}
}
all_events
}
/// Checks whether a genuine hook activation during the attempt was specifically bound
/// to the target work item id through the recorded event link.
pub fn activation_bound_to_work(events: &[klbr_runtime::store::Event], work_item_id: &str) -> bool {
events.iter().any(|ev| {
ev.kind == "hooks.activated"
&& ev.payload.get("work_item_id").and_then(|v| v.as_str()) == Some(work_item_id)
})
}